feat(sub): per-client external links and remote subscriptions

Add a Links tab to the client form for attaching third-party share
links and remote subscription URLs per client. They are merged into
the client's raw/JSON/Clash subscription output: links are emitted
verbatim and parsed for JSON/Clash; subscription URLs are fetched
(cached, with a short timeout) and their configs merged in.

i18n keys added across all 13 locales.
This commit is contained in:
MHSanaei
2026-06-14 20:57:14 +02:00
parent 7c2598fae9
commit dcb923b4a1
33 changed files with 1204 additions and 28 deletions
+238
View File
@@ -0,0 +1,238 @@
package sub
import (
"fmt"
"strconv"
"strings"
)
// clashProxyFromExternal parses a pasted share link and converts it into a
// mihomo/Clash proxy entry named `name`. Returns nil for links Clash can't
// represent (the entry is then skipped, mirroring how getProxies drops
// unsupported inbound protocols). vmess/vless/trojan reuse the existing
// applyTransport/applySecurity helpers; ss/hysteria2/wireguard map directly.
func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[string]any {
ob := parseExternalLink(rawLink)
if ob == nil {
return nil
}
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
stream, _ := ob["streamSettings"].(map[string]any)
if stream == nil {
stream = map[string]any{}
}
if settings == nil {
return nil
}
proxy := map[string]any{"name": name, "udp": true}
switch protocol {
case "vmess":
vnext, _ := settings["vnext"].([]any)
if len(vnext) == 0 {
return nil
}
vn, _ := vnext[0].(map[string]any)
users, _ := vn["users"].([]any)
if vn == nil || len(users) == 0 {
return nil
}
user, _ := users[0].(map[string]any)
proxy["type"] = "vmess"
proxy["server"] = fmt.Sprint(vn["address"])
proxy["port"] = clashInt(vn["port"])
proxy["uuid"] = fmt.Sprint(user["id"])
proxy["alterId"] = 0
cipher, _ := user["security"].(string)
if cipher == "" {
cipher = "auto"
}
proxy["cipher"] = cipher
case "vless":
proxy["type"] = "vless"
proxy["server"] = fmt.Sprint(settings["address"])
proxy["port"] = clashInt(settings["port"])
proxy["uuid"] = fmt.Sprint(settings["id"])
if flow, _ := settings["flow"].(string); flow != "" {
proxy["flow"] = flow
}
case "trojan":
server := firstServer(settings)
if server == nil {
return nil
}
proxy["type"] = "trojan"
proxy["server"] = fmt.Sprint(server["address"])
proxy["port"] = clashInt(server["port"])
proxy["password"] = fmt.Sprint(server["password"])
case "shadowsocks":
server := firstServer(settings)
if server == nil {
server = settings
}
method, _ := server["method"].(string)
if method == "" {
return nil
}
proxy["type"] = "ss"
proxy["server"] = fmt.Sprint(server["address"])
proxy["port"] = clashInt(server["port"])
proxy["cipher"] = method
proxy["password"] = fmt.Sprint(server["password"])
return proxy
case "hysteria":
return clashHysteriaFromExternal(settings, stream, name)
case "wireguard":
return clashWireguardFromExternal(settings, name)
default:
return nil
}
network, _ := stream["network"].(string)
if !s.applyTransport(proxy, network, stream) {
return nil
}
security, _ := stream["security"].(string)
if !s.applySecurity(proxy, security, stream) {
return nil
}
return proxy
}
func firstServer(settings map[string]any) map[string]any {
servers, _ := settings["servers"].([]any)
if len(servers) == 0 {
return nil
}
server, _ := servers[0].(map[string]any)
return server
}
func clashHysteriaFromExternal(settings, stream map[string]any, name string) map[string]any {
hy, _ := stream["hysteriaSettings"].(map[string]any)
auth := ""
if hy != nil {
auth, _ = hy["auth"].(string)
}
if auth == "" {
return nil
}
proxy := map[string]any{
"name": name,
"type": "hysteria2",
"server": fmt.Sprint(settings["address"]),
"port": clashInt(settings["port"]),
"password": auth,
"udp": true,
}
if tls, _ := stream["tlsSettings"].(map[string]any); tls != nil {
if sni, _ := tls["serverName"].(string); sni != "" {
proxy["sni"] = sni
}
if alpn := clashStringList(tls["alpn"]); len(alpn) > 0 {
proxy["alpn"] = alpn
}
if fp, _ := tls["fingerprint"].(string); fp != "" {
proxy["client-fingerprint"] = fp
}
}
return proxy
}
func clashWireguardFromExternal(settings map[string]any, name string) map[string]any {
peers, _ := settings["peers"].([]any)
if len(peers) == 0 {
return nil
}
peer, _ := peers[0].(map[string]any)
if peer == nil {
return nil
}
host, port := splitClashHostPort(fmt.Sprint(peer["endpoint"]))
if host == "" || port == 0 {
return nil
}
proxy := map[string]any{
"name": name,
"type": "wireguard",
"server": host,
"port": port,
"udp": true,
}
if sk, _ := settings["secretKey"].(string); sk != "" {
proxy["private-key"] = sk
}
if pk, _ := peer["publicKey"].(string); pk != "" {
proxy["public-key"] = pk
}
if psk, _ := peer["preSharedKey"].(string); psk != "" {
proxy["pre-shared-key"] = psk
}
for _, addr := range clashStringList(settings["address"]) {
ip := stripCIDR(addr)
if strings.Contains(ip, ":") {
proxy["ipv6"] = ip
} else {
proxy["ip"] = ip
}
}
return proxy
}
func clashInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
default:
return 0
}
}
func clashStringList(v any) []string {
switch x := v.(type) {
case []any:
out := make([]string, 0, len(x))
for _, item := range x {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case []string:
return x
case string:
if x == "" {
return nil
}
return strings.Split(x, ",")
default:
return nil
}
}
func stripCIDR(addr string) string {
if i := strings.IndexByte(addr, '/'); i >= 0 {
return addr[:i]
}
return addr
}
func splitClashHostPort(endpoint string) (string, int) {
endpoint = strings.TrimSpace(endpoint)
i := strings.LastIndex(endpoint, ":")
if i < 0 {
return endpoint, 0
}
host := strings.Trim(endpoint[:i], "[]")
port, _ := strconv.Atoi(endpoint[i+1:])
return host, port
}
+20 -1
View File
@@ -25,9 +25,16 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
// Set per-request state so resolveInboundAddress sees the node map.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 {
if err != nil {
return "", "", err
}
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
if len(inbounds) == 0 && len(externalLinks) == 0 {
return "", "", nil
}
var proxies []map[string]any
@@ -43,6 +50,18 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
proxies = append(proxies, s.getProxies(inbound, client, host)...)
}
}
for _, ext := range externalLinks {
for _, el := range expandEntry(ext) {
name := el.Name
if name == "" {
name = ext.Email
}
if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
seenEmails[ext.Email] = struct{}{}
proxies = append(proxies, proxy)
}
}
}
if len(proxies) == 0 {
return "", "", nil
+160
View File
@@ -0,0 +1,160 @@
package sub
import (
"encoding/base64"
"net/url"
"strings"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
)
// externalLinkEntry is one client × external-link row, resolved for a
// subscription request. Email/Enable come from the owning client.
type externalLinkEntry struct {
Kind string
Value string
Remark string
Email string
Enable bool
}
// expandedLink is a single share link contributed by an entry, with the display
// name to use (empty → keep the link's own remark / fall back to the email).
type expandedLink struct {
Link string
Name string
}
// getClientExternalLinksBySubId returns every external-link row attached to a
// client that carries the given subId, in stable order. Stays inside
// internal/sub + database + util/link — no dependency on the panel service layer.
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
db := database.GetDB()
var recs []model.ClientRecord
if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
return nil, err
}
if len(recs) == 0 {
return nil, nil
}
clientIds := make([]int, 0, len(recs))
byId := make(map[int]model.ClientRecord, len(recs))
for _, rec := range recs {
clientIds = append(clientIds, rec.Id)
byId[rec.Id] = rec
}
var rows []model.ClientExternalLink
if err := db.Where("client_id IN ?", clientIds).
Order("client_id ASC, sort_index ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
out := make([]externalLinkEntry, 0, len(rows))
for _, r := range rows {
rec := byId[r.ClientId]
out = append(out, externalLinkEntry{
Kind: r.Kind,
Value: r.Value,
Remark: r.Remark,
Email: rec.Email,
Enable: rec.Enable,
})
}
return out, nil
}
// expandEntry turns one entry into the concrete share links it contributes. A
// "subscription" entry is fetched (cached) and its links are kept with their own
// names; a "link" entry yields the single link with the row's remark.
func expandEntry(e externalLinkEntry) []expandedLink {
if e.Kind == model.ExternalLinkKindSubscription {
links := fetchSubscriptionLinks(e.Value)
out := make([]expandedLink, 0, len(links))
for _, l := range links {
out = append(out, expandedLink{Link: l, Name: ""})
}
return out
}
return []expandedLink{{Link: e.Value, Name: e.Remark}}
}
// applyRemarkToLink rewrites a share link's display name to remark (when set),
// leaving everything else byte-for-byte. vmess carries its remark in the base64
// JSON `ps`; every other scheme carries it in the URL #fragment.
func applyRemarkToLink(rawLink, remark string) string {
rawLink = strings.TrimSpace(rawLink)
if remark == "" {
return rawLink
}
if strings.HasPrefix(rawLink, "vmess://") {
return applyVmessRemark(rawLink, remark)
}
if i := strings.IndexByte(rawLink, '#'); i >= 0 {
rawLink = rawLink[:i]
}
return rawLink + "#" + url.PathEscape(remark)
}
func applyVmessRemark(rawLink, remark string) string {
b64 := strings.TrimPrefix(rawLink, "vmess://")
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
}
if err != nil {
return rawLink
}
var j map[string]any
if err := json.Unmarshal(raw, &j); err != nil {
return rawLink
}
j["ps"] = remark
nb, err := json.Marshal(j)
if err != nil {
return rawLink
}
return "vmess://" + base64.StdEncoding.EncodeToString(nb)
}
func padBase64Sub(s string) string {
for len(s)%4 != 0 {
s += "="
}
return s
}
// parsedExternalOutbound turns a pasted share link into a structured Xray
// outbound (tagged "proxy") for the JSON subscription. Returns nil when the
// link can't be parsed — the caller skips it.
func parsedExternalOutbound(rawLink string) json_util.RawMessage {
ob := parseExternalLink(rawLink)
if ob == nil {
return nil
}
ob["tag"] = "proxy"
b, err := json.MarshalIndent(ob, "", " ")
if err != nil {
return nil
}
return b
}
// parseExternalLink parses a share link into the Xray outbound wire shape
// (map), or nil if unsupported/invalid.
func parseExternalLink(rawLink string) map[string]any {
res, err := link.ParseLink(strings.TrimSpace(rawLink))
if err != nil || res == nil || res.Outbound == nil {
return nil
}
return map[string]any(res.Outbound)
}
+123
View File
@@ -0,0 +1,123 @@
package sub
import (
"encoding/base64"
"net/url"
"strings"
"testing"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestApplyRemarkToLinkRewritesFragment(t *testing.T) {
link := "vless://uuid@example.com:443?security=reality&pbk=abc&sid=12#old-name"
out := applyRemarkToLink(link, "DE-Provider")
u, err := url.Parse(out)
if err != nil {
t.Fatalf("parse: %v", err)
}
if u.Fragment != "DE-Provider" {
t.Fatalf("fragment = %q, want DE-Provider", u.Fragment)
}
// Everything before the fragment must be byte-for-byte preserved.
if !strings.HasPrefix(out, "vless://uuid@example.com:443?security=reality&pbk=abc&sid=12#") {
t.Fatalf("link body altered: %s", out)
}
}
func TestApplyRemarkToLinkVmessSetsPs(t *testing.T) {
payload := map[string]any{"v": "2", "ps": "old", "add": "1.2.3.4", "port": "443", "id": "uuid"}
b, _ := json.Marshal(payload)
link := "vmess://" + base64.StdEncoding.EncodeToString(b)
out := applyRemarkToLink(link, "NL-Node")
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
if err != nil {
t.Fatalf("decode: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["ps"] != "NL-Node" {
t.Fatalf("ps = %v, want NL-Node", got["ps"])
}
if got["id"] != "uuid" {
t.Fatalf("credentials lost: %v", got)
}
}
func TestApplyRemarkEmptyKeepsLinkVerbatim(t *testing.T) {
link := "trojan://pass@1.2.3.4:8443?security=tls#orig"
if out := applyRemarkToLink(link, ""); out != link {
t.Fatalf("empty remark altered link: %s", out)
}
}
func TestParsedExternalOutboundTagsProxy(t *testing.T) {
link := "vless://uuid@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#srv"
data := parsedExternalOutbound(link)
if data == nil {
t.Fatal("expected an outbound, got nil")
}
var ob map[string]any
if err := json.Unmarshal(data, &ob); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if ob["tag"] != "proxy" {
t.Fatalf("tag = %v, want proxy", ob["tag"])
}
if ob["protocol"] != "vless" {
t.Fatalf("protocol = %v, want vless", ob["protocol"])
}
}
func TestDecodeSubscriptionBodyBase64(t *testing.T) {
plain := "vless://uuid@a.com:443#one\ntrojan://pw@b.com:8443#two\n"
body := []byte(base64.StdEncoding.EncodeToString([]byte(plain)))
links := decodeSubscriptionBody(body)
if len(links) != 2 || links[0] != "vless://uuid@a.com:443#one" || links[1] != "trojan://pw@b.com:8443#two" {
t.Fatalf("decoded links = %#v", links)
}
}
func TestDecodeSubscriptionBodyPlainSkipsComments(t *testing.T) {
body := []byte("# header\nvmess://abc\n\nnot-a-link\nss://def#x\n")
links := decodeSubscriptionBody(body)
if len(links) != 2 || links[0] != "vmess://abc" || links[1] != "ss://def#x" {
t.Fatalf("decoded links = %#v", links)
}
}
func TestExpandEntryLinkAppliesRemark(t *testing.T) {
got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com:8443#orig", Remark: "DE"})
if len(got) != 1 || got[0].Name != "DE" {
t.Fatalf("expandEntry = %#v", got)
}
}
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
svc := NewSubClashService(false, "", NewSubService(false, "-io"))
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
if proxy == nil {
t.Fatal("expected a clash proxy, got nil")
}
if proxy["type"] != "trojan" {
t.Fatalf("type = %v, want trojan", proxy["type"])
}
if proxy["server"] != "37.27.201.56" {
t.Fatalf("server = %v", proxy["server"])
}
if proxy["password"] != "provider-pass" {
t.Fatalf("password = %v", proxy["password"])
}
if proxy["name"] != "DE-Provider" {
t.Fatalf("name = %v", proxy["name"])
}
if proxy["tls"] != true {
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
}
}
+133
View File
@@ -0,0 +1,133 @@
package sub
import (
"encoding/base64"
"io"
"net/http"
"strings"
"sync"
"time"
)
// External subscription fetching: a "subscription" external link is a remote
// URL whose body is a (often base64-encoded) newline list of share links. We
// fetch it on demand, cache the decoded links briefly, and bound the request
// with a short timeout so a slow/dead provider can't stall a client's sub.
const (
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
)
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
type subscriptionCacheEntry struct {
links []string
fetchedAt time.Time
}
var subscriptionCache = struct {
sync.Mutex
m map[string]subscriptionCacheEntry
}{m: make(map[string]subscriptionCacheEntry)}
// fetchSubscriptionLinks returns the share links contained in a remote
// subscription URL, using a short-lived cache. On any failure it returns the
// last cached value (if present) or nil — never an error, so the rest of the
// client's subscription still renders.
func fetchSubscriptionLinks(rawURL string) []string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil
}
subscriptionCache.Lock()
cached, ok := subscriptionCache.m[rawURL]
subscriptionCache.Unlock()
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
return cached.links
}
links, err := doFetchSubscriptionLinks(rawURL)
if err != nil {
// Serve stale on error rather than dropping the client's configs.
if ok {
return cached.links
}
return nil
}
subscriptionCache.Lock()
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
subscriptionCache.Unlock()
return links
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
// Some providers gate the link body on a known client User-Agent.
req.Header.Set("User-Agent", "v2rayNG/1.8.5")
resp, err := subscriptionHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errBadStatus
}
body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes))
if err != nil {
return nil, err
}
return decodeSubscriptionBody(body), nil
}
var errBadStatus = &subError{"non-2xx subscription response"}
type subError struct{ msg string }
func (e *subError) Error() string { return e.msg }
// decodeSubscriptionBody handles the common base64-encoded newline list as well
// as a plain-text body, returning only the lines that look like share links.
func decodeSubscriptionBody(body []byte) []string {
text := strings.TrimSpace(string(body))
if text == "" {
return nil
}
if decoded, ok := tryDecodeBase64Body(text); ok {
text = strings.TrimSpace(decoded)
}
lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
out := make([]string, 0, len(lines))
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
if strings.Contains(ln, "://") {
out = append(out, ln)
}
}
return out
}
func tryDecodeBase64Body(s string) (string, bool) {
clean := strings.Map(func(r rune) rune {
switch r {
case ' ', '\n', '\r', '\t':
return -1
}
return r
}, s)
if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
return string(b), true
}
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
return string(b), true
}
return "", false
}
+29 -1
View File
@@ -62,9 +62,16 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
// resolveInboundAddress call inside picks node-aware host values.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId)
if err != nil || len(inbounds) == 0 {
if err != nil {
return "", "", err
}
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
if len(inbounds) == 0 && len(externalLinks) == 0 {
return "", "", nil
}
var header string
var configArray []json_util.RawMessage
@@ -83,6 +90,27 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
configArray = append(configArray, s.getConfig(inbound, client, host)...)
}
}
for _, ext := range externalLinks {
for _, el := range expandEntry(ext) {
outbound := parsedExternalOutbound(el.Link)
if outbound == nil {
continue
}
seenEmails[ext.Email] = struct{}{}
remark := el.Name
if remark == "" {
remark = ext.Email
}
newOutbounds := []json_util.RawMessage{outbound}
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = remark
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
configArray = append(configArray, newConfig)
}
}
if len(configArray) == 0 {
return "", "", nil
+17 -1
View File
@@ -147,8 +147,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
if err != nil {
return nil, nil, 0, traffic, err
}
externalLinks, err := s.getClientExternalLinksBySubId(subId)
if err != nil {
return nil, nil, 0, traffic, err
}
if len(inbounds) == 0 {
if len(inbounds) == 0 && len(externalLinks) == 0 {
return nil, nil, 0, traffic, nil
}
@@ -178,6 +182,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
seenEmails[client.Email] = struct{}{}
}
}
for _, ext := range externalLinks {
if ext.Enable {
hasEnabledClient = true
}
for _, el := range expandEntry(ext) {
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
result = append(result, link)
emails = append(emails, ext.Email)
seenEmails[ext.Email] = struct{}{}
}
}
}
uniqueEmails := make([]string, 0, len(seenEmails))
for e := range seenEmails {