Files
3x-ui/internal/sub/external_config.go
T
MHSanaei dcb923b4a1 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.
2026-06-14 20:57:14 +02:00

161 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}