Browse Source

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 20 hours ago
parent
commit
dcb923b4a1
33 changed files with 1204 additions and 28 deletions
  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": "新增客戶端",