Files
3x-ui/util/link/outbound.go
T
Rouzbeh† 0daedd3db9 feat: add support for subscription-based outbounds with auto-update (#5037)
* feat: add support for subscription-based outbounds with auto-update

- New OutboundSubscription model (full support on both SQLite and PostgreSQL)
- Go subscription link parser (vmess/vless/trojan/ss/hysteria2/wireguard) matching frontend behavior
- Stable tag assignment across refreshes (designed for balancer + routing use)
- Runtime merge of subscription outbounds into Xray config (additive only)
- Full CRUD + manual refresh + preview API
- Background auto-update job (per-subscription interval)
- Frontend management UI in Outbounds tab (Subscriptions drawer) + tag integration in balancers/routing rules
- Proper dual-database support including CLI migration path

Review & hardening notes:
- Fixed merge logic bug that could drop manual outbounds
- Added SSRF/private-IP protection on subscription URLs using SanitizePublicHTTPURL
- Improved update interval UX (hours + minutes)
- Auto-fetch on first subscription creation
- Added detailed comments on tag stability strategy and balancer implications when servers are added/removed/rotated
- Updated migrationModels() for CLI migrate-db support

* fix: resolve frontend lint/type errors and Go build break

Frontend (eslint + tsc clean):
- Destructure subscriptionOutboundTags prop in RoutingTab and
  BalancersTab. It was declared in the interface and used in useMemo
  but never destructured, so it resolved as an unresolved global
  (react-hooks warning + tsc "Cannot find name"). The prop is passed
  by XrayPage, so the feature was silently inert.
- OutboundsTab: remove unused useEffect import, add an OutboundSub
  type to replace any[] state and the any/any table render signature,
  type the subscriptionOutbounds cast, and replace unused catch (e)
  bindings with parameter-less catch. Also type HttpUtil.post as
  OutboundSub so r.obj?.id type-checks.

Backend (go build clean):
- outbound_subscription_job: websocket.MessageTypeXray is undefined;
  use the existing MessageTypeOutbounds since the job refreshes
  outbound subscriptions.

* fix(xray): make outbound subscription creation work end-to-end

- Correct API paths from /panel/xray/outbound-subs to
  /panel/api/xray/outbound-subs. The controller is mounted under
  /panel/api, so the old paths hit the SPA page route (GET-only)
  and 404'd on POST.
- Send the create-subscription body as a plain object instead of
  URLSearchParams. The axios request interceptor serializes bodies
  with qs.stringify, which can't read URLSearchParams' internal
  storage and produced an empty body, so the backend rejected it
  with "subscription URL is required".
- Use message.useMessage() + context holder instead of the static
  antd message API (resolves the "Static function can not consume
  context" warning), matching XrayPage's pattern.
- Migrate the subscriptions Drawer to antd v6 props: width -> size,
  destroyOnClose -> destroyOnHidden, and Space direction -> orientation.

* feat(xray): show traffic/test for subscription outbounds; harden + test the feature

Display (the reported issue):
- Replace the flat read-only pills with a proper read-only table (desktop)
  and cards (mobile) in a new SubscriptionOutbounds component, showing
  Address, Protocol, Traffic (matched by tag — already collected by Xray),
  and a Test button with Latency. No edit/delete/move (read-only).
- Test subscription outbounds via the existing /testOutbound endpoint, with
  results keyed by tag (subscriptionTestStates + testSubscriptionOutbound in
  useXraySetting, wired through XrayPage). Generalize isTesting/testResult to
  a string|number key so the same helpers serve index- and tag-keyed states.

i18n:
- Replace all hardcoded English subscription strings with t() calls and add
  pages.xray.outboundSub.* keys to en-US.json (other locales fall back).

Backend hardening + tests:
- xray.go: drop the tautological `subSvc != nil` check.
- outbound_subscription: re-validate every redirect hop against private/
  internal addresses (CheckRedirect) and cap the redirect chain, closing an
  SSRF gap where only the initial host was checked.
- Extract assignStableTags as a pure function and add unit tests for tag
  stability and SSRF rejection (the feature previously had no tests).

Misc:
- gofmt util/link/outbound.go (it was not gofmt-clean).

* fix(xray): make outbound-subs feature pass CI (test compile, route docs, openapi)

- outbound_test.go: remove unused `inner`/`lines` variables that broke the
  `util/link` test build (declared and not used).
- Document the 7 outbound-subscription routes in endpoints.ts (list, create,
  update, delete, del alias, refresh, parse) so TestAPIRoutesDocumented passes.
- Regenerate frontend/public/openapi.json (npm run gen) to include the new
  endpoints, satisfying the codegen freshness check.

* feat(xray): per-subscription allow-private, gap-filled tags, UI tweaks, delete refresh

Backend:
- Add a per-subscription AllowPrivate flag (default off). Create/Update/refresh
  and the redirect check sanitize the URL with it, so localhost/LAN sources work
  only when explicitly opted in; the SSRF guard still blocks private targets by
  default. Controller reads the allowPrivate form field on create/update/parse.
- Default outbound tag prefix now uses the smallest free "subN-" number instead
  of the auto-increment id, so deleting a subscription frees its number for reuse
  (a fresh start gives sub1) while staying stable per subscription. Extracted a
  pure defaultPrefixNumber() with unit tests.
- deleteOutboundSub now signals SetToNeedRestart so xray drops the outbounds.

Frontend:
- "Allow private address" toggle in the add form (sends allowPrivate).
- Delete now refreshes the xray view immediately (no manual page reload).
- Subscriptions manager opens as a centered Modal instead of a right-side Drawer.
- Move Outbounds to a top-level sidebar item under Nodes (out of Xray Configs).
- Collapse WARP/NordVPN into a "more" dropdown.
- Document the allowPrivate param in endpoints.ts.

* i18n(xray): translate outbound-subscription UI into all locales

- Translate the pages.xray.outboundSub.* strings (and allowPrivate label/hint)
  into all 12 non-English locales, matching each file's existing terminology.
- Remove the unused outboundSub.add ("Add subscription") key from every locale.

* feat(xray): subscription manager — edit, reorder/priority, status, preview, refresh-all

Backend:
- Per-subscription Priority + Prepend: subscriptions are ordered by Priority and
  placed before (Prepend) or after the manual template outbounds in the merge, so
  a subscription server can become the default. New Move(up/down) endpoint
  re-normalizes priorities; merge split into prepend/template/append.
- List now returns a derived OutboundCount and orders by priority, and strips the
  heavy LastFetchedOutbounds/LinkIdentities blobs from the list payload.
- Create/Update accept the prepend flag; new subs append at the end of priority.

Frontend (Outbound Subscriptions modal):
- Edit existing subscriptions (reuses the form + Update endpoint).
- Inline enable/disable Switch, Status column (OK / error tooltip), Outbounds
  count column, per-row refresh spinner, "Refresh all" button.
- Reorder (move up/down) controls + a "Before manual outbounds" toggle.
- Preview button: fetch+parse a URL via /parse without saving.
- Document the move route + prepend param in endpoints.ts; regenerate openapi.json.

* i18n(xray): translate new subscription-manager strings into all locales

Add the prepend/prependHint, preview/previewEmpty, refreshAll, statusOk and
toastUpdated keys to all 12 non-English locales, matching each file's terminology.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-08 18:09:53 +02:00

810 lines
21 KiB
Go

// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
// and subscription bodies (typically base64-encoded newline lists of such links).
// The output shape matches the wire format used by the panel's Xray template
// outbounds array so that parsed objects can be injected directly.
package link
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// Outbound is the minimal shape we emit for each parsed link.
// Extra fields (mux, etc.) are carried inside settings/streamSettings.
type Outbound map[string]any
// ParseResult holds a parsed outbound together with a stable identity string
// that can be used to correlate the same logical server across refreshes
// (even if the remark changes).
type ParseResult struct {
Outbound Outbound
Identity string
}
// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
// It handles the common case where the body is a base64-encoded blob of
// newline-separated links, and also tolerates an already-decoded text body.
// It returns the list of successfully parsed outbounds (in order) and their
// corresponding identities.
func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return nil, nil, nil
}
// Try base64 decode first (standard and URL-safe variants).
if decoded, ok := tryBase64(text); ok {
text = strings.TrimSpace(decoded)
}
lines := splitLines(text)
var outbounds []Outbound
var identities []string
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
res, err := ParseLink(ln)
if err != nil || res == nil {
// Ignore unparseable lines (comments, unsupported protocols, etc.)
continue
}
outbounds = append(outbounds, res.Outbound)
identities = append(identities, res.Identity)
}
return outbounds, identities, nil
}
func tryBase64(s string) (string, bool) {
// Remove whitespace that some providers insert.
clean := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return -1
}
return r
}, s)
// Common padding fix
for len(clean)%4 != 0 {
clean += "="
}
// Standard
if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe (no padding)
if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe with padding
if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
return "", false
}
func splitLines(s string) []string {
// Accept \n, \r\n, and also some providers use literal \n in the text.
s = strings.ReplaceAll(s, `\n`, "\n")
return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
}
// ParseLink parses a single share link and returns the outbound object plus
// a stable identity for tag correlation. Supported schemes:
// - vmess://
// - vless://
// - trojan://
// - ss:// (modern and legacy)
// - hysteria2:// (also hy2://)
// - wireguard:// (also wg://)
func ParseLink(link string) (*ParseResult, error) {
link = strings.TrimSpace(link)
switch {
case strings.HasPrefix(link, "vmess://"):
return parseVmess(link)
case strings.HasPrefix(link, "vless://"):
return parseVless(link)
case strings.HasPrefix(link, "trojan://"):
return parseTrojan(link)
case strings.HasPrefix(link, "ss://"):
return parseShadowsocks(link)
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
return parseHysteria2(link)
case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
return parseWireguard(link)
default:
return nil, fmt.Errorf("unsupported link scheme")
}
}
// --- vmess ---
func parseVmess(link string) (*ParseResult, error) {
b64 := strings.TrimPrefix(link, "vmess://")
// vmess:// base64(json)
raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
if err != nil {
// Some providers use raw URL-safe
raw, err = base64.RawURLEncoding.DecodeString(b64)
}
if err != nil {
return nil, fmt.Errorf("vmess decode: %w", err)
}
var j map[string]any
if err := json.Unmarshal(raw, &j); err != nil {
return nil, fmt.Errorf("vmess json: %w", err)
}
identity := vmessIdentity(j)
network := getString(j, "net", "tcp")
security := "none"
if tls, _ := j["tls"].(string); tls == "tls" {
security = "tls"
}
stream := buildStream(network, security)
// Map known fields (best effort, matching frontend parser coverage)
switch network {
case "ws":
if host, ok := j["host"].(string); ok {
setWS(stream, host, getString(j, "path", "/"))
}
case "grpc":
svc := getString(j, "path", "")
if auth, ok := j["authority"].(string); ok && auth != "" {
(stream["grpcSettings"].(map[string]any))["authority"] = auth
}
(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = getString(j, "host", "")
xh["path"] = getString(j, "path", "/")
if m := getString(j, "mode", ""); m != "" {
xh["mode"] = m
}
// xhttp advanced keys are passed through if present in the json
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
if v, ok := j[k]; ok {
xh[k] = v
}
}
case "tcp":
if getString(j, "type", "") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(getString(j, "path", "/")),
"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
},
},
}
}
}
if security == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = getString(j, "sni", "")
tls["fingerprint"] = getString(j, "fp", "")
if alpn := getString(j, "alpn", ""); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
}
port := num(j["port"])
ob := Outbound{
"protocol": "vmess",
"tag": getString(j, "ps", ""),
"settings": map[string]any{
"vnext": []any{
map[string]any{
"address": getString(j, "add", ""),
"port": port,
"users": []any{
map[string]any{
"id": getString(j, "id", ""),
"security": getString(j, "scy", "auto"),
},
},
},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func vmessIdentity(j map[string]any) string {
// Remove ps (remark) for identity
core := map[string]any{}
for k, v := range j {
if k == "ps" {
continue
}
core[k] = v
}
b, _ := json.Marshal(core)
return "vmess:" + string(b)
}
// --- vless / trojan (URL forms) ---
func parseVless(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "vless" {
return nil, fmt.Errorf("not vless")
}
id := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "none"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "vless",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"address": host,
"port": port,
"id": id,
"flow": params.Get("flow"),
"encryption": firstNonEmpty(params.Get("encryption"), "none"),
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func parseTrojan(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "trojan" {
return nil, fmt.Errorf("not trojan")
}
pw := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "tls"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "trojan",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pw},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- shadowsocks ---
func parseShadowsocks(link string) (*ParseResult, error) {
// Two shapes:
// ss://base64(method:pass)@host:port#remark
// ss://base64(method:pass@host:port)#remark
remark := ""
if i := strings.Index(link, "#"); i >= 0 {
remark, _ = url.QueryUnescape(link[i+1:])
link = link[:i]
}
core := strings.TrimPrefix(link, "ss://")
at := strings.Index(core, "@")
if at >= 0 {
// modern
userB64 := core[:at]
hp := core[at+1:]
userInfo, err := base64DecodeFlexible(userB64)
if err != nil {
userInfo = userB64 // not b64, rare
}
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad ss host:port")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// legacy: whole thing b64
dec, err := base64DecodeFlexible(core)
if err != nil {
return nil, err
}
at = strings.Index(dec, "@")
if at < 0 {
return nil, fmt.Errorf("bad legacy ss")
}
userInfo := dec[:at]
hp := dec[at+1:]
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad legacy ss hp")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func splitMethodPass(userInfo string) (string, string) {
colon := strings.Index(userInfo, ":")
if colon < 0 {
return "2022-blake3-aes-128-gcm", userInfo // guess
}
return userInfo[:colon], userInfo[colon+1:]
}
// --- hysteria2 ---
func parseHysteria2(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
return nil, fmt.Errorf("not hysteria2")
}
auth := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
stream := map[string]any{
"network": "hysteria",
"security": "tls",
"hysteriaSettings": map[string]any{
"version": 2,
"auth": auth,
"udpIdleTimeout": 60,
},
"tlsSettings": map[string]any{
"serverName": params.Get("sni"),
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
"fingerprint": params.Get("fp"),
"echConfigList": params.Get("ech"),
"verifyPeerCertByName": "",
"pinnedPeerCertSha256": params.Get("pinSHA256"),
},
}
applyFinalMask(stream, params)
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "hysteria",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{"address": host, "port": port, "version": 2},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- wireguard ---
func parseWireguard(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "wireguard" && u.Scheme != "wg" {
return nil, fmt.Errorf("not wireguard")
}
secret, _ := url.QueryUnescape(u.User.Username())
params := u.Query()
host := u.Hostname()
portStr := u.Port()
endpoint := host
if portStr != "" {
endpoint = host + ":" + portStr
}
addrRaw := firstParam(params, "address", "ip")
allowedRaw := firstParam(params, "allowedips", "allowed_ips")
addrs := splitComma(addrRaw)
if len(addrs) == 0 {
addrs = []string{"0.0.0.0/0", "::/0"}
}
allowed := splitComma(allowedRaw)
if len(allowed) == 0 {
allowed = []string{"0.0.0.0/0", "::/0"}
}
peer := map[string]any{
"publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
"endpoint": endpoint,
"allowedIPs": allowed,
}
if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
peer["preSharedKey"] = psk
}
if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
if n, err := strconv.Atoi(ka); err == nil {
peer["keepAlive"] = n
}
}
settings := map[string]any{
"secretKey": secret,
"address": addrs,
"peers": []any{peer},
}
if mtu := params.Get("mtu"); mtu != "" {
if n, err := strconv.Atoi(mtu); err == nil {
settings["mtu"] = n
}
}
if res := params.Get("reserved"); res != "" {
parts := splitComma(res)
var iv []int
for _, p := range parts {
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
iv = append(iv, n)
}
}
if len(iv) > 0 {
settings["reserved"] = iv
}
}
identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "wireguard",
"tag": decodeHash(u.Fragment),
"settings": settings,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- helpers ---
func buildStream(network, security string) map[string]any {
stream := map[string]any{"network": network, "security": security}
switch network {
case "tcp":
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
case "kcp":
stream["kcpSettings"] = map[string]any{
"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
"cwndMultiplier": 1, "maxSendingWindow": 2097152,
}
case "ws":
stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
case "grpc":
stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
case "httpupgrade":
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
case "xhttp":
stream["xhttpSettings"] = map[string]any{
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
}
default:
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
}
if security == "tls" {
stream["tlsSettings"] = map[string]any{
"serverName": "", "alpn": []any{}, "fingerprint": "",
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
}
} else if security == "reality" {
stream["realitySettings"] = map[string]any{
"publicKey": "", "fingerprint": "chrome", "serverName": "",
"shortId": "", "spiderX": "", "mldsa65Verify": "",
}
}
return stream
}
func setWS(stream map[string]any, host, path string) {
ws := stream["wsSettings"].(map[string]any)
ws["host"] = host
ws["path"] = path
}
func setHTTPUpgrade(stream map[string]any, host, path string) {
h := stream["httpupgradeSettings"].(map[string]any)
h["host"] = host
h["path"] = path
}
func applyTransport(stream map[string]any, p url.Values) {
net := stream["network"].(string)
host := p.Get("host")
path := firstNonEmpty(p.Get("path"), "/")
switch net {
case "ws":
setWS(stream, host, path)
case "grpc":
gs := stream["grpcSettings"].(map[string]any)
gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
gs["authority"] = p.Get("authority")
gs["multiMode"] = p.Get("mode") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, host, path)
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = host
xh["path"] = path
if m := p.Get("mode"); m != "" {
xh["mode"] = m
}
// A few advanced xhttp fields that are commonly carried
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
if v := p.Get(k); v != "" {
xh[k] = v
}
}
case "tcp":
if p.Get("headerType") == "http" || p.Get("type") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(path),
"headers": map[string]any{"Host": splitComma(host)},
},
},
}
}
}
}
func applySecurity(stream map[string]any, p url.Values) {
sec := stream["security"].(string)
if sec == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = p.Get("sni")
tls["fingerprint"] = p.Get("fp")
if alpn := p.Get("alpn"); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
tls["echConfigList"] = p.Get("ech")
tls["pinnedPeerCertSha256"] = p.Get("pcs")
} else if sec == "reality" {
re := stream["realitySettings"].(map[string]any)
re["serverName"] = p.Get("sni")
re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
re["publicKey"] = p.Get("pbk")
re["shortId"] = p.Get("sid")
re["spiderX"] = p.Get("spx")
re["mldsa65Verify"] = p.Get("pqv")
}
}
func applyFinalMask(stream map[string]any, p url.Values) {
if fm := p.Get("fm"); fm != "" {
var parsed any
if json.Unmarshal([]byte(fm), &parsed) == nil {
stream["finalmask"] = parsed
}
}
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func firstParam(p url.Values, keys ...string) string {
for _, k := range keys {
if v := p.Get(k); v != "" {
return v
}
}
return ""
}
func canonicalQuery(p url.Values) string {
// Sort keys for stable identity
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
// simple sort
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
parts := make([]string, 0, len(keys))
for _, k := range keys {
for _, v := range p[k] {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
func decodeHash(h string) string {
if h == "" {
return ""
}
if dec, err := url.QueryUnescape(h); err == nil {
return dec
}
return h
}
func defaultPort(p string, def int) int {
if p == "" {
return def
}
n, err := strconv.Atoi(p)
if err != nil || n <= 0 {
return def
}
return n
}
func num(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
}
return 0
}
func getString(m map[string]any, key, def string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return def
}
func splitComma(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func splitCommaOrDefault(s string, def []string) []string {
if s == "" {
return def
}
return splitComma(s)
}
func padBase64(s string) string {
for len(s)%4 != 0 {
s += "="
}
return s
}
func base64DecodeFlexible(s string) (string, error) {
s = padBase64(s)
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return string(b), nil
}
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
return string(b), nil
}
return "", fmt.Errorf("base64 decode failed")
}
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func SlugRemark(remark string) string {
s := strings.ToLower(strings.TrimSpace(remark))
s = slugRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return ""
}
// collapse runs of dashes
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return s
}
// SuggestTag builds a tag from a prefix and a remark (or index fallback).
// It is intended for initial assignment; stability is handled by the service layer.
func SuggestTag(prefix, remark string, idx int) string {
base := SlugRemark(remark)
if base == "" {
base = fmt.Sprintf("%d", idx)
}
p := strings.TrimSuffix(prefix, "-")
if p != "" {
return p + "-" + base
}
return base
}