Просмотр исходного кода

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.
MHSanaei 16 часов назад
Родитель
Сommit
dcb923b4a1
33 измененных файлов с 1204 добавлено и 28 удалено
  1. 69 1
      frontend/public/openapi.json
  2. 6 10
      frontend/src/components/form/SelectAllClearButtons.tsx
  3. 17 1
      frontend/src/hooks/useClients.ts
  4. 13 2
      frontend/src/pages/api-docs/endpoints.ts
  5. 108 3
      frontend/src/pages/clients/ClientFormModal.tsx
  6. 20 5
      frontend/src/pages/clients/ClientsPage.tsx
  7. 12 0
      frontend/src/schemas/client.ts
  8. 1 0
      internal/database/db.go
  9. 1 0
      internal/database/migrate_data.go
  10. 25 0
      internal/database/model/model.go
  11. 238 0
      internal/sub/clash_external.go
  12. 20 1
      internal/sub/clash_service.go
  13. 160 0
      internal/sub/external_config.go
  14. 123 0
      internal/sub/external_config_test.go
  15. 133 0
      internal/sub/external_subscription.go
  16. 29 1
      internal/sub/json_service.go
  17. 17 1
      internal/sub/service.go
  18. 26 1
      internal/web/controller/client.go
  19. 3 0
      internal/web/service/client_crud.go
  20. 103 0
      internal/web/service/client_external_link.go
  21. 6 0
      internal/web/translation/ar-EG.json
  22. 7 1
      internal/web/translation/en-US.json
  23. 6 0
      internal/web/translation/es-ES.json
  24. 7 1
      internal/web/translation/fa-IR.json
  25. 6 0
      internal/web/translation/id-ID.json
  26. 6 0
      internal/web/translation/ja-JP.json
  27. 6 0
      internal/web/translation/pt-BR.json
  28. 6 0
      internal/web/translation/ru-RU.json
  29. 6 0
      internal/web/translation/tr-TR.json
  30. 6 0
      internal/web/translation/uk-UA.json
  31. 6 0
      internal/web/translation/vi-VN.json
  32. 6 0
      internal/web/translation/zh-CN.json
  33. 6 0
      internal/web/translation/zh-TW.json

+ 69 - 1
frontend/public/openapi.json

@@ -4390,7 +4390,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Fetch one client by email, including the inbound IDs it is attached to.",
+        "summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
         "operationId": "get_panel_api_clients_get_email",
         "parameters": [
           {
@@ -4719,6 +4719,74 @@
         }
       }
     },
+    "/panel/api/clients/{email}/externalLinks": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
+        "operationId": "post_panel_api_clients_email_externalLinks",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email (unique identifier).",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "externalLinks": [
+                  {
+                    "kind": "link",
+                    "value": "vless://uuid@host:443?...#srv",
+                    "remark": "DE"
+                  },
+                  {
+                    "kind": "subscription",
+                    "value": "https://provider.example/sub/abc",
+                    "remark": "Provider"
+                  }
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/resetAllTraffics": {
       "post": {
         "tags": [

+ 6 - 10
frontend/src/components/form/SelectAllClearButtons.tsx

@@ -1,27 +1,23 @@
 import { useTranslation } from 'react-i18next';
 import { Button } from 'antd';
 
-interface Option {
-  value: number;
-}
-
-interface SelectAllClearButtonsProps {
-  options: Option[];
-  value: number[];
-  onChange: (value: number[]) => void;
+interface SelectAllClearButtonsProps<T extends string | number = number> {
+  options: Array<{ value: T }>;
+  value: T[];
+  onChange: (value: T[]) => void;
   /** Override the default "Select all" label (defaults to the inbound copy). */
   selectAllLabel?: string;
   /** Override the default "Clear all" label (defaults to the inbound copy). */
   clearLabel?: string;
 }
 
-export default function SelectAllClearButtons({
+export default function SelectAllClearButtons<T extends string | number = number>({
   options,
   value,
   onChange,
   selectAllLabel,
   clearLabel,
-}: SelectAllClearButtonsProps) {
+}: SelectAllClearButtonsProps<T>) {
   const { t } = useTranslation();
 
   const optionValues = options.map((o) => o.value);

+ 17 - 1
frontend/src/hooks/useClients.ts

@@ -22,6 +22,7 @@ import {
   type ClientsSummary,
   type ClientPageResponse,
   type InboundOption,
+  type ExternalLink,
   type BulkAdjustResult,
   type BulkAttachResult,
   type BulkCreateResult,
@@ -30,7 +31,10 @@ import {
 } from '@/schemas/client';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
 
-export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
+// One row sent to POST /clients/:email/externalLinks.
+export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
+
+export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
 
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
@@ -350,6 +354,12 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+  const setExternalLinksMut = useMutation({
+    mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const bulkAttachMut = useMutation({
     mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
@@ -364,6 +374,7 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+
   const bulkDetachMut = useMutation({
     mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
@@ -424,6 +435,10 @@ export function useClients() {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
   }, [attachMut]);
+  const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+    return setExternalLinksMut.mutateAsync({ email, externalLinks });
+  }, [setExternalLinksMut]);
   const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
     if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
@@ -553,6 +568,7 @@ export function useClients() {
     bulkAddToGroup,
     bulkRemoveFromGroup,
     attach,
+    setExternalLinks,
     bulkAttach,
     detach,
     bulkDetach,

+ 13 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/clients/get/:email',
-        summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
+        summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5]\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
       },
       {
         method: 'POST',
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
         body: '{\n  "inboundIds": [5]\n}',
         response: '{\n  "success": true\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/externalLinks',
+        summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
+        ],
+        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n  ]\n}',
+        response: '{\n  "success": true\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetAllTraffics',

+ 108 - 3
frontend/src/pages/clients/ClientFormModal.tsx

@@ -16,16 +16,17 @@ import {
   Tabs,
   Tag,
   Tooltip,
+  Typography,
   message,
 } from 'antd';
-import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
+import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import { HttpUtil, RandomUtil } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
-import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -38,6 +39,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
 const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
 
+// One editable row in the Links tab. `key` is a stable client-side id for React.
+interface ExternalLinkRow {
+  key: number;
+  kind: 'link' | 'subscription';
+  value: string;
+}
+
 interface ApiMsg<T = unknown> {
   success?: boolean;
   msg?: string;
@@ -51,10 +59,13 @@ interface SaveMetaEdit {
   email: string;
   attach: number[];
   detach: number[];
+  externalLinks: ExternalLinkInput[];
 }
 
 interface SaveMetaCreate {
   isEdit: false;
+  email: string;
+  externalLinks: ExternalLinkInput[];
 }
 
 interface SaveCreatePayload {
@@ -67,6 +78,7 @@ interface ClientFormModalProps {
   mode: Mode;
   client: ClientRecord | null;
   inbounds: InboundOption[];
+  attachedExternalLinks?: ExternalLink[];
   attachedIds?: number[];
   tgBotEnable?: boolean;
   groups?: string[];
@@ -98,6 +110,7 @@ interface FormState {
   comment: string;
   enable: boolean;
   inboundIds: number[];
+  externalLinks: ExternalLinkRow[];
 }
 
 function emptyForm(): FormState {
@@ -121,9 +134,19 @@ function emptyForm(): FormState {
     comment: '',
     enable: true,
     inboundIds: [],
+    externalLinks: [],
   };
 }
 
+let externalLinkRowSeq = 0;
+function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
+  return (links || []).map((l) => ({
+    key: (externalLinkRowSeq += 1),
+    kind: l.kind === 'subscription' ? 'subscription' : 'link',
+    value: l.value || '',
+  }));
+}
+
 function bytesToGB(bytes: number): number {
   if (!bytes || bytes <= 0) return 0;
   return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
@@ -139,6 +162,7 @@ export default function ClientFormModal({
   mode,
   client,
   inbounds,
+  attachedExternalLinks = [],
   attachedIds = [],
   tgBotEnable = false,
   groups = [],
@@ -162,6 +186,27 @@ export default function ClientFormModal({
     setForm((prev) => ({ ...prev, [key]: value }));
   }
 
+  function addExternalLinkRow(kind: 'link' | 'subscription') {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
+    }));
+  }
+
+  function updateExternalLinkRow(key: number, value: string) {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
+    }));
+  }
+
+  function removeExternalLinkRow(key: number) {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: prev.externalLinks.filter((r) => r.key !== key),
+    }));
+  }
+
   useEffect(() => {
     if (!open) return;
     setIpsModalOpen(false);
@@ -186,6 +231,7 @@ export default function ClientFormModal({
         comment: client.comment || '',
         enable: !!client.enable,
         inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
+        externalLinks: toExternalLinkRows(attachedExternalLinks),
       };
       if (et < 0) {
         next.delayedStart = true;
@@ -300,6 +346,9 @@ export default function ClientFormModal({
     [inbounds],
   );
 
+  const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
+  const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
+
   async function loadIps() {
     if (!isEdit || !client?.email) return;
     setIpsLoading(true);
@@ -400,6 +449,10 @@ export default function ClientFormModal({
       clientPayload.reverse = { tag: reverseTag };
     }
 
+    const externalLinks: ExternalLinkInput[] = form.externalLinks
+      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
+      .filter((r) => r.value !== '');
+
     setSubmitting(true);
     try {
       let msg;
@@ -413,11 +466,12 @@ export default function ClientFormModal({
           email: client.email,
           attach: toAttach,
           detach: toDetach,
+          externalLinks,
         });
       } else {
         msg = await save(
           { client: clientPayload, inboundIds: form.inboundIds },
-          { isEdit: false },
+          { isEdit: false, email: clientPayload.email as string, externalLinks },
         );
       }
       if (msg?.success) close();
@@ -692,6 +746,57 @@ export default function ClientFormModal({
                   </>
                 ),
               },
+              {
+                key: 'links',
+                label: t('pages.clients.tabLinks'),
+                children: (
+                  <>
+                    <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
+                      {t('pages.clients.linksHint')}
+                    </Typography.Paragraph>
+
+                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
+                      {t('pages.clients.addExternalLink')}
+                    </Button>
+                    <div style={{ marginTop: 12, marginBottom: 24 }}>
+                      {linkRows.length === 0 ? (
+                        <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
+                      ) : linkRows.map((row) => (
+                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                          <Input
+                            value={row.value}
+                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
+                            placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                          />
+                          <Tooltip title={t('delete')}>
+                            <Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
+                          </Tooltip>
+                        </div>
+                      ))}
+                    </div>
+
+                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
+                      {t('pages.clients.addExternalSubscription')}
+                    </Button>
+                    <div style={{ marginTop: 12 }}>
+                      {subscriptionRows.length === 0 ? (
+                        <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
+                      ) : subscriptionRows.map((row) => (
+                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                          <Input
+                            value={row.value}
+                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
+                            placeholder="https://provider.example/sub/…"
+                          />
+                          <Tooltip title={t('delete')}>
+                            <Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
+                          </Tooltip>
+                        </div>
+                      ))}
+                    </div>
+                  </>
+                ),
+              },
             ]}
           />
         </Form>

+ 20 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useDatepicker } from '@/hooks/useDatepicker';
-import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
 import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
@@ -199,7 +199,7 @@ export default function ClientsPage() {
     setQuery,
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
+    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
@@ -220,6 +220,7 @@ export default function ClientsPage() {
   const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
   const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
   const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
+  const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
   const [infoOpen, setInfoOpen] = useState(false);
   const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
   const [qrOpen, setQrOpen] = useState(false);
@@ -429,6 +430,7 @@ export default function ClientsPage() {
     setFormMode('add');
     setEditingClient(null);
     setEditingAttachedIds([]);
+    setEditingExternalLinks([]);
     setFormOpen(true);
   }
 
@@ -441,6 +443,7 @@ export default function ClientsPage() {
     setEditingClient(merged);
     const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
     setEditingAttachedIds([...ids]);
+    setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
     setFormOpen(true);
   }
 
@@ -567,10 +570,18 @@ export default function ClientsPage() {
 
   const onSave = useCallback(async (
     payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
-    meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
+    meta:
+      | { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
+      | { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
   ) => {
     if (!meta.isEdit) {
-      return create(payload);
+      const createMsg = await create(payload);
+      if (!createMsg?.success) return createMsg;
+      if (meta.email && meta.externalLinks.length > 0) {
+        const r = await setExternalLinks(meta.email, meta.externalLinks);
+        if (!r?.success) return r;
+      }
+      return createMsg;
     }
     const updateMsg = await update(meta.email, payload);
     if (!updateMsg?.success) return updateMsg;
@@ -582,8 +593,11 @@ export default function ClientsPage() {
       const r = await detach(meta.email, meta.detach);
       if (!r?.success) return r;
     }
+    // Always replace the client's external links (an empty set clears them).
+    const r = await setExternalLinks(meta.email, meta.externalLinks);
+    if (!r?.success) return r;
     return updateMsg;
-  }, [create, update, attach, detach]);
+  }, [create, update, attach, detach, setExternalLinks]);
 
   const pageClass = useMemo(() => {
     const classes = ['clients-page'];
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
             mode={formMode}
             client={editingClient}
             attachedIds={editingAttachedIds}
+            attachedExternalLinks={editingExternalLinks}
             inbounds={inbounds}
             tgBotEnable={tgBotEnable}
             groups={allGroups}

+ 12 - 0
frontend/src/schemas/client.ts

@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
   groups: nullableStringArray.optional(),
 });
 
+// A per-client external link surfaced in the client's subscription:
+// kind=link is a single share link, kind=subscription is a remote sub URL.
+export const ExternalLinkSchema = z.object({
+  kind: z.enum(['link', 'subscription']).default('link'),
+  value: z.string(),
+  remark: z.string().optional().default(''),
+}).loose();
+
+export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
+
 export const ClientHydrateSchema = z.object({
   client: ClientRecordSchema,
   inboundIds: nullableNumberArray,
+  externalLinks: ExternalLinkListSchema.optional(),
 });
 
 export const BulkAdjustResultSchema = z.object({
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
 export type ClientRecord = z.infer<typeof ClientRecordSchema>;
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 export type InboundOption = z.infer<typeof InboundOptionSchema>;
+export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
 export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;

+ 1 - 0
internal/database/db.go

@@ -69,6 +69,7 @@ func initModels() error {
 		&model.ApiToken{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},

+ 1 - 0
internal/database/migrate_data.go

@@ -47,6 +47,7 @@ func migrationModels() []any {
 		&model.InboundClientIps{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientExternalLink{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.OutboundSubscription{},

+ 25 - 0
internal/database/model/model.go

@@ -629,6 +629,31 @@ type ClientInbound struct {
 
 func (ClientInbound) TableName() string { return "client_inbounds" }
 
+// ClientExternalLink is a per-client entry surfaced in the client's
+// subscription. Two kinds:
+//   - "link": a single third-party share link (vless://, vmess://, trojan://,
+//     ss://, hysteria2://, wireguard://). Emitted verbatim in raw subs; parsed
+//     into an outbound/proxy for JSON and Clash.
+//   - "subscription": a remote subscription URL. The panel fetches it (cached),
+//     decodes its links, and merges them into the client's subscription.
+type ClientExternalLink struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	ClientId  int    `json:"clientId" gorm:"index;column:client_id"`
+	Kind      string `json:"kind" gorm:"column:kind"`
+	Value     string `json:"value" gorm:"column:value"`
+	Remark    string `json:"remark" gorm:"column:remark"`
+	SortIndex int    `json:"sortIndex" gorm:"column:sort_index"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+}
+
+func (ClientExternalLink) TableName() string { return "client_external_links" }
+
+// External link kinds.
+const (
+	ExternalLinkKindLink         = "link"
+	ExternalLinkKindSubscription = "subscription"
+)
+
 type InboundFallback struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	MasterId  int    `json:"masterId" gorm:"index;not null;column:master_id"`

+ 238 - 0
internal/sub/clash_external.go

@@ -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
internal/sub/clash_service.go

@@ -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 - 0
internal/sub/external_config.go

@@ -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 - 0
internal/sub/external_config_test.go

@@ -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://[email protected]: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://[email protected]: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://[email protected]: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://[email protected]: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://[email protected]:443#one\ntrojan://[email protected]:8443#two\n"
+	body := []byte(base64.StdEncoding.EncodeToString([]byte(plain)))
+	links := decodeSubscriptionBody(body)
+	if len(links) != 2 || links[0] != "vless://[email protected]:443#one" || links[1] != "trojan://[email protected]: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://[email protected]:8443#orig", Remark: "DE"})
+	if len(got) != 1 || got[0].Name != "DE" {
+		t.Fatalf("expandEntry = %#v", got)
+	}
+}
+
+func TestClashProxyFromExternalTrojanReality(t *testing.T) {
+	link := "trojan://[email protected]: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 - 0
internal/sub/external_subscription.go

@@ -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
internal/sub/json_service.go

@@ -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
internal/sub/service.go

@@ -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 {

+ 26 - 1
internal/web/controller/client.go

@@ -59,6 +59,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/del/:email", a.delete)
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/detach", a.detach)
+	g.POST("/:email/externalLinks", a.setExternalLinks)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
@@ -112,6 +113,11 @@ func (a *ClientController) get(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 	}
+	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "get"), err)
@@ -125,7 +131,7 @@ func (a *ClientController) get(c *gin.Context) {
 	if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
 		usedTraffic = t.Up + t.Down
 	}
-	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
+	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
 }
 
 func (a *ClientController) create(c *gin.Context) {
@@ -185,6 +191,10 @@ type attachDetachBody struct {
 	InboundIds []int `json:"inboundIds"`
 }
 
+type externalLinksBody struct {
+	ExternalLinks []service.ExternalLinkInput `json:"externalLinks"`
+}
+
 func (a *ClientController) attach(c *gin.Context) {
 	email := c.Param("email")
 	var body attachDetachBody
@@ -204,6 +214,21 @@ func (a *ClientController) attach(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+func (a *ClientController) setExternalLinks(c *gin.Context) {
+	email := c.Param("email")
+	var body externalLinksBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.SetExternalLinksByEmail(email, body.ExternalLinks); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	notifyClientsChanged()
+}
+
 func (a *ClientController) resetAllTraffics(c *gin.Context) {
 	needRestart, err := a.clientService.ResetAllTraffics()
 	if err != nil {

+ 3 - 0
internal/web/service/client_crud.go

@@ -407,6 +407,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
 		return needRestart, err
 	}
+	if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
+		return needRestart, err
+	}
 	if !keepTraffic && existing.Email != "" {
 		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return needRestart, err

+ 103 - 0
internal/web/service/client_external_link.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"net/url"
+	"strings"
+
+	"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/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+
+	"gorm.io/gorm"
+)
+
+// ExternalLinkInput is one row from the client form's Links tab.
+type ExternalLinkInput struct {
+	Kind   string `json:"kind"`
+	Value  string `json:"value"`
+	Remark string `json:"remark"`
+}
+
+func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
+	var rows []model.ClientExternalLink
+	if err := database.GetDB().
+		Where("client_id = ?", id).
+		Order("sort_index ASC, id ASC").
+		Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+// normalizeExternalLinks validates and orders the incoming rows. A "link" must
+// parse to a supported share-link scheme; a "subscription" must be an http(s)
+// URL. Blank values are dropped; an invalid value is a hard error so the
+// operator gets immediate feedback instead of a silently missing config.
+func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalLink, error) {
+	out := make([]model.ClientExternalLink, 0, len(inputs))
+	for _, in := range inputs {
+		value := strings.TrimSpace(in.Value)
+		if value == "" {
+			continue
+		}
+		kind := strings.TrimSpace(in.Kind)
+		switch kind {
+		case model.ExternalLinkKindSubscription:
+			if !isHTTPURL(value) {
+				return nil, common.NewError("external subscription must be an http(s) URL: " + value)
+			}
+		case model.ExternalLinkKindLink, "":
+			kind = model.ExternalLinkKindLink
+			if _, err := link.ParseLink(value); err != nil {
+				return nil, common.NewError("unsupported or invalid share link: " + value)
+			}
+		default:
+			return nil, common.NewError("unknown external link kind: " + kind)
+		}
+		out = append(out, model.ClientExternalLink{
+			Kind:      kind,
+			Value:     value,
+			Remark:    strings.TrimSpace(in.Remark),
+			SortIndex: len(out),
+		})
+	}
+	return out, nil
+}
+
+func isHTTPURL(s string) bool {
+	u, err := url.Parse(s)
+	return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
+}
+
+// SetExternalLinksForRecord replaces a client's entire external-link set.
+func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkInput) error {
+	rows, err := normalizeExternalLinks(inputs)
+	if err != nil {
+		return err
+	}
+	db := database.GetDB()
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
+			return err
+		}
+		for i := range rows {
+			rows[i].ClientId = id
+			if err := tx.Create(&rows[i]).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func (s *ClientService) SetExternalLinksByEmail(email string, inputs []ExternalLinkInput) error {
+	if strings.TrimSpace(email) == "" {
+		return common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return err
+	}
+	return s.SetExternalLinksForRecord(rec.Id, inputs)
+}

+ 6 - 0
internal/web/translation/ar-EG.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "أساسي",
       "tabCredentials": "بيانات الاعتماد",
+      "tabLinks": "الروابط",
+      "linksHint": "أضف روابط مشاركة من جهات خارجية وعناوين اشتراك خارجية لتضمينها في اشتراك هذا العميل.",
+      "addExternalLink": "إضافة رابط خارجي",
+      "addExternalSubscription": "إضافة اشتراك خارجي",
+      "noExternalLinks": "لا توجد روابط خارجية بعد.",
+      "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
       "add": "إضافة عميل",
       "edit": "تعديل العميل",
       "submitAdd": "إضافة عميل",

+ 7 - 1
internal/web/translation/en-US.json

@@ -648,6 +648,12 @@
     "clients": {
       "tabBasics": "Basics",
       "tabCredentials": "Credentials",
+      "tabLinks": "Links",
+      "linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
+      "addExternalLink": "Add External Link",
+      "addExternalSubscription": "Add External Subscription",
+      "noExternalLinks": "No external links yet.",
+      "noExternalSubscriptions": "No external subscriptions yet.",
       "add": "Add Client",
       "edit": "Edit Client",
       "submitAdd": "Add Client",
@@ -1765,4 +1771,4 @@
       "chooseInbound": "Choose an Inbound"
     }
   }
-}
+}

+ 6 - 0
internal/web/translation/es-ES.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Básico",
       "tabCredentials": "Credenciales",
+      "tabLinks": "Enlaces",
+      "linksHint": "Añade enlaces de terceros y URLs de suscripción remotas para incluirlos en la suscripción de este cliente.",
+      "addExternalLink": "Añadir enlace externo",
+      "addExternalSubscription": "Añadir suscripción externa",
+      "noExternalLinks": "Aún no hay enlaces externos.",
+      "noExternalSubscriptions": "Aún no hay suscripciones externas.",
       "add": "Añadir cliente",
       "edit": "Editar cliente",
       "submitAdd": "Añadir cliente",

+ 7 - 1
internal/web/translation/fa-IR.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "پایه",
       "tabCredentials": "اطلاعات اتصال",
+      "tabLinks": "لینک‌ها",
+      "linksHint": "لینک‌های اشتراک شخص‌ثالث و آدرس سابسکریپشن‌های خارجی را اضافه کنید تا در سابسکریپشن این کاربر قرار گیرند.",
+      "addExternalLink": "افزودن لینک خارجی",
+      "addExternalSubscription": "افزودن سابسکریپشن خارجی",
+      "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
+      "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
       "add": "افزودن کلاینت",
       "edit": "ویرایش کلاینت",
       "submitAdd": "افزودن کلاینت",
@@ -1764,4 +1770,4 @@
       "chooseInbound": "یک ورودی انتخاب کنید"
     }
   }
-}
+}

+ 6 - 0
internal/web/translation/id-ID.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Dasar",
       "tabCredentials": "Kredensial",
+      "tabLinks": "Tautan",
+      "linksHint": "Tambahkan tautan berbagi pihak ketiga dan URL langganan jarak jauh untuk disertakan dalam langganan klien ini.",
+      "addExternalLink": "Tambah Tautan Eksternal",
+      "addExternalSubscription": "Tambah Langganan Eksternal",
+      "noExternalLinks": "Belum ada tautan eksternal.",
+      "noExternalSubscriptions": "Belum ada langganan eksternal.",
       "add": "Tambah klien",
       "edit": "Ubah klien",
       "submitAdd": "Tambah klien",

+ 6 - 0
internal/web/translation/ja-JP.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "認証情報",
+      "tabLinks": "リンク",
+      "linksHint": "サードパーティの共有リンクやリモートのサブスクリプションURLを追加して、このクライアントのサブスクリプションに含めます。",
+      "addExternalLink": "外部リンクを追加",
+      "addExternalSubscription": "外部サブスクリプションを追加",
+      "noExternalLinks": "外部リンクはまだありません。",
+      "noExternalSubscriptions": "外部サブスクリプションはまだありません。",
       "add": "クライアントを追加",
       "edit": "クライアントを編集",
       "submitAdd": "クライアントを追加",

+ 6 - 0
internal/web/translation/pt-BR.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Básico",
       "tabCredentials": "Credenciais",
+      "tabLinks": "Links",
+      "linksHint": "Adicione links de terceiros e URLs de assinatura remotas para incluir na assinatura deste cliente.",
+      "addExternalLink": "Adicionar link externo",
+      "addExternalSubscription": "Adicionar assinatura externa",
+      "noExternalLinks": "Ainda não há links externos.",
+      "noExternalSubscriptions": "Ainda não há assinaturas externas.",
       "add": "Adicionar cliente",
       "edit": "Editar cliente",
       "submitAdd": "Adicionar cliente",

+ 6 - 0
internal/web/translation/ru-RU.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Основные",
       "tabCredentials": "Учетные данные",
+      "tabLinks": "Ссылки",
+      "linksHint": "Добавьте сторонние ссылки и URL удалённых подписок, чтобы включить их в подписку этого клиента.",
+      "addExternalLink": "Добавить внешнюю ссылку",
+      "addExternalSubscription": "Добавить внешнюю подписку",
+      "noExternalLinks": "Пока нет внешних ссылок.",
+      "noExternalSubscriptions": "Пока нет внешних подписок.",
       "add": "Добавить клиента",
       "edit": "Изменить клиента",
       "submitAdd": "Добавить клиента",

+ 6 - 0
internal/web/translation/tr-TR.json

@@ -648,6 +648,12 @@
     "clients": {
       "tabBasics": "Temel",
       "tabCredentials": "Kimlik Bilgileri",
+      "tabLinks": "Bağlantılar",
+      "linksHint": "Bu istemcinin aboneliğine dahil etmek için üçüncü taraf paylaşım bağlantıları ve uzak abonelik URL'leri ekleyin.",
+      "addExternalLink": "Harici Bağlantı Ekle",
+      "addExternalSubscription": "Harici Abonelik Ekle",
+      "noExternalLinks": "Henüz harici bağlantı yok.",
+      "noExternalSubscriptions": "Henüz harici abonelik yok.",
       "add": "Kullanıcı Ekle",
       "edit": "Kullanıcıyı Düzenle",
       "submitAdd": "Kullanıcı Ekle",

+ 6 - 0
internal/web/translation/uk-UA.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Основні",
       "tabCredentials": "Облікові дані",
+      "tabLinks": "Посилання",
+      "linksHint": "Додайте сторонні посилання та URL віддалених підписок, щоб включити їх до підписки цього клієнта.",
+      "addExternalLink": "Додати зовнішнє посилання",
+      "addExternalSubscription": "Додати зовнішню підписку",
+      "noExternalLinks": "Зовнішніх посилань ще немає.",
+      "noExternalSubscriptions": "Зовнішніх підписок ще немає.",
       "add": "Додати клієнта",
       "edit": "Редагувати клієнта",
       "submitAdd": "Додати клієнта",

+ 6 - 0
internal/web/translation/vi-VN.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "Cơ bản",
       "tabCredentials": "Thông tin xác thực",
+      "tabLinks": "Liên kết",
+      "linksHint": "Thêm liên kết chia sẻ của bên thứ ba và URL đăng ký từ xa để đưa vào đăng ký của khách hàng này.",
+      "addExternalLink": "Thêm liên kết ngoài",
+      "addExternalSubscription": "Thêm đăng ký ngoài",
+      "noExternalLinks": "Chưa có liên kết ngoài.",
+      "noExternalSubscriptions": "Chưa có đăng ký ngoài.",
       "add": "Thêm khách hàng",
       "edit": "Chỉnh sửa khách hàng",
       "submitAdd": "Thêm khách hàng",

+ 6 - 0
internal/web/translation/zh-CN.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "凭据",
+      "tabLinks": "链接",
+      "linksHint": "添加第三方分享链接和远程订阅地址,将其包含在该客户端的订阅中。",
+      "addExternalLink": "添加外部链接",
+      "addExternalSubscription": "添加外部订阅",
+      "noExternalLinks": "暂无外部链接。",
+      "noExternalSubscriptions": "暂无外部订阅。",
       "add": "添加客户端",
       "edit": "编辑客户端",
       "submitAdd": "添加客户端",

+ 6 - 0
internal/web/translation/zh-TW.json

@@ -647,6 +647,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "認證資訊",
+      "tabLinks": "連結",
+      "linksHint": "新增第三方分享連結和遠端訂閱網址,將其包含在此用戶端的訂閱中。",
+      "addExternalLink": "新增外部連結",
+      "addExternalSubscription": "新增外部訂閱",
+      "noExternalLinks": "尚無外部連結。",
+      "noExternalSubscriptions": "尚無外部訂閱。",
       "add": "新增客戶端",
       "edit": "編輯客戶端",
       "submitAdd": "新增客戶端",