mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 09:05:01 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 340d0df9fc | |||
| 982595968d | |||
| 21e9b94bb4 | |||
| 5038fa1cec | |||
| 709b332d17 |
+945
-26
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import type { HostFormValues } from '@/schemas/api/host';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
export function useHostMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<HostFormValues>) => HttpUtil.post('/panel/api/hosts/add', payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Partial<HostFormValues> }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/update/${id}`, payload),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const setEnableMut = useMutation({
|
||||
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
|
||||
HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const reorderMut = useMutation({
|
||||
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const bulkEnableMut = useMutation({
|
||||
mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) =>
|
||||
HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const bulkDelMut = useMutation({
|
||||
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
return {
|
||||
create: (payload: Partial<HostFormValues>) => createMut.mutateAsync(payload),
|
||||
update: (id: number, payload: Partial<HostFormValues>) => updateMut.mutateAsync({ id, payload }),
|
||||
remove: (id: number) => removeMut.mutateAsync(id),
|
||||
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||
reorder: (ids: number[]) => reorderMut.mutateAsync(ids),
|
||||
bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }),
|
||||
bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { HostListSchema, type HostRecord } from '@/schemas/api/host';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
export type { HostRecord };
|
||||
|
||||
async function fetchHosts(): Promise<HostRecord[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/hosts/list', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
|
||||
const validated = parseMsg(msg, HostListSchema, 'hosts/list');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
export function useHostsQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: keys.hosts.list(),
|
||||
queryFn: fetchHosts,
|
||||
});
|
||||
|
||||
const hosts = useMemo(() => query.data ?? [], [query.data]);
|
||||
|
||||
return {
|
||||
hosts,
|
||||
loading: query.isFetching,
|
||||
fetched: query.data !== undefined || query.isError,
|
||||
fetchError: query.error ? (query.error as Error).message : '',
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,12 @@ export const keys = {
|
||||
root: () => ['nodes'] as const,
|
||||
list: () => ['nodes', 'list'] as const,
|
||||
},
|
||||
hosts: {
|
||||
root: () => ['hosts'] as const,
|
||||
list: () => ['hosts', 'list'] as const,
|
||||
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||
tags: () => ['hosts', 'tags'] as const,
|
||||
},
|
||||
settings: {
|
||||
root: () => ['settings'] as const,
|
||||
all: () => ['settings', 'all'] as const,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button, Input, Popover, Tooltip } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { CodeOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import RemarkVarPicker from './RemarkVarPicker';
|
||||
|
||||
interface RemarkTemplateFieldProps {
|
||||
// Injected by antd Form.Item:
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
|
||||
* (insert-at-caret) and a live, sample-based preview of the expanded result.
|
||||
* Used for the global subscription Remark Template.
|
||||
*/
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
function insertToken(token: string) {
|
||||
const el = inputRef.current?.input;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
const insert = wrapToken(token);
|
||||
const next = value.slice(0, start) + insert + value.slice(end);
|
||||
onChange?.(maxLength ? next.slice(0, maxLength) : next);
|
||||
const caret = start + insert.length;
|
||||
// The controlled value updates next render; restore the caret after it.
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
addonAfter={
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
{hasRemarkTokens(value) && (
|
||||
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||
{t('pages.hosts.remarkVars.preview')}:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Tag, Tooltip, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
|
||||
interface RemarkVarPickerProps {
|
||||
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
|
||||
onPick: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
|
||||
* the global remark-template field.
|
||||
*/
|
||||
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
{t('pages.hosts.remarkVars.intro')}
|
||||
</Typography.Paragraph>
|
||||
{REMARK_VAR_GROUPS.map((group) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
|
||||
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
onClick={() => onPick(v.token)}
|
||||
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
|
||||
>
|
||||
{wrapToken(v.token)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,6 @@ export { default as DateTimePicker } from './DateTimePicker';
|
||||
export { default as JsonEditor } from './JsonEditor';
|
||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||
|
||||
@@ -27,7 +27,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapVlessField": "",
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkModel": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
@@ -47,7 +47,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subDomain": "",
|
||||
"subEmailInRemark": false,
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
@@ -63,7 +62,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowInfo": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -126,7 +124,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapVlessField": "",
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkModel": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
@@ -146,7 +144,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subDomain": "",
|
||||
"subEmailInRemark": false,
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
@@ -162,7 +159,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subPort": 1,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowInfo": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
@@ -277,6 +273,51 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"id": 0,
|
||||
"seederName": ""
|
||||
},
|
||||
"Host": {
|
||||
"address": "cdn.example.com",
|
||||
"allowInsecure": false,
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
],
|
||||
"finalMask": "",
|
||||
"fingerprint": "",
|
||||
"hostHeader": "",
|
||||
"id": 1,
|
||||
"inboundId": 1,
|
||||
"isDisabled": false,
|
||||
"isHidden": false,
|
||||
"keepSniBlank": false,
|
||||
"mihomoIpVersion": "dual",
|
||||
"mihomoX25519": false,
|
||||
"muxParams": null,
|
||||
"nodeGuids": [
|
||||
""
|
||||
],
|
||||
"overrideSniFromAddress": false,
|
||||
"path": "",
|
||||
"pinnedPeerCertSha256": [
|
||||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
"shuffleHost": false,
|
||||
"sni": "",
|
||||
"sockoptParams": null,
|
||||
"sortOrder": 0,
|
||||
"tags": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": false,
|
||||
"vlessRoute": ""
|
||||
},
|
||||
"Inbound": {
|
||||
"clientStats": [
|
||||
{
|
||||
|
||||
@@ -98,8 +98,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
||||
"type": "string"
|
||||
},
|
||||
"remarkModel": {
|
||||
"description": "Remark model pattern for inbounds",
|
||||
"remarkTemplate": {
|
||||
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
@@ -184,10 +184,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Domain for subscription server validation",
|
||||
"type": "string"
|
||||
},
|
||||
"subEmailInRemark": {
|
||||
"description": "Include email in subscription remark/name",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnable": {
|
||||
"description": "Subscription server settings\nEnable subscription server",
|
||||
"type": "boolean"
|
||||
@@ -249,10 +245,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Subscription global routing rules (Only for Happ)",
|
||||
"type": "string"
|
||||
},
|
||||
"subShowInfo": {
|
||||
"description": "Show client information in subscriptions",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"description": "Subscription support URL",
|
||||
"type": "string"
|
||||
@@ -398,7 +390,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkModel",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
@@ -418,7 +410,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subDomain",
|
||||
"subEmailInRemark",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
@@ -434,7 +425,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowInfo",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -584,8 +574,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
|
||||
"type": "string"
|
||||
},
|
||||
"remarkModel": {
|
||||
"description": "Remark model pattern for inbounds",
|
||||
"remarkTemplate": {
|
||||
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
|
||||
"type": "string"
|
||||
},
|
||||
"restartXrayOnClientDisable": {
|
||||
@@ -670,10 +660,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Domain for subscription server validation",
|
||||
"type": "string"
|
||||
},
|
||||
"subEmailInRemark": {
|
||||
"description": "Include email in subscription remark/name",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnable": {
|
||||
"description": "Subscription server settings\nEnable subscription server",
|
||||
"type": "boolean"
|
||||
@@ -735,10 +721,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Subscription global routing rules (Only for Happ)",
|
||||
"type": "string"
|
||||
},
|
||||
"subShowInfo": {
|
||||
"description": "Show client information in subscriptions",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subSupportUrl": {
|
||||
"description": "Subscription support URL",
|
||||
"type": "string"
|
||||
@@ -891,7 +873,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapVlessField",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkModel",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
@@ -911,7 +893,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subDomain",
|
||||
"subEmailInRemark",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
@@ -927,7 +908,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowInfo",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
@@ -1326,6 +1306,181 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Host": {
|
||||
"description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
|
||||
"properties": {
|
||||
"address": {
|
||||
"example": "cdn.example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"allowInsecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"alpn": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
"excludeFromSubTypes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"finalMask": {
|
||||
"description": "FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),\nmerged into this host's JSON-subscription stream. Empty = no override.",
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"hostHeader": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundId": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"isDisabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"keepSniBlank": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mihomoIpVersion": {
|
||||
"enum": [
|
||||
"dual",
|
||||
"ipv4",
|
||||
"ipv6",
|
||||
"ipv4-prefer",
|
||||
"ipv6-prefer"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"mihomoX25519": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"muxParams": {},
|
||||
"nodeGuids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"overrideSniFromAddress": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedPeerCertSha256": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"port": {
|
||||
"example": 8443,
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"enum": [
|
||||
"same",
|
||||
"tls",
|
||||
"none",
|
||||
"reality"
|
||||
],
|
||||
"example": "same",
|
||||
"type": "string"
|
||||
},
|
||||
"serverDescription": {
|
||||
"maxLength": 64,
|
||||
"type": "string"
|
||||
},
|
||||
"shuffleHost": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sni": {
|
||||
"type": "string"
|
||||
},
|
||||
"sockoptParams": {},
|
||||
"sortOrder": {
|
||||
"type": "integer"
|
||||
},
|
||||
"tags": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"verifyPeerCertByName": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"vlessRoute": {
|
||||
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
"fingerprint",
|
||||
"hostHeader",
|
||||
"id",
|
||||
"inboundId",
|
||||
"isDisabled",
|
||||
"isHidden",
|
||||
"keepSniBlank",
|
||||
"mihomoIpVersion",
|
||||
"mihomoX25519",
|
||||
"muxParams",
|
||||
"overrideSniFromAddress",
|
||||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
"shuffleHost",
|
||||
"sni",
|
||||
"sockoptParams",
|
||||
"sortOrder",
|
||||
"tags",
|
||||
"updatedAt",
|
||||
"verifyPeerCertByName",
|
||||
"vlessRoute"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Inbound": {
|
||||
"description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
|
||||
"properties": {
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface AllSetting {
|
||||
ldapVlessField: string;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkModel: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
@@ -53,7 +53,6 @@ export interface AllSetting {
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
@@ -69,7 +68,6 @@ export interface AllSetting {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowInfo: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -133,7 +131,7 @@ export interface AllSettingView {
|
||||
ldapVlessField: string;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkModel: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
@@ -153,7 +151,6 @@ export interface AllSettingView {
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
@@ -169,7 +166,6 @@ export interface AllSettingView {
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowInfo: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
@@ -294,6 +290,42 @@ export interface HistoryOfSeeders {
|
||||
seederName: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
address: string;
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
createdAt: number;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
finalMask: string;
|
||||
fingerprint: string;
|
||||
hostHeader: string;
|
||||
id: number;
|
||||
inboundId: number;
|
||||
isDisabled: boolean;
|
||||
isHidden: boolean;
|
||||
keepSniBlank: boolean;
|
||||
mihomoIpVersion: string;
|
||||
mihomoX25519: boolean;
|
||||
muxParams: unknown;
|
||||
nodeGuids?: string[];
|
||||
overrideSniFromAddress: boolean;
|
||||
path: string;
|
||||
pinnedPeerCertSha256: string[];
|
||||
port: number;
|
||||
remark: string;
|
||||
security: string;
|
||||
serverDescription: string;
|
||||
shuffleHost: boolean;
|
||||
sni: string;
|
||||
sockoptParams: unknown;
|
||||
sortOrder: number;
|
||||
tags: string[];
|
||||
updatedAt: number;
|
||||
verifyPeerCertByName: boolean;
|
||||
vlessRoute: string;
|
||||
}
|
||||
|
||||
export interface Inbound {
|
||||
clientStats: ClientTraffic[];
|
||||
down: number;
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AllSettingSchema = z.object({
|
||||
ldapVlessField: z.string(),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkModel: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
@@ -65,7 +65,6 @@ export const AllSettingSchema = z.object({
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
@@ -81,7 +80,6 @@ export const AllSettingSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowInfo: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -146,7 +144,7 @@ export const AllSettingViewSchema = z.object({
|
||||
ldapVlessField: z.string(),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkModel: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
@@ -166,7 +164,6 @@ export const AllSettingViewSchema = z.object({
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
@@ -182,7 +179,6 @@ export const AllSettingViewSchema = z.object({
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowInfo: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
@@ -317,6 +313,43 @@ export const HistoryOfSeedersSchema = z.object({
|
||||
});
|
||||
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
|
||||
|
||||
export const HostSchema = z.object({
|
||||
address: z.string(),
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
createdAt: z.number().int(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
finalMask: z.string(),
|
||||
fingerprint: z.string(),
|
||||
hostHeader: z.string(),
|
||||
id: z.number().int(),
|
||||
inboundId: z.number().int(),
|
||||
isDisabled: z.boolean(),
|
||||
isHidden: z.boolean(),
|
||||
keepSniBlank: z.boolean(),
|
||||
mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']),
|
||||
mihomoX25519: z.boolean(),
|
||||
muxParams: z.unknown(),
|
||||
nodeGuids: z.array(z.string()).optional(),
|
||||
overrideSniFromAddress: z.boolean(),
|
||||
path: z.string(),
|
||||
pinnedPeerCertSha256: z.array(z.string()),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
remark: z.string().max(256),
|
||||
security: z.enum(['same', 'tls', 'none', 'reality']),
|
||||
serverDescription: z.string().max(64),
|
||||
shuffleHost: z.boolean(),
|
||||
sni: z.string(),
|
||||
sockoptParams: z.unknown(),
|
||||
sortOrder: z.number().int(),
|
||||
tags: z.array(z.string()),
|
||||
updatedAt: z.number().int(),
|
||||
verifyPeerCertByName: z.boolean(),
|
||||
vlessRoute: z.string(),
|
||||
});
|
||||
export type Host = z.infer<typeof HostSchema>;
|
||||
|
||||
export const InboundSchema = z.object({
|
||||
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
|
||||
down: z.number().int(),
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
CodeOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
ExportOutlined,
|
||||
GithubOutlined,
|
||||
GlobalOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -41,7 +42,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
|
||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
|
||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
@@ -51,9 +52,10 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
hosts: GlobalOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
outbound: UploadOutlined,
|
||||
outbound: ExportOutlined,
|
||||
};
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
@@ -139,6 +141,7 @@ export default function AppSidebar() {
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
||||
import type { HostFormValues } from '@/schemas/api/host';
|
||||
|
||||
// The subset of a host that affects its share link. Mirrors the fields the
|
||||
// backend's hostToExternalProxyMap reads.
|
||||
export type HostLinkInput = Pick<
|
||||
HostFormValues,
|
||||
| 'security'
|
||||
| 'address'
|
||||
| 'port'
|
||||
| 'remark'
|
||||
| 'sni'
|
||||
| 'alpn'
|
||||
| 'fingerprint'
|
||||
| 'pinnedPeerCertSha256'
|
||||
| 'echConfigList'
|
||||
| 'overrideSniFromAddress'
|
||||
| 'keepSniBlank'
|
||||
>;
|
||||
|
||||
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
|
||||
// share-link preview generators already understand — the frontend mirror of the
|
||||
// backend's hostToExternalProxyMap. security "reality"/"same" keep the inbound's
|
||||
// base TLS (forceTls "same"); the preview falls back to port 443 when the host
|
||||
// inherits the inbound port (port 0).
|
||||
export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntry {
|
||||
const forceTls = host.security === 'tls' || host.security === 'none' ? host.security : 'same';
|
||||
|
||||
let sni: string | undefined;
|
||||
if (host.keepSniBlank) {
|
||||
sni = undefined;
|
||||
} else if (host.overrideSniFromAddress) {
|
||||
sni = host.address || undefined;
|
||||
} else {
|
||||
sni = host.sni || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
forceTls,
|
||||
dest: host.address || '',
|
||||
port: host.port && host.port > 0 ? host.port : 443,
|
||||
remark: host.remark || '',
|
||||
sni,
|
||||
fingerprint: host.fingerprint,
|
||||
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
|
||||
pinnedPeerCertSha256:
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
|
||||
echConfigList: host.echConfigList || undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Template variables an operator can embed in a Host's Remark. At subscription
|
||||
// time the backend (internal/sub/remark_vars.go) substitutes each {{TOKEN}}
|
||||
// per client. This file is the single frontend source of truth for the picker
|
||||
// UI and the live preview — keep the token list in sync with remark_vars.go.
|
||||
|
||||
export type RemarkVarGroup = 'client' | 'traffic' | 'time';
|
||||
|
||||
export interface RemarkVar {
|
||||
/** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
|
||||
token: string;
|
||||
group: RemarkVarGroup;
|
||||
/** Example value used only for the form's live preview. */
|
||||
sample: string;
|
||||
}
|
||||
|
||||
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
|
||||
|
||||
export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
// Client identity
|
||||
{ token: 'EMAIL', group: 'client', sample: 'john' },
|
||||
{ token: 'INBOUND', group: 'client', sample: 'Germany' },
|
||||
{ token: 'HOST', group: 'client', sample: 'CDN' },
|
||||
{ token: 'ID', group: 'client', sample: '3f2a9c1b-aaaa-bbbb-cccc-1234567890ab' },
|
||||
{ token: 'SHORT_ID', group: 'client', sample: '3f2a9c1b' },
|
||||
{ token: 'TELEGRAM_ID', group: 'client', sample: '123456789' },
|
||||
{ token: 'SUB_ID', group: 'client', sample: 'subABC' },
|
||||
{ token: 'COMMENT', group: 'client', sample: 'vip' },
|
||||
// Traffic
|
||||
{ token: 'TRAFFIC_USED', group: 'traffic', sample: '8.40GB' },
|
||||
{ token: 'TRAFFIC_LEFT', group: 'traffic', sample: '41.60GB' },
|
||||
{ token: 'TRAFFIC_TOTAL', group: 'traffic', sample: '50.00GB' },
|
||||
{ token: 'TRAFFIC_USED_BYTES', group: 'traffic', sample: '9019431321' },
|
||||
{ token: 'TRAFFIC_LEFT_BYTES', group: 'traffic', sample: '44667656679' },
|
||||
{ token: 'TRAFFIC_TOTAL_BYTES', group: 'traffic', sample: '53687091200' },
|
||||
{ token: 'UP', group: 'traffic', sample: '5.20GB' },
|
||||
{ token: 'DOWN', group: 'traffic', sample: '3.20GB' },
|
||||
// Time / status
|
||||
{ token: 'STATUS', group: 'time', sample: 'active' },
|
||||
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
|
||||
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
|
||||
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
|
||||
{ token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
|
||||
{ token: 'RESET_DAYS', group: 'time', sample: '30' },
|
||||
];
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||
);
|
||||
|
||||
const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
|
||||
|
||||
/** wrapToken("EMAIL") → "{{EMAIL}}". */
|
||||
export function wrapToken(token: string): string {
|
||||
return `{{${token}}}`;
|
||||
}
|
||||
|
||||
/** Whether a remark string uses any {{VAR}} token at all. */
|
||||
export function hasRemarkTokens(template: string): boolean {
|
||||
return template.includes('{{');
|
||||
}
|
||||
|
||||
/**
|
||||
* previewRemark renders a template against the sample values, mirroring the
|
||||
* backend substitution closely enough for an at-a-glance preview. Unknown
|
||||
* tokens collapse to empty, just like the server.
|
||||
*/
|
||||
export function previewRemark(template: string): string {
|
||||
if (!hasRemarkTokens(template)) return template;
|
||||
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button, Divider, Form, Input, Select } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
|
||||
// entry is rendered as a titled group of labeled fields (system / level / opt /
|
||||
// type / value) instead of one cramped inline row, so it reads like the rest of
|
||||
// the sockopt form. Shared by the inbound and outbound (and host) sockopt forms.
|
||||
// Ref: https://xtls.github.io/config/transports/sockopt.html#sockoptobject
|
||||
|
||||
const SYSTEM_OPTIONS = [
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'windows', label: 'windows' },
|
||||
{ value: 'darwin', label: 'darwin' },
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'int', label: 'int' },
|
||||
{ value: 'str', label: 'str' },
|
||||
];
|
||||
|
||||
interface CustomSockoptListProps {
|
||||
name?: NamePath;
|
||||
}
|
||||
|
||||
export default function CustomSockoptList({
|
||||
name = ['streamSettings', 'sockopt', 'customSockopt'],
|
||||
}: CustomSockoptListProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
||||
>
|
||||
{t('pages.inbounds.form.addCustomOption')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key}>
|
||||
<Divider plain style={{ margin: '4px 0 8px' }}>
|
||||
{t('pages.inbounds.form.customSockopt')} {idx + 1}
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
style={{ marginInlineStart: 8 }}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</Divider>
|
||||
<Form.Item label="System" name={[field.name, 'system']}>
|
||||
<Select placeholder="all" allowClear options={SYSTEM_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Level" name={[field.name, 'level']}>
|
||||
<Input placeholder="6 (SOL_TCP)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Opt" name={[field.name, 'opt']}>
|
||||
<Input placeholder="decimal, e.g. 19" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" name={[field.name, 'type']}>
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Value" name={[field.name, 'value']}>
|
||||
<Input placeholder="value" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
|
||||
case 'sudoku':
|
||||
return {
|
||||
password: '', ascii: '', customTable: '', customTables: [''],
|
||||
password: '', ascii: '', customTable: '', customTables: [],
|
||||
paddingMin: 0, paddingMax: 0,
|
||||
};
|
||||
case 'header-custom':
|
||||
|
||||
@@ -595,6 +595,18 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
}
|
||||
|
||||
// SIP002 clients (v2rayN) ignore type/headerType/host/path and only read
|
||||
// `plugin`. Re-encode a TCP http header as obfs-local so they build a
|
||||
// matching tcp/http outbound (v2rayN forces request path "/").
|
||||
if ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') {
|
||||
const host = params.get('host') ?? '';
|
||||
params.delete('type');
|
||||
params.delete('headerType');
|
||||
params.delete('host');
|
||||
params.delete('path');
|
||||
params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`);
|
||||
}
|
||||
|
||||
const isSS2022 = settings.method.substring(0, 4) === '2022';
|
||||
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
const passwords: string[] = [];
|
||||
@@ -983,23 +995,19 @@ export interface GenAllLinksEntry {
|
||||
export interface GenAllLinksInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
client: ClientShape;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
// Fans out a single client's link per externalProxy entry, or just one
|
||||
// link when there are no external proxies. remarkModel is a 4-char
|
||||
// string: first char is the separator, remaining chars pick which
|
||||
// pieces to compose into the per-link remark — 'i' = inbound remark,
|
||||
// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
|
||||
// (dash-separated, inbound + email + proxy).
|
||||
// Fans out a single client's link per externalProxy entry, or just one link
|
||||
// when there are no external proxies. The panel copy/QR remark is the inbound
|
||||
// remark plus the externalProxy remark, dash-joined (the configurable
|
||||
// subscription remark model was removed; subscription output uses the template).
|
||||
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
remarkModel = '-io',
|
||||
client,
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
@@ -1007,17 +1015,9 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const port = inbound.port;
|
||||
const separationChar = remarkModel.charAt(0);
|
||||
const orderChars = remarkModel.slice(1);
|
||||
const email = client.email ?? '';
|
||||
|
||||
const composeRemark = (proxyRemark: string): string => {
|
||||
const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
|
||||
return orderChars.split('')
|
||||
.map((c) => orders[c] ?? '')
|
||||
.filter((x) => x.length > 0)
|
||||
.join(separationChar);
|
||||
};
|
||||
const composeRemark = (proxyRemark: string): string =>
|
||||
[remark, proxyRemark].filter((x) => x.length > 0).join('-');
|
||||
|
||||
const externals = inbound.streamSettings?.externalProxy;
|
||||
if (!externals || externals.length === 0) {
|
||||
@@ -1044,7 +1044,6 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
export interface GenInboundLinksInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
@@ -1058,7 +1057,6 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
remarkModel = '-io',
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
@@ -1067,7 +1065,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
if (clients) {
|
||||
const links: string[] = [];
|
||||
for (const client of clients) {
|
||||
const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
|
||||
const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
|
||||
for (const e of entries) links.push(e.link);
|
||||
}
|
||||
return links.join('\r\n');
|
||||
@@ -1076,7 +1074,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
||||
}
|
||||
if (inbound.protocol === 'wireguard') {
|
||||
return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
|
||||
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -1087,16 +1085,15 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
export interface GenWireguardFanoutInput {
|
||||
inbound: Inbound;
|
||||
remark?: string;
|
||||
remarkModel?: string;
|
||||
hostOverride?: string;
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = remarkModel.charAt(0);
|
||||
const sep = '-';
|
||||
return inbound.settings.peers
|
||||
.map((p, i) => genWireguardLink({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
@@ -1109,10 +1106,10 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
}
|
||||
|
||||
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = remarkModel.charAt(0);
|
||||
const sep = '-';
|
||||
return inbound.settings.peers
|
||||
.map((p, i) => genWireguardConfig({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
|
||||
@@ -47,29 +47,16 @@ const TRANSPORT_COLOR = 'gold';
|
||||
|
||||
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
|
||||
|
||||
/* Strip the client email and the optional traffic/expiry decorations the
|
||||
panel appends to a remark (e.g. "5.23GB📊", "30D⏳", "⛔️N/A") together
|
||||
with any separator chars left dangling, so the label shows just the
|
||||
inbound remark. The email is known from the client record, so it can be
|
||||
removed even though its position in the composed remark depends on the
|
||||
panel's remark-model settings. */
|
||||
function cleanRemark(remark: string, email: string): string {
|
||||
let r = remark
|
||||
.replace(/⛔️?N\/A/gu, '')
|
||||
.replace(/[0-9][0-9A-Za-z.,]*[📊⏳]/gu, '');
|
||||
if (email) {
|
||||
const esc = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
r = r.replace(new RegExp(`[\\s\\-_.|,@]*${esc}`, 'g'), '');
|
||||
}
|
||||
return r.replace(/^[\s\-_.|,@]+|[\s\-_.|,@]+$/gu, '').trim();
|
||||
}
|
||||
/* Pull protocol, transport, security plus the remark and port out of a share
|
||||
link. vless/trojan carry network+security as `type`/`security` query params
|
||||
and the remark in the URL hash; vmess packs them into the base64 JSON as
|
||||
`net`/`tls`/`ps`/`port`. Returns null when the scheme is unknown or the
|
||||
payload can't be parsed, so callers fall back to "Link N".
|
||||
|
||||
/* Pull protocol, transport, security plus the inbound remark and port out
|
||||
of a share link. vless/trojan carry network+security as `type`/`security`
|
||||
query params and the remark in the URL hash; vmess packs them into the
|
||||
base64 JSON as `net`/`tls`/`ps`/`port`. Returns null when the scheme is
|
||||
unknown or the payload can't be parsed, so callers fall back to "Link N". */
|
||||
export function parseLinkParts(link: string, email = ''): LinkParts | null {
|
||||
The remark is shown verbatim: the panel displays the subscription's clean
|
||||
(name-only) remarks — the per-client traffic/expiry info is rendered only
|
||||
into the body a client app imports, so there is nothing to strip here. */
|
||||
export function parseLinkParts(link: string): LinkParts | null {
|
||||
const trimmed = link.trim();
|
||||
const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? '';
|
||||
if (!scheme) return null;
|
||||
@@ -106,7 +93,7 @@ export function parseLinkParts(link: string, email = ''): LinkParts | null {
|
||||
protocol,
|
||||
network: network.toUpperCase(),
|
||||
security: security.toUpperCase(),
|
||||
remark: cleanRemark(remark, email),
|
||||
remark: remark.trim(),
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export class AllSetting {
|
||||
pageSize = 25;
|
||||
expireDiff = 0;
|
||||
trafficDiff = 0;
|
||||
remarkModel = '-io';
|
||||
remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
@@ -48,8 +48,6 @@ export class AllSetting {
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
subEncrypt = true;
|
||||
subShowInfo = true;
|
||||
subEmailInRemark = true;
|
||||
subURI = '';
|
||||
subJsonURI = '';
|
||||
subClashURI = '';
|
||||
|
||||
@@ -907,6 +907,99 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'hosts',
|
||||
title: 'Hosts',
|
||||
description:
|
||||
'Per-inbound override endpoints. Each enabled host renders one extra subscription link/proxy with its own address/port/TLS, superseding the legacy externalProxy array. All endpoints under /panel/api/hosts.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/list',
|
||||
summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
|
||||
responseSchema: 'Host',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/get/:id',
|
||||
summary: 'Fetch a single host by ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||
],
|
||||
responseSchema: 'Host',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/byInbound/:inboundId',
|
||||
summary: "Fetch one inbound's hosts, ordered by sort order then id.",
|
||||
params: [
|
||||
{ name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
responseSchema: 'Host',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/tags',
|
||||
summary: 'Distinct, sorted set of tags used across all hosts.',
|
||||
response: '{\n "success": true,\n "obj": ["CDN", "EU", "FAST"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/add',
|
||||
summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).',
|
||||
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
|
||||
responseSchema: 'Host',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/update/:id',
|
||||
summary: 'Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||
],
|
||||
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
|
||||
responseSchema: 'Host',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/del/:id',
|
||||
summary: 'Delete a host.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/setEnable/:id',
|
||||
summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
|
||||
],
|
||||
body: '{\n "enable": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/reorder',
|
||||
summary: 'Set host sort order by the position of each id in the array.',
|
||||
body: '{\n "ids": [3, 1, 2]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/bulk/setEnable',
|
||||
summary: 'Enable or disable many hosts in one call.',
|
||||
body: '{\n "ids": [1, 2, 3],\n "enable": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/bulk/del',
|
||||
summary: 'Delete many hosts in one call.',
|
||||
body: '{\n "ids": [1, 2, 3]\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'backup',
|
||||
title: 'Backup',
|
||||
|
||||
@@ -354,7 +354,7 @@ export default function ClientInfoModal({
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link, client.email);
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function ClientQrModal({
|
||||
});
|
||||
}
|
||||
links.forEach((link, idx) => {
|
||||
const parts = parseLinkParts(link, client?.email ?? '');
|
||||
const parts = parseLinkParts(link);
|
||||
const meta = parts ? linkMetaText(parts) : '';
|
||||
const label: React.ReactNode = parts ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function SubLinksModal({
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('pages.clients.subLinksDisabled')}
|
||||
title={t('pages.clients.subLinksDisabled')}
|
||||
description={t('pages.clients.subLinksDisabledHint')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
@@ -174,7 +174,7 @@ export default function SubLinksModal({
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('pages.clients.subLinksEmpty')}
|
||||
title={t('pages.clients.subLinksEmpty')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
|
||||
import {
|
||||
ProfileOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ControlOutlined,
|
||||
NodeIndexOutlined,
|
||||
SettingOutlined,
|
||||
PartitionOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
RocketOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import type { HostFormValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
|
||||
|
||||
// inboundId is optional in the form so a new host starts unselected (the Select
|
||||
// shows its placeholder instead of 0); the required rule enforces it on submit.
|
||||
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
|
||||
|
||||
interface HostFormModalProps {
|
||||
open: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
host: HostRecord | null;
|
||||
inboundOptions: InboundOption[];
|
||||
save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
function defaultsFor(host: HostRecord | null): FormShape {
|
||||
return {
|
||||
inboundId: host?.inboundId,
|
||||
sortOrder: host?.sortOrder ?? 0,
|
||||
remark: host?.remark ?? '',
|
||||
serverDescription: host?.serverDescription ?? '',
|
||||
enable: host ? !host.isDisabled : true,
|
||||
isHidden: host?.isHidden ?? false,
|
||||
tags: host?.tags ?? [],
|
||||
address: host?.address ?? '',
|
||||
port: host?.port ?? 0,
|
||||
security: (host?.security as HostFormValues['security']) ?? 'same',
|
||||
sni: host?.sni ?? '',
|
||||
hostHeader: host?.hostHeader ?? '',
|
||||
path: host?.path ?? '',
|
||||
alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
|
||||
fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
|
||||
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||
keepSniBlank: host?.keepSniBlank ?? false,
|
||||
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||
verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
|
||||
allowInsecure: host?.allowInsecure ?? false,
|
||||
echConfigList: host?.echConfigList ?? '',
|
||||
muxParams: asString(host?.muxParams),
|
||||
sockoptParams: asString(host?.sockoptParams),
|
||||
finalMask: host?.finalMask ?? '',
|
||||
vlessRoute: host?.vlessRoute ?? '',
|
||||
excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
|
||||
nodeGuids: host?.nodeGuids ?? [],
|
||||
mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
|
||||
mihomoX25519: host?.mihomoX25519 ?? false,
|
||||
shuffleHost: host?.shuffleHost ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [form] = Form.useForm<FormShape>();
|
||||
|
||||
// Drive conditional field visibility off the selected security, like the
|
||||
// legacy externalProxy form: same/none inherit fully and hide every TLS/cert
|
||||
// field; reality shows only the reality-relevant subset (its keys are
|
||||
// inherited from the inbound); tls shows the full TLS override set.
|
||||
const security = (Form.useWatch('security', form) ?? 'same') as string;
|
||||
const showTls = security === 'tls' || security === 'reality';
|
||||
const showTlsExtras = security === 'tls';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) form.setFieldsValue(defaultsFor(host));
|
||||
}, [open, host, form]);
|
||||
|
||||
const { nodes } = useNodesQuery();
|
||||
|
||||
const inboundSelectOptions = useMemo(
|
||||
() => inboundOptions.map((ib) => ({
|
||||
value: ib.id,
|
||||
label: ib.remark || ib.tag || `#${ib.id}`,
|
||||
})),
|
||||
[inboundOptions],
|
||||
);
|
||||
|
||||
const nodeSelectOptions = useMemo(
|
||||
() => nodes
|
||||
.filter((n) => n.guid)
|
||||
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
|
||||
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
|
||||
|
||||
const onOk = async () => {
|
||||
let values: FormShape;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { enable, ...rest } = values;
|
||||
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
|
||||
const res = await save(payload);
|
||||
if (res?.success) {
|
||||
message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
|
||||
onOpenChange(false);
|
||||
} else if (res?.msg) {
|
||||
message.error(res.msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
|
||||
onOk={onOk}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
okText={t('save')}
|
||||
cancelText={t('cancel')}
|
||||
destroyOnHidden
|
||||
width={isMobile ? '95vw' : 760}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
labelWrap
|
||||
initialValues={defaultsFor(host)}
|
||||
preserve={false}
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="basic"
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
|
||||
<Input maxLength={256} />
|
||||
</Form.Item>
|
||||
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={inboundSelectOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
disabled={mode === 'edit'}
|
||||
placeholder={t('pages.hosts.selectInbound')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
|
||||
<Input placeholder="cdn.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
|
||||
<InputNumber min={0} max={65535} />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
|
||||
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="security" label={t('pages.hosts.fields.security')}>
|
||||
<Select
|
||||
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{showTls && (
|
||||
<>
|
||||
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
|
||||
<Select allowClear options={fpOptions} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{showTlsExtras && (
|
||||
<>
|
||||
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
|
||||
children: (
|
||||
<Tabs
|
||||
size="small"
|
||||
defaultActiveKey="adv-general"
|
||||
items={[
|
||||
{
|
||||
key: 'adv-general',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label={t('pages.hosts.fields.path')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
|
||||
<Input placeholder="53,443,1000-2000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'adv-mux',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
|
||||
children: (
|
||||
<Form.Item name="muxParams" noStyle>
|
||||
<HostMuxForm />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'adv-sockopt',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
|
||||
children: (
|
||||
<Form.Item name="sockoptParams" noStyle>
|
||||
<HostSockoptForm />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'adv-finalmask',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
|
||||
children: (
|
||||
<Form.Item name="finalMask" noStyle>
|
||||
<HostFinalMaskForm />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'clash',
|
||||
forceRender: true,
|
||||
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
|
||||
<Select
|
||||
allowClear
|
||||
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.hosts-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.host-remark-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.host-remark {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.host-desc {
|
||||
font-size: 0.82em;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.host-endpoint {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.host-muted {
|
||||
color: var(--ant-color-text-quaternary);
|
||||
}
|
||||
|
||||
/* card-toolbar is shared with the Clients/Inbounds list cards, but its rules
|
||||
live in a lazily-loaded page stylesheet — re-declare here so the Hosts page
|
||||
renders correctly when opened directly. */
|
||||
.hosts-card .card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 920px) {
|
||||
.hosts-card .card-toolbar {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty-table state. The shared .card-empty rule otherwise lives only in the
|
||||
lazily-loaded Clients/Inbounds/Nodes stylesheets, so a direct /hosts refresh
|
||||
would render it unstyled (faint + uncentered) until another page is visited.
|
||||
Re-declare it here so it's correct on first load. */
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
color: var(--ant-color-text-secondary);
|
||||
padding: 24px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import './HostList.css';
|
||||
|
||||
interface HostListProps {
|
||||
hosts: HostRecord[];
|
||||
inboundOptions: InboundOption[];
|
||||
loading?: boolean;
|
||||
isMobile?: boolean;
|
||||
selectedIds: number[];
|
||||
onSelectionChange: (ids: number[]) => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (host: HostRecord) => void;
|
||||
onDelete: (host: HostRecord) => void;
|
||||
onToggleEnable: (host: HostRecord, next: boolean) => void;
|
||||
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
|
||||
onBulkEnable: (enable: boolean) => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
|
||||
// Sorted by inbound then sort_order then id — the same order the subscription
|
||||
// renderer uses, so the list mirrors the emitted link order.
|
||||
function sortHosts(hosts: HostRecord[]): HostRecord[] {
|
||||
return [...hosts].sort((a, b) => {
|
||||
if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
|
||||
const sa = a.sortOrder ?? 0;
|
||||
const sb = b.sortOrder ?? 0;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
export default function HostList(props: HostListProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange,
|
||||
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
|
||||
} = props;
|
||||
|
||||
const inboundLabel = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`);
|
||||
return map;
|
||||
}, [inboundOptions]);
|
||||
|
||||
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
|
||||
|
||||
// Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
|
||||
const movable = useMemo(() => {
|
||||
const byInbound = new Map<number, number>();
|
||||
const idxInGroup = new Map<number, number>();
|
||||
const counters = new Map<number, number>();
|
||||
for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
|
||||
for (const h of sorted) {
|
||||
const c = counters.get(h.inboundId) ?? 0;
|
||||
idxInGroup.set(h.id, c);
|
||||
counters.set(h.inboundId, c + 1);
|
||||
}
|
||||
return { byInbound, idxInGroup };
|
||||
}, [sorted]);
|
||||
|
||||
// Column order requested: Actions, Enable, then the rest.
|
||||
const columns: ColumnsType<HostRecord> = [
|
||||
{
|
||||
title: t('pages.hosts.fields.actions'),
|
||||
key: 'actions',
|
||||
width: 168,
|
||||
render: (_, h) => {
|
||||
const idx = movable.idxInGroup.get(h.id) ?? 0;
|
||||
const count = movable.byInbound.get(h.inboundId) ?? 1;
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Tooltip title={t('pages.hosts.moveUp')}>
|
||||
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.hosts.moveDown')}>
|
||||
<Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.enable'),
|
||||
key: 'enable',
|
||||
width: 90,
|
||||
render: (_, h) => (
|
||||
<Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.remark'),
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
render: (_, h) => (
|
||||
<div className="host-remark-cell">
|
||||
<span className="host-remark">{h.remark}</span>
|
||||
{h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.endpoint'),
|
||||
key: 'endpoint',
|
||||
render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.inbound'),
|
||||
key: 'inbound',
|
||||
render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`,
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.security'),
|
||||
dataIndex: 'security',
|
||||
key: 'security',
|
||||
render: (security: string) => <Tag>{security || 'same'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('pages.hosts.fields.tags'),
|
||||
key: 'tags',
|
||||
render: (_, h) => (h.tags && h.tags.length > 0
|
||||
? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
|
||||
: <span className="host-muted">—</span>),
|
||||
},
|
||||
];
|
||||
|
||||
const toolbar = (
|
||||
<div className="card-toolbar">
|
||||
{selectedIds.length === 0 ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
{!isMobile && t('pages.hosts.addHost')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Tag
|
||||
color="blue"
|
||||
closable
|
||||
onClose={() => onSelectionChange([])}
|
||||
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
|
||||
>
|
||||
{t('pages.hosts.selectedCount', { count: selectedIds.length })}
|
||||
</Tag>
|
||||
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
|
||||
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable title={toolbar} className="hosts-card">
|
||||
<Table<HostRecord>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={sorted}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => onSelectionChange(keys as number[]),
|
||||
}}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
|
||||
import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useHostsQuery, type HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { useHostMutations } from '@/api/queries/useHostMutations';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import type { HostFormValues } from '@/schemas/api/host';
|
||||
import HostList from './HostList';
|
||||
import HostFormModal from './HostFormModal';
|
||||
|
||||
// Hosts for one inbound in render order — used to compute a reorder payload.
|
||||
function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
|
||||
return hosts
|
||||
.filter((h) => h.inboundId === inboundId)
|
||||
.sort((a, b) => {
|
||||
const sa = a.sortOrder ?? 0;
|
||||
const sb = b.sortOrder ?? 0;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
export default function HostsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
|
||||
const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
|
||||
const { data: inboundOptions = [] } = useInboundOptions();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formHost, setFormHost] = useState<HostRecord | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
setFormHost(null);
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onEdit = useCallback((host: HostRecord) => {
|
||||
setFormMode('edit');
|
||||
setFormHost({ ...host });
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(async (payload: Partial<HostFormValues>) => {
|
||||
if (formMode === 'edit' && formHost?.id) {
|
||||
return update(formHost.id, payload);
|
||||
}
|
||||
return create(payload);
|
||||
}, [formMode, formHost, update, create]);
|
||||
|
||||
const onDelete = useCallback((host: HostRecord) => {
|
||||
modal.confirm({
|
||||
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await remove(host.id);
|
||||
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
|
||||
},
|
||||
});
|
||||
}, [modal, t, remove, messageApi]);
|
||||
|
||||
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
|
||||
await setEnable(host.id, next);
|
||||
}, [setEnable]);
|
||||
|
||||
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
|
||||
const group = inboundHostsInOrder(hosts, host.inboundId);
|
||||
const idx = group.findIndex((h) => h.id === host.id);
|
||||
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
|
||||
if (idx < 0 || swapWith < 0 || swapWith >= group.length) return;
|
||||
const ids = group.map((h) => h.id);
|
||||
[ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]];
|
||||
await reorder(ids);
|
||||
}, [hosts, reorder]);
|
||||
|
||||
const onBulkEnable = useCallback(async (enable: boolean) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const msg = await bulkSetEnable(selectedIds, enable);
|
||||
if (msg?.success) setSelectedIds([]);
|
||||
}, [selectedIds, bulkSetEnable]);
|
||||
|
||||
const onBulkDelete = useCallback(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
modal.confirm({
|
||||
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await bulkDel(selectedIds);
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.hosts.toasts.delete'));
|
||||
setSelectedIds([]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [selectedIds, modal, t, bulkDel, messageApi]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = hosts.length;
|
||||
const enabled = hosts.filter((h) => !h.isDisabled).length;
|
||||
return { total, enabled, disabled: total - enabled };
|
||||
}, [hosts]);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['hosts-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title={t('somethingWentWrong')}
|
||||
subTitle={fetchError}
|
||||
extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
<Col span={24}>
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={8} sm={8} md={8}>
|
||||
<Statistic
|
||||
title={t('pages.hosts.summary.total')}
|
||||
value={String(summary.total)}
|
||||
prefix={<GlobalOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={8} sm={8} md={8}>
|
||||
<Statistic
|
||||
title={t('pages.hosts.summary.enabled')}
|
||||
value={String(summary.enabled)}
|
||||
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={8} sm={8} md={8}>
|
||||
<Statistic
|
||||
title={t('pages.hosts.summary.disabled')}
|
||||
value={String(summary.disabled)}
|
||||
prefix={<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<HostList
|
||||
hosts={hosts}
|
||||
inboundOptions={inboundOptions}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleEnable={onToggleEnable}
|
||||
onMove={onMove}
|
||||
onBulkEnable={onBulkEnable}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<HostFormModal
|
||||
open={formOpen}
|
||||
mode={formMode}
|
||||
host={formHost}
|
||||
inboundOptions={inboundOptions}
|
||||
save={onSave}
|
||||
onOpenChange={setFormOpen}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||
|
||||
// Per-host Final Mask editor — same shape as the sub-JSON settings one
|
||||
// (SubJsonFinalMaskForm) but reused for a host: reads/writes the host's
|
||||
// finalMask JSON string. The masks are merged into this host's JSON stream.
|
||||
|
||||
function hasValue(v: unknown): boolean {
|
||||
if (v == null) return false;
|
||||
if (Array.isArray(v)) return v.some(hasValue);
|
||||
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
|
||||
if (typeof v === 'string') return v.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseFinalMask(raw: string): FinalMaskStreamSettings {
|
||||
try {
|
||||
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
|
||||
} catch {
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
|
||||
export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => parseFinalMask(value));
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (finalmask === undefined) return;
|
||||
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
|
||||
if (next !== value) onChangeRef.current?.(next);
|
||||
}, [finalmask, value]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
component={false}
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
labelWrap
|
||||
initialValues={{ finalmask: initial }}
|
||||
>
|
||||
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MuxForm } from '@/pages/xray/outbounds/transport';
|
||||
|
||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||
import { serializeOverride } from './helpers';
|
||||
|
||||
// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
|
||||
// settings editor). Stored in the host's muxParams JSON string. Defaults match
|
||||
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
|
||||
// when the toggle is off, an explicit mux object when on.
|
||||
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
|
||||
|
||||
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||
return (
|
||||
<OutboundSubtreeJsonForm
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
path={['mux']}
|
||||
defaultSubtree={DEFAULT_MUX}
|
||||
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
|
||||
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
|
||||
// a host's mux override is protocol-agnostic and should always be editable.
|
||||
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SockoptForm } from '@/pages/xray/outbounds/transport';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
|
||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||
import { serializeOverride } from './helpers';
|
||||
|
||||
// Sockopt override editor — reuses the outbound SockoptForm (which carries its
|
||||
// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
|
||||
// sockoptParams JSON string.
|
||||
//
|
||||
// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
|
||||
// from the output. Verified against xray-core transport/internet/sockopt_*.go:
|
||||
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
|
||||
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
|
||||
// on the outbound/dialer socket, so they stay. The outbound form no longer shows
|
||||
// the inbound-only keys, but its default object still seeds them, so strip here.
|
||||
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
|
||||
|
||||
function serializeClientSockopt(sockopt: unknown): string {
|
||||
if (!sockopt || typeof sockopt !== 'object') return serializeOverride(sockopt);
|
||||
const copy = { ...(sockopt as Record<string, unknown>) };
|
||||
for (const key of INBOUND_ONLY_SOCKOPT) delete copy[key];
|
||||
return serializeOverride(copy);
|
||||
}
|
||||
|
||||
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
|
||||
// Populate the dialerProxy dropdown with the panel's outbound tags (a host can
|
||||
// chain through one of the subscription's outbounds by tag). dialerProxy chains
|
||||
// through a single outbound, so balancers (routing targets) are excluded — only
|
||||
// the outbound group is used; blackhole is dropped too (chaining to it just
|
||||
// drops the traffic).
|
||||
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
const outboundTags = tagGroups?.outbounds ?? [];
|
||||
return (
|
||||
<OutboundSubtreeJsonForm
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
path={['streamSettings', 'sockopt']}
|
||||
serialize={serializeClientSockopt}
|
||||
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Form, type FormInstance } from 'antd';
|
||||
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
|
||||
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
|
||||
|
||||
interface OutboundSubtreeJsonFormProps {
|
||||
value?: string;
|
||||
onChange?: (next: string) => void;
|
||||
// Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
|
||||
path: (string | number)[];
|
||||
// Renders the reused outbound form given this wrapper's own form instance.
|
||||
render: (form: FormInstance<OutboundFormValues>) => ReactNode;
|
||||
// Seeds the form when the stored value is empty, so toggling a section on
|
||||
// pre-fills sensible defaults instead of blanks (used by Mux).
|
||||
defaultSubtree?: Record<string, unknown>;
|
||||
// Turns the edited subtree into the stored JSON string (default: prune empties).
|
||||
// Mux overrides this to store '' (= inherit) when its enable flag is off.
|
||||
serialize?: (subtree: unknown) => string;
|
||||
}
|
||||
|
||||
// Hosts the reused outbound transport forms (which bind to fixed form paths)
|
||||
// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
|
||||
// from the JSON string, watch the edited subtree, and report a JSON string back
|
||||
// to the parent host form. component={false} avoids a nested <form> DOM node.
|
||||
export default function OutboundSubtreeJsonForm({
|
||||
value = '',
|
||||
onChange,
|
||||
path,
|
||||
render,
|
||||
defaultSubtree,
|
||||
serialize = serializeOverride,
|
||||
}: OutboundSubtreeJsonFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState<Record<string, unknown>>(() => {
|
||||
const parsed = parseJsonObject(value);
|
||||
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
|
||||
});
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const subtree = Form.useWatch(path, form);
|
||||
|
||||
useEffect(() => {
|
||||
const next = serialize(subtree);
|
||||
if (next !== value) onChangeRef.current?.(next);
|
||||
// serialize is logically stable; re-run only when the edited subtree changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subtree, value]);
|
||||
|
||||
const hasInitial = Object.keys(initial).length > 0;
|
||||
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
component={false}
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
labelWrap
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{render(form as unknown as FormInstance<OutboundFormValues>)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared helpers for the host's structured JSON-override editors. Each host
|
||||
// override is persisted as a JSON string (muxParams / sockoptParams /
|
||||
// finalMask); these convert between that string and the object the reused
|
||||
// outbound/sub-JSON forms edit.
|
||||
|
||||
export function parseJsonObject(raw: string): Record<string, unknown> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively drop '', null, undefined, and empty arrays/objects so an override
|
||||
// stays sparse — only the keys the operator actually set are emitted and merged
|
||||
// into the inbound stream. 0 and false are kept (meaningful sockopt/mux values).
|
||||
export function pruneEmptyDeep(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const arr = value.map(pruneEmptyDeep).filter((v) => v !== undefined);
|
||||
return arr.length ? arr : undefined;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const pv = pruneEmptyDeep(v);
|
||||
if (pv !== undefined) out[k] = pv;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
if (value === '' || value === null) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Prune then stringify; an all-empty override serializes to '' (= no override).
|
||||
export function serializeOverride(value: unknown): string {
|
||||
const pruned = pruneEmptyDeep(value);
|
||||
return pruned === undefined ? '' : JSON.stringify(pruned);
|
||||
}
|
||||
|
||||
// Build a nested object { a: { b: leaf } } from a form path ['a','b'] so the
|
||||
// inner form can be seeded with initialValues at the exact path it edits.
|
||||
export function nestAtPath(path: (string | number)[], leaf: unknown): Record<string, unknown> {
|
||||
let acc: unknown = leaf;
|
||||
for (let i = path.length - 1; i >= 0; i -= 1) {
|
||||
acc = { [path[i]]: acc };
|
||||
}
|
||||
return acc as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as HostMuxForm } from './HostMuxForm';
|
||||
export { default as HostSockoptForm } from './HostSockoptForm';
|
||||
export { default as HostFinalMaskForm } from './HostFinalMaskForm';
|
||||
@@ -90,7 +90,6 @@ export default function InboundsPage() {
|
||||
subSettings,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
refresh,
|
||||
hydrateInbound,
|
||||
applyTrafficEvent,
|
||||
@@ -265,13 +264,12 @@ export default function InboundsPage() {
|
||||
content: genInboundLinks({
|
||||
inbound: inboundFromDb(projected),
|
||||
remark: projected.remark,
|
||||
remarkModel,
|
||||
hostOverride: hostOverrideFor(dbInbound),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
}),
|
||||
fileName: projected.remark || 'inbound',
|
||||
});
|
||||
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
|
||||
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
|
||||
@@ -303,13 +301,12 @@ export default function InboundsPage() {
|
||||
out.push(genInboundLinks({
|
||||
inbound: inboundFromDb(projected),
|
||||
remark: projected.remark,
|
||||
remarkModel,
|
||||
hostOverride: hostOverrideFor(ib),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
}));
|
||||
}
|
||||
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
|
||||
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
}, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
|
||||
const exportAllSubs = useCallback(async () => {
|
||||
const hydrated = await Promise.all(
|
||||
@@ -658,7 +655,6 @@ export default function InboundsPage() {
|
||||
onClose={() => setInfoOpen(false)}
|
||||
dbInbound={infoDbInbound}
|
||||
clientIndex={infoClientIndex}
|
||||
remarkModel={remarkModel}
|
||||
expireDiff={expireDiff}
|
||||
trafficDiff={trafficDiff}
|
||||
ipLimitEnable={ipLimitEnable}
|
||||
@@ -674,7 +670,6 @@ export default function InboundsPage() {
|
||||
onClose={() => setQrOpen(false)}
|
||||
dbInbound={qrDbInbound}
|
||||
client={null}
|
||||
remarkModel={remarkModel}
|
||||
nodeAddress={qrNodeAddress}
|
||||
subSettings={subSettings}
|
||||
/>
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
WireguardFields,
|
||||
} from './protocols';
|
||||
import {
|
||||
ExternalProxyForm,
|
||||
GrpcForm,
|
||||
HttpUpgradeForm,
|
||||
KcpForm,
|
||||
@@ -251,23 +250,6 @@ export default function InboundFormModal({
|
||||
onSecurityChange,
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
|
||||
|
||||
const toggleExternalProxy = (on: boolean) => {
|
||||
if (on) {
|
||||
const port = (form.getFieldValue('port') as number) ?? 443;
|
||||
form.setFieldValue(['streamSettings', 'externalProxy'], [{
|
||||
forceTls: 'same',
|
||||
dest: typeof window !== 'undefined' ? window.location.hostname : '',
|
||||
port,
|
||||
remark: '',
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
pinnedPeerCertSha256: [],
|
||||
}]);
|
||||
} else {
|
||||
form.setFieldValue(['streamSettings', 'externalProxy'], []);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSockopt = (on: boolean) => {
|
||||
if (on) {
|
||||
@@ -703,7 +685,7 @@ export default function InboundFormModal({
|
||||
className="mt-12"
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('pages.inbounds.fallbacks.needsTls')}
|
||||
title={t('pages.inbounds.fallbacks.needsTls')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -811,12 +793,9 @@ export default function InboundFormModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* externalProxy only feeds client share links. Wireguard's per-peer
|
||||
.conf fanout resolves its host elsewhere, and tunnel (dokodemo-door)
|
||||
has no clients at all — the section is dead weight on both. */}
|
||||
{protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL && (
|
||||
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
|
||||
)}
|
||||
{/* The legacy externalProxy section is replaced by the Hosts page; the
|
||||
field is still parsed/rendered for backward compatibility but is no
|
||||
longer editable here. */}
|
||||
|
||||
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
|
||||
import './external-proxy.css';
|
||||
|
||||
const newEntry = () => ({
|
||||
forceTls: 'same',
|
||||
dest: '',
|
||||
port: 443,
|
||||
remark: '',
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
pinnedPeerCertSha256: [],
|
||||
echConfigList: '',
|
||||
});
|
||||
|
||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="ext-proxy-field">
|
||||
<span className="ext-proxy-flabel">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalProxyForm({
|
||||
toggleExternalProxy,
|
||||
}: {
|
||||
toggleExternalProxy: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const generateRandomPin = (name: number) => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
|
||||
const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
|
||||
form.setFieldValue(path, [...current, hash]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => {
|
||||
const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
|
||||
const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
|
||||
return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const arr = getFieldValue(['streamSettings', 'externalProxy']);
|
||||
const on = Array.isArray(arr) && arr.length > 0;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.externalProxy')}>
|
||||
<Switch checked={on} onChange={toggleExternalProxy} />
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
<Form.List name={['streamSettings', 'externalProxy']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div className="ext-proxy-list">
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key} className="ext-proxy-card">
|
||||
<div className="ext-proxy-card__head">
|
||||
<span className="ext-proxy-card__title">#{idx + 1}</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ext-proxy-grid ext-proxy-grid--dest">
|
||||
<Field label={t('pages.inbounds.form.forceTls')}>
|
||||
<Form.Item name={[field.name, 'forceTls']} noStyle>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'same', label: t('pages.inbounds.same') },
|
||||
{ value: 'none', label: t('none') },
|
||||
{ value: 'tls', label: 'TLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.address')}>
|
||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.address')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.port')}>
|
||||
<Form.Item name={[field.name, 'port']} noStyle>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pages.inbounds.remark')}>
|
||||
<Form.Item name={[field.name, 'remark']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.remark')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const ft = getFieldValue([
|
||||
'streamSettings', 'externalProxy', field.name, 'forceTls',
|
||||
]);
|
||||
if (ft !== 'tls') return null;
|
||||
return (
|
||||
<div className="ext-proxy-tls">
|
||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||
<Field label="SNI">
|
||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.inbounds.form.fingerprint')}
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({
|
||||
value: fp,
|
||||
label: fp,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label="ALPN">
|
||||
<Form.Item name={[field.name, 'alpn']} noStyle>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="ALPN"
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({
|
||||
value: a,
|
||||
label: a,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pages.inbounds.form.echConfig')}>
|
||||
<Form.Item name={[field.name, 'echConfigList']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.form.echConfig')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => generateRandomPin(field.name)}
|
||||
title={t('pages.inbounds.form.generateRandomPin')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="ext-proxy-add"
|
||||
block
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add(newEntry())}
|
||||
>
|
||||
{t('add')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -4,5 +4,4 @@ export { default as GrpcForm } from './grpc';
|
||||
export { default as XhttpForm } from './xhttp';
|
||||
export { default as HttpUpgradeForm } from './httpupgrade';
|
||||
export { default as KcpForm } from './kcp';
|
||||
export { default as ExternalProxyForm } from './external-proxy';
|
||||
export { default as SockoptForm } from './sockopt';
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
|
||||
import {
|
||||
Address_Port_Strategy,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TCP_CONGESTION_OPTION,
|
||||
} from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import { CustomSockoptList } from '@/components/form';
|
||||
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
|
||||
// Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
|
||||
@@ -157,7 +153,7 @@ export default function SockoptForm({
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
{proxyMismatch && (
|
||||
@@ -165,7 +161,7 @@ export default function SockoptForm({
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -218,13 +214,6 @@ export default function SockoptForm({
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpMptcp']}
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||
label={t('pages.inbounds.form.penetrate')}
|
||||
@@ -239,15 +228,6 @@ export default function SockoptForm({
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
@@ -267,15 +247,6 @@ export default function SockoptForm({
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'interface']}
|
||||
label={t('pages.inbounds.info.interfaceName')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
@@ -293,115 +264,7 @@ export default function SockoptForm({
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
|
||||
label={t('pages.inbounds.form.addressPortStrategy')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
|
||||
const hasHe = he != null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Happy Eyeballs">
|
||||
<Switch
|
||||
checked={hasHe}
|
||||
onChange={(v) => {
|
||||
setFieldValue(
|
||||
['streamSettings', 'sockopt', 'happyEyeballs'],
|
||||
v ? HappyEyeballsSchema.parse({}) : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasHe && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
||||
label={t('pages.inbounds.form.tryDelayMs')}
|
||||
>
|
||||
<InputNumber min={0} placeholder="0 disabled — 250 recommended" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
|
||||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
||||
label={t('pages.inbounds.form.interleave')}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
||||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
||||
>
|
||||
+ {t('pages.inbounds.form.addCustomOption')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field) => (
|
||||
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item name={[field.name, 'system']} noStyle>
|
||||
<Select
|
||||
placeholder="all"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'windows', label: 'windows' },
|
||||
{ value: 'darwin', label: 'darwin' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'type']} noStyle>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ value: 'int', label: 'int' },
|
||||
{ value: 'str', label: 'str' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'level']} noStyle>
|
||||
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'opt']} noStyle>
|
||||
<Input placeholder="opt" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'value']} noStyle>
|
||||
<Input placeholder="value" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>−</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<CustomSockoptList />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function InboundInfoModal({
|
||||
onClose,
|
||||
dbInbound,
|
||||
clientIndex = 0,
|
||||
remarkModel = '-io',
|
||||
expireDiff = 0,
|
||||
trafficDiff = 0,
|
||||
ipLimitEnable = false,
|
||||
@@ -120,7 +119,6 @@ export default function InboundInfoModal({
|
||||
genWireguardConfigs({
|
||||
inbound: inboundForLinks,
|
||||
remark: dbInbound.remark,
|
||||
remarkModel: '-io',
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
}).split('\r\n'),
|
||||
@@ -129,7 +127,6 @@ export default function InboundInfoModal({
|
||||
genWireguardLinks({
|
||||
inbound: inboundForLinks,
|
||||
remark: dbInbound.remark,
|
||||
remarkModel: '-io',
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
}).split('\r\n'),
|
||||
@@ -140,7 +137,6 @@ export default function InboundInfoModal({
|
||||
genAllLinks({
|
||||
inbound: inboundForLinks,
|
||||
remark: dbInbound.remark,
|
||||
remarkModel,
|
||||
client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
@@ -189,7 +185,7 @@ export default function InboundInfoModal({
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
|
||||
}, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
|
||||
|
||||
const isEnable = useMemo(() => {
|
||||
if (clientSettings) return !!clientSettings.enable;
|
||||
|
||||
@@ -76,7 +76,6 @@ export interface InboundInfoModalProps {
|
||||
onClose: () => void;
|
||||
dbInbound: DBInboundLike | null;
|
||||
clientIndex?: number;
|
||||
remarkModel?: string;
|
||||
expireDiff?: number;
|
||||
trafficDiff?: number;
|
||||
ipLimitEnable?: boolean;
|
||||
|
||||
@@ -26,7 +26,6 @@ interface QrCodeModalProps {
|
||||
onClose: () => void;
|
||||
dbInbound: (DbInboundLike & { remark?: string }) | null;
|
||||
client?: ClientSetting | null;
|
||||
remarkModel?: string;
|
||||
nodeAddress?: string;
|
||||
subSettings?: SubSettings;
|
||||
}
|
||||
@@ -43,7 +42,6 @@ export default function QrCodeModal({
|
||||
onClose,
|
||||
dbInbound,
|
||||
client = null,
|
||||
remarkModel = '-io',
|
||||
nodeAddress = '',
|
||||
subSettings,
|
||||
}: QrCodeModalProps) {
|
||||
@@ -67,7 +65,6 @@ export default function QrCodeModal({
|
||||
genWireguardConfigs({
|
||||
inbound,
|
||||
remark: peerRemark,
|
||||
remarkModel: '-io',
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
}).split('\r\n'),
|
||||
@@ -76,7 +73,6 @@ export default function QrCodeModal({
|
||||
genWireguardLinks({
|
||||
inbound,
|
||||
remark: peerRemark,
|
||||
remarkModel: '-io',
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
}).split('\r\n'),
|
||||
@@ -87,7 +83,6 @@ export default function QrCodeModal({
|
||||
genAllLinks({
|
||||
inbound,
|
||||
remark: dbInbound.remark || '',
|
||||
remarkModel,
|
||||
client: client ?? {},
|
||||
hostOverride: nodeAddress,
|
||||
fallbackHostname,
|
||||
@@ -106,7 +101,7 @@ export default function QrCodeModal({
|
||||
}
|
||||
setSubLink(nextSub);
|
||||
setSubJsonLink(nextSubJson);
|
||||
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
|
||||
}, [open, dbInbound, client, nodeAddress, subSettings]);
|
||||
|
||||
const qrItems = useMemo<QrItem[]>(() => {
|
||||
const items: QrItem[] = [];
|
||||
|
||||
@@ -161,7 +161,6 @@ export function useInbounds() {
|
||||
const tgBotEnable = !!defaults.tgBotEnable;
|
||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||
const pageSize = defaults.pageSize ?? 0;
|
||||
const remarkModel = defaults.remarkModel || '-io';
|
||||
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
||||
|
||||
const subSettings: SubSettings = useMemo(() => ({
|
||||
@@ -528,7 +527,6 @@ export function useInbounds() {
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
subSettings,
|
||||
remarkModel,
|
||||
datepicker,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
{testResult && (
|
||||
<Alert
|
||||
type={testResult.success ? 'success' : 'error'}
|
||||
message={
|
||||
title={
|
||||
testResult.success
|
||||
? t('pages.settings.' + testResult.msg)
|
||||
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
|
||||
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'External Proxy' };
|
||||
const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
|
||||
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
|
||||
|
||||
interface SubscriptionGeneralTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
@@ -21,30 +17,6 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const remarkModel = useMemo(() => {
|
||||
const rm = allSetting.remarkModel || '';
|
||||
return rm.length > 1 ? rm.substring(1).split('') : [];
|
||||
}, [allSetting.remarkModel]);
|
||||
|
||||
const remarkSeparator = useMemo(() => {
|
||||
const rm = allSetting.remarkModel || '-';
|
||||
return rm.length > 1 ? rm.charAt(0) : '-';
|
||||
}, [allSetting.remarkModel]);
|
||||
|
||||
const remarkSample = useMemo(() => {
|
||||
const parts = remarkModel.map((k) => REMARK_SAMPLES[k]);
|
||||
return parts.length === 0 ? '' : parts.join(remarkSeparator);
|
||||
}, [remarkModel, remarkSeparator]);
|
||||
|
||||
function setRemarkModel(parts: string[]) {
|
||||
updateSetting({ remarkModel: remarkSeparator + parts.join('') });
|
||||
}
|
||||
|
||||
function setRemarkSeparator(sep: string) {
|
||||
const tail = (allSetting.remarkModel || '-').substring(1);
|
||||
updateSetting({ remarkModel: sep + tail });
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultActiveKey="1" items={[
|
||||
{
|
||||
@@ -94,49 +66,16 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
|
||||
<Switch checked={allSetting.subEncrypt} onChange={(v) => updateSetting({ subEncrypt: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subShowInfo')} description={t('pages.settings.subShowInfoDesc')}>
|
||||
<Switch checked={allSetting.subShowInfo} onChange={(v) => updateSetting({ subShowInfo: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subEmailInRemark')} description={t('pages.settings.subEmailInRemarkDesc')}>
|
||||
<Switch checked={allSetting.subEmailInRemark} onChange={(v) => updateSetting({ subEmailInRemark: v })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.remarkModel')}
|
||||
description={
|
||||
<>
|
||||
{t('pages.settings.sampleRemark')}:{' '}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--ant-color-border)',
|
||||
background: 'var(--ant-color-fill-tertiary)',
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{remarkSample ? `#${remarkSample}` : '—'}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
title={t('pages.settings.remarkTemplate')}
|
||||
description={t('pages.settings.remarkTemplateDesc')}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={remarkModel}
|
||||
onChange={setRemarkModel}
|
||||
style={{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }}
|
||||
options={Object.entries(REMARK_MODELS).map(([k, l]) => ({ value: k, label: l }))}
|
||||
/>
|
||||
<Select
|
||||
value={remarkSeparator}
|
||||
onChange={setRemarkSeparator}
|
||||
style={{ width: '20%' }}
|
||||
options={REMARK_SEPARATORS.map((s) => ({ value: s, label: s === ' ' ? '␣' : s }))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.remarkTemplate}
|
||||
onChange={(v) => updateSetting({ remarkTemplate: v })}
|
||||
maxLength={256}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
{testResult && (
|
||||
<Alert
|
||||
type={testResult.success ? 'success' : 'error'}
|
||||
message={testResult.msg}
|
||||
title={testResult.msg}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setTestResult(null)}
|
||||
|
||||
@@ -58,6 +58,7 @@ const subClashUrl = subData.subClashUrl || '';
|
||||
const subTitle = subData.subTitle || '';
|
||||
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
||||
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
||||
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||
const datepicker = subData.datepicker || 'gregorian';
|
||||
|
||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
||||
@@ -149,6 +150,7 @@ export default function SubPage() {
|
||||
const descriptionsItems = useMemo(() => {
|
||||
const items = [
|
||||
{ key: 'subId', label: t('subscription.subId'), children: sId },
|
||||
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
|
||||
{
|
||||
key: 'status',
|
||||
label: t('subscription.status'),
|
||||
@@ -413,7 +415,7 @@ export default function SubPage() {
|
||||
</div>
|
||||
</div>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link, linkEmails[idx] || '');
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `Link ${idx + 1}`;
|
||||
const rowTitle = parts?.remark || fallback;
|
||||
const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
@@ -50,7 +51,12 @@ export default function OutboundCardList({
|
||||
}: OutboundCardListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (rows.length === 0) {
|
||||
return <div className="card-empty">—</div>;
|
||||
return (
|
||||
<div className="card-empty">
|
||||
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Keep this in sync with the other pages' .card-empty (it's a global class):
|
||||
the previous opacity:0.4 here leaked onto whichever page's empty state was
|
||||
shown after the Outbounds CSS loaded, fading it. */
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 16px 0;
|
||||
color: var(--ant-color-text-secondary);
|
||||
padding: 24px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.outbound-card {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ArrowDownOutlined,
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -469,6 +470,14 @@ export default function OutboundsTab({
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
|
||||
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
|
||||
import { CustomSockoptList } from '@/components/form';
|
||||
import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
@@ -136,54 +137,30 @@ export default function SockoptForm({
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.ipv6Only')}
|
||||
name={['streamSettings', 'sockopt', 'V6Only']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.acceptProxyProtocol')}
|
||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
|
||||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpMaxSeg')}
|
||||
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="trusted-proxy.example,10.0.0.0/8"
|
||||
/>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
@@ -210,7 +187,7 @@ export default function SockoptForm({
|
||||
label={t('pages.inbounds.form.tryDelayMs')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
|
||||
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
||||
@@ -223,13 +200,13 @@ export default function SockoptForm({
|
||||
label={t('pages.inbounds.form.interleave')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
@@ -237,56 +214,7 @@ export default function SockoptForm({
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
||||
>
|
||||
+ {t('pages.inbounds.form.addCustomOption')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field) => (
|
||||
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item name={[field.name, 'system']} noStyle>
|
||||
<Select
|
||||
placeholder="all"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'windows', label: 'windows' },
|
||||
{ value: 'darwin', label: 'darwin' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'type']} noStyle>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ value: 'int', label: 'int' },
|
||||
{ value: 'str', label: 'str' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'level']} noStyle>
|
||||
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'opt']} noStyle>
|
||||
<Input placeholder="opt (decimal)" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'value']} noStyle>
|
||||
<Input placeholder="value" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>−</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<CustomSockoptList />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
|
||||
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
|
||||
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
|
||||
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
|
||||
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
@@ -26,6 +27,7 @@ const routes: RouteObject[] = [
|
||||
{ path: 'clients', element: withSuspense(<ClientsPage />) },
|
||||
{ path: 'groups', element: withSuspense(<GroupsPage />) },
|
||||
{ path: 'nodes', element: withSuspense(<NodesPage />) },
|
||||
{ path: 'hosts', element: withSuspense(<HostsPage />) },
|
||||
{ path: 'settings', element: withSuspense(<SettingsPage />) },
|
||||
{ path: 'xray', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
|
||||
|
||||
// A Host is a per-inbound override endpoint: at subscription time each enabled
|
||||
// host renders one extra share link/proxy with its own address/port/TLS, etc.,
|
||||
// superseding the legacy externalProxy array. The form schema mirrors the field
|
||||
// logic of schemas/protocols/stream/external-proxy.ts and reuses the shared
|
||||
// ALPN / uTLS primitives.
|
||||
|
||||
export const HostSecuritySchema = z.enum(['same', 'tls', 'none', 'reality']);
|
||||
export type HostSecurity = z.infer<typeof HostSecuritySchema>;
|
||||
|
||||
export const MihomoIpVersionSchema = z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']);
|
||||
export const SubTypeSchema = z.enum(['raw', 'json', 'clash']);
|
||||
|
||||
// Tags are short uppercase identifiers (≤10 tags, each ≤36 chars). Enforced on
|
||||
// the frontend; the backend stores them verbatim.
|
||||
const HostTagSchema = z.string().regex(/^[A-Z0-9_:]+$/, 'pages.hosts.toasts.badTag').max(36);
|
||||
|
||||
// HostFormValues is what the form edits and POSTs.
|
||||
export const HostFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
inboundId: z.number().int().positive(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
// Remark may contain {{VAR}} template tokens expanded per client at
|
||||
// subscription time, so the stored template gets a generous cap.
|
||||
remark: z.string().trim().min(1).max(256),
|
||||
serverDescription: z.string().max(64).default(''),
|
||||
isDisabled: z.boolean().default(false),
|
||||
isHidden: z.boolean().default(false),
|
||||
tags: z.array(HostTagSchema).max(10).default([]),
|
||||
|
||||
address: z.string().default(''),
|
||||
port: z.number().int().min(0).max(65535).default(0),
|
||||
|
||||
security: HostSecuritySchema.default('same'),
|
||||
sni: z.string().default(''),
|
||||
hostHeader: z.string().default(''),
|
||||
path: z.string().default(''),
|
||||
alpn: z.array(AlpnSchema).default([]),
|
||||
fingerprint: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
UtlsFingerprintSchema.optional(),
|
||||
),
|
||||
overrideSniFromAddress: z.boolean().default(false),
|
||||
keepSniBlank: z.boolean().default(false),
|
||||
pinnedPeerCertSha256: z.array(z.string()).default([]),
|
||||
verifyPeerCertByName: z.boolean().default(false),
|
||||
allowInsecure: z.boolean().default(false),
|
||||
echConfigList: z.string().default(''),
|
||||
|
||||
muxParams: z.string().default(''),
|
||||
sockoptParams: z.string().default(''),
|
||||
finalMask: z.string().default(''),
|
||||
// A comma-separated list of ports/ranges (e.g. "53,443,1000-2000"). Empty = none.
|
||||
vlessRoute: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{1,5}(-\d{1,5})?)(\s*,\s*\d{1,5}(-\d{1,5})?)*$/, 'pages.hosts.toasts.badVlessRoute')
|
||||
.or(z.literal(''))
|
||||
.default(''),
|
||||
|
||||
excludeFromSubTypes: z.array(SubTypeSchema).default([]),
|
||||
|
||||
// Visual-only assignment of nodes that resolve from this host (stored, not yet
|
||||
// wired into routing).
|
||||
nodeGuids: z.array(z.string()).default([]),
|
||||
|
||||
mihomoIpVersion: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
MihomoIpVersionSchema.optional(),
|
||||
),
|
||||
mihomoX25519: z.boolean().default(false),
|
||||
shuffleHost: z.boolean().default(false),
|
||||
});
|
||||
export type HostFormValues = z.infer<typeof HostFormSchema>;
|
||||
|
||||
// HostRecord is the loose list/read projection from /panel/api/hosts. Slice and
|
||||
// free-JSON fields tolerate the backend serializing nil as null.
|
||||
export const HostRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
inboundId: z.number(),
|
||||
sortOrder: z.number().optional(),
|
||||
remark: z.string().optional(),
|
||||
serverDescription: z.string().optional(),
|
||||
isDisabled: z.boolean().optional(),
|
||||
isHidden: z.boolean().optional(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
address: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
security: z.string().optional(),
|
||||
sni: z.string().optional(),
|
||||
hostHeader: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
alpn: z.array(z.string()).nullish(),
|
||||
fingerprint: z.string().optional(),
|
||||
overrideSniFromAddress: z.boolean().optional(),
|
||||
keepSniBlank: z.boolean().optional(),
|
||||
pinnedPeerCertSha256: z.array(z.string()).nullish(),
|
||||
verifyPeerCertByName: z.boolean().optional(),
|
||||
allowInsecure: z.boolean().optional(),
|
||||
echConfigList: z.string().optional(),
|
||||
muxParams: z.unknown().optional(),
|
||||
sockoptParams: z.unknown().optional(),
|
||||
finalMask: z.string().optional(),
|
||||
vlessRoute: z.string().optional(),
|
||||
excludeFromSubTypes: z.array(z.string()).nullish(),
|
||||
nodeGuids: z.array(z.string()).nullish(),
|
||||
mihomoIpVersion: z.string().optional(),
|
||||
mihomoX25519: z.boolean().optional(),
|
||||
shuffleHost: z.boolean().optional(),
|
||||
}).loose();
|
||||
export type HostRecord = z.infer<typeof HostRecordSchema>;
|
||||
|
||||
export const HostListSchema = z.array(HostRecordSchema);
|
||||
@@ -12,7 +12,6 @@ export const DefaultsPayloadSchema = z.object({
|
||||
subClashURI: z.string().optional(),
|
||||
subClashEnable: z.boolean().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
remarkModel: z.string().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
ipLimitEnable: z.boolean().optional(),
|
||||
accessLogEnable: z.boolean().optional(),
|
||||
|
||||
@@ -17,7 +17,7 @@ export const AllSettingSchema = z.object({
|
||||
pageSize: z.number().int().min(0).max(1000).optional(),
|
||||
expireDiff: nonNegativeInt.optional(),
|
||||
trafficDiff: nonNegativeInt.max(100).optional(),
|
||||
remarkModel: z.string().optional(),
|
||||
remarkTemplate: z.string().optional(),
|
||||
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
|
||||
tgBotEnable: z.boolean().optional(),
|
||||
tgBotToken: z.string().optional(),
|
||||
@@ -53,8 +53,6 @@ export const AllSettingSchema = z.object({
|
||||
subKeyFile: z.string().optional(),
|
||||
subUpdates: z.number().int().min(1).max(168).optional(),
|
||||
subEncrypt: z.boolean().optional(),
|
||||
subShowInfo: z.boolean().optional(),
|
||||
subEmailInRemark: z.boolean().optional(),
|
||||
subURI: z.string().optional(),
|
||||
subJsonURI: z.string().optional(),
|
||||
subClashURI: z.string().optional(),
|
||||
|
||||
@@ -85,3 +85,44 @@
|
||||
.api-docs-page .ant-card .ant-card-actions {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Hosts page shares the same card styling + hover shadows (without the default
|
||||
antd hoverable pointer/blur), matching Clients/Inbounds/etc. */
|
||||
.hosts-page .ant-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hosts-page.is-dark .ant-card {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.hosts-page.is-dark.is-ultra .ant-card {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.hosts-page .ant-card.ant-card-hoverable:hover {
|
||||
cursor: default;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.hosts-page.is-dark .ant-card.ant-card-hoverable:hover {
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.hosts-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover {
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.75),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.hosts-page .ant-card .ant-card-actions {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -176,3 +176,43 @@ body.dark .ant-dropdown-menu-item-divider {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hosts page shares the standard panel page shell (background, transparent
|
||||
layout, content padding, summary-card padding). */
|
||||
.hosts-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.hosts-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
|
||||
.hosts-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
.hosts-page .ant-layout,
|
||||
.hosts-page .ant-layout-content,
|
||||
.hosts-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hosts-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hosts-page .summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hosts-page .content-area,
|
||||
.hosts-page .summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +36,6 @@ exports[`inbound security forms > TlsForm field structure is stable 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`inbound transport forms > ExternalProxyForm field structure is stable (one TLS entry) 1`] = `
|
||||
[
|
||||
"External Proxy",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`inbound transport forms > GrpcForm field structure is stable 1`] = `
|
||||
[
|
||||
"Service Name",
|
||||
@@ -77,7 +71,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
|
||||
exports[`inbound transport forms > SockoptForm field structure is stable (server-side fields only) 1`] = `
|
||||
[
|
||||
"Sockopt",
|
||||
"Real client IP",
|
||||
@@ -89,21 +83,11 @@ exports[`inbound transport forms > SockoptForm field structure is stable (enable
|
||||
"TCP Window Clamp",
|
||||
"Proxy Protocol",
|
||||
"TCP Fast Open",
|
||||
"Multipath TCP",
|
||||
"Penetrate",
|
||||
"V6 Only",
|
||||
"Domain Strategy",
|
||||
"TCP Congestion",
|
||||
"TProxy",
|
||||
"Dialer Proxy",
|
||||
"Interface name",
|
||||
"Trusted X-Forwarded-For",
|
||||
"Address+port strategy",
|
||||
"Happy Eyeballs",
|
||||
"Try delay (ms)",
|
||||
"Prioritize IPv6",
|
||||
"Interleave",
|
||||
"Max concurrent try",
|
||||
"Custom sockopt",
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hostToExternalProxyEntry } from '@/lib/hosts/host-link';
|
||||
|
||||
describe('hostToExternalProxyEntry', () => {
|
||||
const base = {
|
||||
security: 'tls' as const,
|
||||
address: 'cdn.example.com',
|
||||
port: 8443,
|
||||
remark: 'R',
|
||||
sni: 'sni.example.com',
|
||||
alpn: ['h2'] as ('h2' | 'h3' | 'http/1.1')[],
|
||||
fingerprint: 'chrome' as const,
|
||||
pinnedPeerCertSha256: ['AAAA'],
|
||||
echConfigList: 'ECH',
|
||||
overrideSniFromAddress: false,
|
||||
keepSniBlank: false,
|
||||
};
|
||||
|
||||
it('maps the overlapping fields onto an external-proxy entry', () => {
|
||||
const ep = hostToExternalProxyEntry(base);
|
||||
expect(ep.forceTls).toBe('tls');
|
||||
expect(ep.dest).toBe('cdn.example.com');
|
||||
expect(ep.port).toBe(8443);
|
||||
expect(ep.remark).toBe('R');
|
||||
expect(ep.sni).toBe('sni.example.com');
|
||||
expect(ep.alpn).toEqual(['h2']);
|
||||
expect(ep.fingerprint).toBe('chrome');
|
||||
expect(ep.pinnedPeerCertSha256).toEqual(['AAAA']);
|
||||
expect(ep.echConfigList).toBe('ECH');
|
||||
});
|
||||
|
||||
it('maps reality/same security to forceTls "same"', () => {
|
||||
expect(hostToExternalProxyEntry({ ...base, security: 'reality' }).forceTls).toBe('same');
|
||||
expect(hostToExternalProxyEntry({ ...base, security: 'same' }).forceTls).toBe('same');
|
||||
expect(hostToExternalProxyEntry({ ...base, security: 'none' }).forceTls).toBe('none');
|
||||
});
|
||||
|
||||
it('uses the address as sni when overrideSniFromAddress is set', () => {
|
||||
const ep = hostToExternalProxyEntry({ ...base, overrideSniFromAddress: true });
|
||||
expect(ep.sni).toBe('cdn.example.com');
|
||||
});
|
||||
|
||||
it('omits sni when keepSniBlank is set', () => {
|
||||
const ep = hostToExternalProxyEntry({ ...base, keepSniBlank: true });
|
||||
expect(ep.sni).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to port 443 when the host port is 0 (inherit)', () => {
|
||||
const ep = hostToExternalProxyEntry({ ...base, port: 0 });
|
||||
expect(ep.port).toBe(443);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { HostFormSchema } from '@/schemas/api/host';
|
||||
|
||||
describe('HostFormSchema', () => {
|
||||
const valid = {
|
||||
inboundId: 1,
|
||||
remark: 'cdn-front',
|
||||
address: 'cdn.example.com',
|
||||
port: 8443,
|
||||
security: 'tls',
|
||||
tags: ['CDN', 'EU'],
|
||||
mihomoIpVersion: 'dual',
|
||||
excludeFromSubTypes: ['clash'],
|
||||
};
|
||||
|
||||
it('parses a valid host', () => {
|
||||
const parsed = HostFormSchema.parse(valid);
|
||||
expect(parsed.remark).toBe('cdn-front');
|
||||
expect(parsed.security).toBe('tls');
|
||||
expect(parsed.tags).toEqual(['CDN', 'EU']);
|
||||
expect(parsed.excludeFromSubTypes).toEqual(['clash']);
|
||||
});
|
||||
|
||||
it('rejects an empty remark', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, remark: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('accepts a templated remark up to 256 chars and rejects beyond', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, remark: 'x'.repeat(256) })).not.toThrow();
|
||||
expect(() => HostFormSchema.parse({ ...valid, remark: 'x'.repeat(257) })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range port', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, port: 70000 })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a bad security enum', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, security: 'bogus' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a tag with invalid characters', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, tags: ['lower-case'] })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects more than 10 tags', () => {
|
||||
expect(() =>
|
||||
HostFormSchema.parse({ ...valid, tags: Array.from({ length: 11 }, (_, i) => `T${i}`) }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a bad mihomoIpVersion enum', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, mihomoIpVersion: 'nope' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a bad excludeFromSubTypes value', () => {
|
||||
expect(() => HostFormSchema.parse({ ...valid, excludeFromSubTypes: ['xml'] })).toThrow();
|
||||
});
|
||||
|
||||
it('defaults security to "same" and port to 0', () => {
|
||||
const parsed = HostFormSchema.parse({ inboundId: 1, remark: 'r' });
|
||||
expect(parsed.security).toBe('same');
|
||||
expect(parsed.port).toBe(0);
|
||||
expect(parsed.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { Form, type FormInstance } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
ExternalProxyForm,
|
||||
GrpcForm,
|
||||
HttpUpgradeForm,
|
||||
KcpForm,
|
||||
@@ -67,30 +66,13 @@ describe('inbound transport forms', () => {
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('ExternalProxyForm field structure is stable (one TLS entry)', () => {
|
||||
renderInForm(
|
||||
() => <ExternalProxyForm toggleExternalProxy={noop} />,
|
||||
{
|
||||
streamSettings: {
|
||||
externalProxy: [{
|
||||
forceTls: 'tls',
|
||||
dest: '',
|
||||
port: 443,
|
||||
remark: '',
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
|
||||
it('SockoptForm field structure is stable (server-side fields only)', () => {
|
||||
// The inbound sockopt form shows only server/listening-side fields;
|
||||
// outbound-only fields (dialerProxy, domainStrategy, interface,
|
||||
// addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form.
|
||||
renderInForm(
|
||||
() => <SockoptForm toggleSockopt={noop} network="tcp" />,
|
||||
{ streamSettings: { sockopt: { happyEyeballs: {} } } },
|
||||
{ streamSettings: { sockopt: { mark: 0 } } },
|
||||
);
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { parseLinkParts, linkMetaText } from '@/lib/xray/link-label';
|
||||
|
||||
// The panel shows the subscription's remark verbatim. Per-client traffic/expiry
|
||||
// info is rendered only into the body a client app imports (backend, first link
|
||||
// only), so the panel's display links are already clean — nothing is stripped.
|
||||
describe('link-label parseLinkParts', () => {
|
||||
const linkWith = (remark: string) =>
|
||||
`vless://uid@host.example.com:443?type=tcp&security=tls#${encodeURIComponent(remark)}`;
|
||||
|
||||
it('parses protocol / network / security and keeps the remark verbatim', () => {
|
||||
const parts = parseLinkParts(linkWith('Germany-john@example.com'));
|
||||
expect(parts?.protocol).toBe('Vless');
|
||||
expect(parts?.network).toBe('TCP');
|
||||
expect(parts?.security).toBe('TLS');
|
||||
expect(parts?.remark).toBe('Germany-john@example.com');
|
||||
expect(parts?.port).toBe('443');
|
||||
});
|
||||
|
||||
it('linkMetaText joins the remark with the port', () => {
|
||||
const parts = parseLinkParts(linkWith('Germany-john@example.com'));
|
||||
expect(parts && linkMetaText(parts)).toBe('Germany-john@example.com:443');
|
||||
});
|
||||
|
||||
it('returns null for an unparseable scheme', () => {
|
||||
expect(parseLinkParts('not-a-link')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
|
||||
|
||||
describe('RemarkTemplateField', () => {
|
||||
it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RemarkTemplateField value="DE " onChange={onChange} maxLength={256} />);
|
||||
|
||||
// Open the variable picker (the only button is the addon trigger).
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(await screen.findByText('{{EMAIL}}'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const inserted = onChange.mock.calls[0][0] as string;
|
||||
expect(inserted).toContain('{{EMAIL}}');
|
||||
expect(inserted).toContain('DE');
|
||||
});
|
||||
|
||||
it('renders a live preview of the expanded remark', () => {
|
||||
render(<RemarkTemplateField value="{{EMAIL}}" onChange={() => {}} />);
|
||||
// Sample expansion of {{EMAIL}} is "john".
|
||||
expect(screen.getByText('john')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
REMARK_VARIABLES,
|
||||
hasRemarkTokens,
|
||||
previewRemark,
|
||||
wrapToken,
|
||||
} from '@/lib/remark/remarkVariables';
|
||||
|
||||
describe('remark variables', () => {
|
||||
it('wrapToken / hasRemarkTokens', () => {
|
||||
expect(wrapToken('EMAIL')).toBe('{{EMAIL}}');
|
||||
expect(hasRemarkTokens('hi {{EMAIL}}')).toBe(true);
|
||||
expect(hasRemarkTokens('plain')).toBe(false);
|
||||
});
|
||||
|
||||
it('previewRemark substitutes known tokens and drops unknown', () => {
|
||||
expect(previewRemark('plain text')).toBe('plain text');
|
||||
expect(previewRemark('{{EMAIL}}')).toBe('john');
|
||||
expect(previewRemark('{{EMAIL}} · {{TRAFFIC_LEFT}} · {{DAYS_LEFT}}d')).toBe('john · 41.60GB · 12d');
|
||||
expect(previewRemark('{{NOT_A_TOKEN}}')).toBe('');
|
||||
});
|
||||
|
||||
it('every catalog token previews to its own sample', () => {
|
||||
for (const v of REMARK_VARIABLES) {
|
||||
expect(v.sample.length).toBeGreaterThan(0);
|
||||
expect(previewRemark(wrapToken(v.token))).toBe(v.sample);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ func initModels() error {
|
||||
&model.ClientExternalLink{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.Host{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.NodeClientIp{},
|
||||
&model.ClientGlobalTraffic{},
|
||||
@@ -93,6 +94,9 @@ func initModels() error {
|
||||
if err := pruneOrphanedClientInbounds(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pruneOrphanedHosts(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeInboundSubSortIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,6 +120,127 @@ func dropLegacyForeignKeys() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedHostsFromExternalProxy is a one-time, self-gated migration that creates a
|
||||
// Host row for every legacy externalProxy entry on every inbound. Additive: the
|
||||
// externalProxy arrays are left intact in StreamSettings.
|
||||
func seedHostsFromExternalProxy() error {
|
||||
var history []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(history, "HostsFromExternalProxy") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.StreamSettings) == "" {
|
||||
continue
|
||||
}
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil {
|
||||
log.Printf("HostsFromExternalProxy: skip inbound %d (invalid stream json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
eps, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(eps) == 0 {
|
||||
continue
|
||||
}
|
||||
for i, raw := range eps {
|
||||
ep, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := tx.Create(externalProxyEntryToHost(inbound.Id, i, ep)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// externalProxyEntryToHost maps one legacy externalProxy entry onto a Host.
|
||||
// forceTls (same|tls|none) maps straight to Security; an unknown value falls back
|
||||
// to "same" (inherit). An empty remark gets a stable generated label so the row
|
||||
// stays valid/editable, and the remark is capped at the model's 256-char limit.
|
||||
func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
|
||||
security, _ := ep["forceTls"].(string)
|
||||
switch security {
|
||||
case "same", "tls", "none":
|
||||
default:
|
||||
security = "same"
|
||||
}
|
||||
dest, _ := ep["dest"].(string)
|
||||
port := 0
|
||||
if p, ok := ep["port"].(float64); ok {
|
||||
port = int(p)
|
||||
}
|
||||
remark, _ := ep["remark"].(string)
|
||||
if strings.TrimSpace(remark) == "" {
|
||||
remark = "imported " + strconv.Itoa(index+1)
|
||||
}
|
||||
if len(remark) > 256 {
|
||||
remark = remark[:256]
|
||||
}
|
||||
sni, _ := ep["sni"].(string)
|
||||
fingerprint, _ := ep["fingerprint"].(string)
|
||||
ech, _ := ep["echConfigList"].(string)
|
||||
return &model.Host{
|
||||
InboundId: inboundId,
|
||||
SortOrder: index,
|
||||
Remark: remark,
|
||||
Address: dest,
|
||||
Port: port,
|
||||
Security: security,
|
||||
Sni: sni,
|
||||
Fingerprint: fingerprint,
|
||||
Alpn: anyToNonEmptyStrings(ep["alpn"]),
|
||||
PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
|
||||
EchConfigList: ech,
|
||||
}
|
||||
}
|
||||
|
||||
func anyToNonEmptyStrings(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, s := range t {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func pruneOrphanedHosts() error {
|
||||
res := db.Exec("DELETE FROM hosts WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
|
||||
if res.Error != nil {
|
||||
log.Printf("Error pruning orphaned hosts rows: %v", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Pruned %d orphaned hosts row(s)", res.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneOrphanedClientInbounds() error {
|
||||
res := db.Exec("DELETE FROM client_inbounds WHERE inbound_id NOT IN (SELECT id FROM inbounds)")
|
||||
if res.Error != nil {
|
||||
@@ -294,6 +419,12 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Self-gated on the "HostsFromExternalProxy" row, so it is safe to call
|
||||
// unconditionally here.
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func initMigrateDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
}
|
||||
|
||||
func seedInboundWithStream(t *testing.T, tag string, port int, stream string) *model.Inbound {
|
||||
t.Helper()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
|
||||
Remark: tag, Settings: `{"clients":[]}`, StreamSettings: stream,
|
||||
}
|
||||
if err := GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
const epMigrationStream = `{"network":"ws","security":"tls","externalProxy":[
|
||||
{"forceTls":"tls","dest":"a.cdn.com","port":8443,"remark":"A","sni":"a.sni","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"},
|
||||
{"forceTls":"none","dest":"b.cdn.com","port":80,"remark":"B"}
|
||||
]}`
|
||||
|
||||
// #1 — each externalProxy entry becomes one host row with the exact field
|
||||
// mapping; sort_order is the entry index; inbound_id is correct.
|
||||
func TestMigrate_ExternalProxyToHosts(t *testing.T) {
|
||||
initMigrateDB(t)
|
||||
ib := seedInboundWithStream(t, "m1", 5551, epMigrationStream)
|
||||
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var hosts []model.Host
|
||||
if err := GetDB().Where("inbound_id = ?", ib.Id).Order("sort_order asc").Find(&hosts).Error; err != nil {
|
||||
t.Fatalf("load hosts: %v", err)
|
||||
}
|
||||
if len(hosts) != 2 {
|
||||
t.Fatalf("hosts = %d, want 2", len(hosts))
|
||||
}
|
||||
a := hosts[0]
|
||||
if a.InboundId != ib.Id || a.SortOrder != 0 || a.Security != "tls" || a.Address != "a.cdn.com" ||
|
||||
a.Port != 8443 || a.Remark != "A" || a.Sni != "a.sni" || a.Fingerprint != "chrome" || a.EchConfigList != "ECHV" {
|
||||
t.Fatalf("host A mapping wrong: %+v", a)
|
||||
}
|
||||
if len(a.Alpn) != 2 || a.Alpn[0] != "h2" || a.Alpn[1] != "h3" {
|
||||
t.Fatalf("host A alpn = %v, want [h2 h3]", a.Alpn)
|
||||
}
|
||||
if len(a.PinnedPeerCertSha256) != 1 || a.PinnedPeerCertSha256[0] != "AAAA" {
|
||||
t.Fatalf("host A pins = %v, want [AAAA]", a.PinnedPeerCertSha256)
|
||||
}
|
||||
b := hosts[1]
|
||||
if b.InboundId != ib.Id || b.SortOrder != 1 || b.Security != "none" || b.Address != "b.cdn.com" ||
|
||||
b.Port != 80 || b.Remark != "B" {
|
||||
t.Fatalf("host B mapping wrong: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// #2 — a second run is a no-op (the HistoryOfSeeders gate).
|
||||
func TestMigrate_Idempotent(t *testing.T) {
|
||||
initMigrateDB(t)
|
||||
seedInboundWithStream(t, "m2", 5552, epMigrationStream)
|
||||
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
var count int64
|
||||
GetDB().Model(&model.Host{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("host count = %d, want 2 (second run must be a no-op)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// #3 — inbounds without externalProxy create no hosts.
|
||||
func TestMigrate_NoExternalProxy_NoHosts(t *testing.T) {
|
||||
initMigrateDB(t)
|
||||
seedInboundWithStream(t, "m3", 5553, `{"network":"tcp","security":"none"}`)
|
||||
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
var count int64
|
||||
GetDB().Model(&model.Host{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("host count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// #4 — externalProxy stays in StreamSettings (additive, rollback-safe).
|
||||
func TestMigrate_KeepsExternalProxyIntact(t *testing.T) {
|
||||
initMigrateDB(t)
|
||||
ib := seedInboundWithStream(t, "m4", 5554, epMigrationStream)
|
||||
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
var got model.Inbound
|
||||
if err := GetDB().First(&got, ib.Id).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
if !strings.Contains(got.StreamSettings, "externalProxy") || !strings.Contains(got.StreamSettings, "a.cdn.com") {
|
||||
t.Fatalf("externalProxy must remain in StreamSettings: %s", got.StreamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// #5 — same against a real Postgres DSN (sequence resync); skips without a DSN.
|
||||
func TestMigrate_Postgres(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres migration test")
|
||||
}
|
||||
if err := InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
// Clean slate so this run owns the migration regardless of prior tests.
|
||||
GetDB().Exec("TRUNCATE TABLE hosts, inbounds RESTART IDENTITY CASCADE")
|
||||
GetDB().Where("seeder_name = ?", "HostsFromExternalProxy").Delete(&model.HistoryOfSeeders{})
|
||||
|
||||
seedInboundWithStream(t, "mpg", 5555, epMigrationStream)
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("migrate pg: %v", err)
|
||||
}
|
||||
var count int64
|
||||
GetDB().Model(&model.Host{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("pg host count = %d, want 2", count)
|
||||
}
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
t.Fatalf("migrate pg (2nd): %v", err)
|
||||
}
|
||||
GetDB().Model(&model.Host{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("pg host count after 2nd run = %d, want 2 (idempotent)", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// hostColumns is the set of columns initModels must create for the hosts table.
|
||||
func hostColumns() []string {
|
||||
return []string{
|
||||
"id", "inbound_id", "sort_order", "remark", "server_description", "is_disabled", "is_hidden", "tags",
|
||||
"address", "port",
|
||||
"security", "sni", "host_header", "path", "alpn", "fingerprint",
|
||||
"override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256",
|
||||
"verify_peer_cert_by_name", "allow_insecure", "ech_config_list",
|
||||
"mux_params", "sockopt_params", "final_mask", "vless_route",
|
||||
"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "node_guids",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
}
|
||||
|
||||
func assertHostSchema(t *testing.T) {
|
||||
t.Helper()
|
||||
m := GetDB().Migrator()
|
||||
if !m.HasTable("hosts") {
|
||||
t.Fatalf("hosts table not created by initModels")
|
||||
}
|
||||
for _, col := range hostColumns() {
|
||||
if !m.HasColumn(&model.Host{}, col) {
|
||||
t.Fatalf("hosts table missing column %q", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostAutoMigrateCreatesColumns verifies the hosts table and every expected
|
||||
// column exist after initModels (SQLite).
|
||||
func TestHostAutoMigrateCreatesColumns(t *testing.T) {
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
assertHostSchema(t)
|
||||
}
|
||||
|
||||
// TestHostAutoMigrateCreatesColumns_Postgres is the dual-driver counterpart.
|
||||
func TestHostAutoMigrateCreatesColumns_Postgres(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres schema test")
|
||||
}
|
||||
if err := InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
assertHostSchema(t)
|
||||
}
|
||||
|
||||
// TestPruneOrphanedHosts verifies a host whose inbound_id has no matching inbound
|
||||
// is removed by the prune step.
|
||||
func TestPruneOrphanedHosts(t *testing.T) {
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
db := GetDB()
|
||||
|
||||
orphan := &model.Host{InboundId: 99999, Remark: "orphan"}
|
||||
if err := db.Create(orphan).Error; err != nil {
|
||||
t.Fatalf("create orphan host: %v", err)
|
||||
}
|
||||
if err := pruneOrphanedHosts(); err != nil {
|
||||
t.Fatalf("pruneOrphanedHosts: %v", err)
|
||||
}
|
||||
var cnt int64
|
||||
if err := db.Model(&model.Host{}).Where("id = ?", orphan.Id).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Fatalf("orphan host not pruned, count=%d", cnt)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func migrationModels() []any {
|
||||
&model.ClientInbound{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.InboundFallback{},
|
||||
&model.Host{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.NodeClientIp{},
|
||||
&model.OutboundSubscription{},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// TestHostTableName locks the table name the rest of the feature (queries,
|
||||
// prune, migration) keys off.
|
||||
func TestHostTableName(t *testing.T) {
|
||||
if got := (Host{}).TableName(); got != "hosts" {
|
||||
t.Fatalf("Host.TableName() = %q, want hosts", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostValidation locks the struct-tag constraints enforced by the request
|
||||
// binder (middleware.BindAndValidate -> validate.Struct).
|
||||
func TestHostValidation(t *testing.T) {
|
||||
v := validator.New(validator.WithRequiredStructEnabled())
|
||||
|
||||
valid := Host{InboundId: 1, Remark: "cdn-front", Port: 8443, Security: "tls", MihomoIpVersion: "dual"}
|
||||
if err := v.Struct(valid); err != nil {
|
||||
t.Fatalf("valid host rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
h Host
|
||||
}{
|
||||
{"missing inbound", Host{Remark: "ok"}},
|
||||
{"empty remark", Host{InboundId: 1, Remark: ""}},
|
||||
{"remark too long", Host{InboundId: 1, Remark: strings.Repeat("x", 257)}},
|
||||
{"port too high", Host{InboundId: 1, Remark: "ok", Port: 70000}},
|
||||
{"port negative", Host{InboundId: 1, Remark: "ok", Port: -1}},
|
||||
{"bad security", Host{InboundId: 1, Remark: "ok", Security: "bogus"}},
|
||||
{"bad mihomo ip version", Host{InboundId: 1, Remark: "ok", MihomoIpVersion: "nope"}},
|
||||
}
|
||||
for _, tc := range bad {
|
||||
if err := v.Struct(tc.h); err == nil {
|
||||
t.Fatalf("%s: expected validation error, got nil", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,6 +718,60 @@ type InboundFallback struct {
|
||||
|
||||
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
|
||||
|
||||
// Host is an override endpoint attached to an inbound: at subscription time each
|
||||
// enabled host renders one share link/proxy with its own address/port/TLS/etc.,
|
||||
// superseding the legacy externalProxy array. Free-JSON fields are stored as
|
||||
// text and parsed in the sub layer; slice fields use the json serializer.
|
||||
type Host struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
||||
InboundId int `json:"inboundId" form:"inboundId" gorm:"index;not null;column:inbound_id" validate:"required" example:"1"`
|
||||
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||
Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"cdn-front"`
|
||||
ServerDescription string `json:"serverDescription" form:"serverDescription" gorm:"column:server_description" validate:"omitempty,max=64"`
|
||||
IsDisabled bool `json:"isDisabled" form:"isDisabled" gorm:"default:false;column:is_disabled"`
|
||||
IsHidden bool `json:"isHidden" form:"isHidden" gorm:"default:false;column:is_hidden"`
|
||||
Tags []string `json:"tags" form:"tags" gorm:"serializer:json"`
|
||||
|
||||
Address string `json:"address" form:"address" example:"cdn.example.com"`
|
||||
Port int `json:"port" form:"port" gorm:"default:0" validate:"gte=0,lte=65535" example:"8443"`
|
||||
|
||||
Security string `json:"security" form:"security" gorm:"default:same" validate:"omitempty,oneof=same tls none reality" example:"same"`
|
||||
Sni string `json:"sni" form:"sni"`
|
||||
HostHeader string `json:"hostHeader" form:"hostHeader" gorm:"column:host_header"`
|
||||
Path string `json:"path" form:"path"`
|
||||
Alpn []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
|
||||
Fingerprint string `json:"fingerprint" form:"fingerprint"`
|
||||
OverrideSniFromAddress bool `json:"overrideSniFromAddress" form:"overrideSniFromAddress" gorm:"column:override_sni_from_address"`
|
||||
KeepSniBlank bool `json:"keepSniBlank" form:"keepSniBlank" gorm:"column:keep_sni_blank"`
|
||||
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256" form:"pinnedPeerCertSha256" gorm:"serializer:json;column:pinned_peer_cert_sha256"`
|
||||
VerifyPeerCertByName bool `json:"verifyPeerCertByName" form:"verifyPeerCertByName" gorm:"column:verify_peer_cert_by_name"`
|
||||
AllowInsecure bool `json:"allowInsecure" form:"allowInsecure" gorm:"column:allow_insecure"`
|
||||
EchConfigList string `json:"echConfigList" form:"echConfigList" gorm:"column:ech_config_list"`
|
||||
|
||||
MuxParams string `json:"muxParams" form:"muxParams" gorm:"type:text;column:mux_params"`
|
||||
SockoptParams string `json:"sockoptParams" form:"sockoptParams" gorm:"type:text;column:sockopt_params"`
|
||||
// FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),
|
||||
// merged into this host's JSON-subscription stream. Empty = no override.
|
||||
FinalMask string `json:"finalMask" form:"finalMask" gorm:"type:text;column:final_mask"`
|
||||
|
||||
// VlessRoute is a free-form port/range routing spec (e.g. "53,443,1000-2000");
|
||||
// stored verbatim, format-validated on the frontend.
|
||||
VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route"`
|
||||
|
||||
ExcludeFromSubTypes []string `json:"excludeFromSubTypes" form:"excludeFromSubTypes" gorm:"serializer:json;column:exclude_from_sub_types"`
|
||||
|
||||
MihomoIpVersion string `json:"mihomoIpVersion" form:"mihomoIpVersion" gorm:"column:mihomo_ip_version" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
|
||||
MihomoX25519 bool `json:"mihomoX25519" form:"mihomoX25519" gorm:"column:mihomo_x25519"`
|
||||
ShuffleHost bool `json:"shuffleHost" form:"shuffleHost" gorm:"column:shuffle_host"`
|
||||
|
||||
NodeGuids []string `json:"nodeGuids,omitempty" form:"nodeGuids" gorm:"serializer:json;column:node_guids"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (Host) TableName() string { return "hosts" }
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
rec := &ClientRecord{
|
||||
Email: c.Email,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Characterization snapshots (Phase 0 of the Hosts feature). These lock the
|
||||
// CURRENT subscription-link output for the externalProxy paths so the Phase-1
|
||||
// ShareEndpoint refactor can be proven behavior-preserving: they must pass on
|
||||
// unchanged code and stay green, unedited, through every later phase. Assertions
|
||||
// are exact (==) where output is deterministic and Contains where a value is
|
||||
// randomized (reality spx) or hex-derived.
|
||||
|
||||
const charClient = `{"id":"11111111-2222-4333-8444-555555555555","email":"user"}`
|
||||
|
||||
// charVlessInbound builds a VLESS inbound with one client "user".
|
||||
func charVlessInbound(stream string) *model.Inbound {
|
||||
return &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Remark: "char",
|
||||
Settings: `{"clients":[` + charClient + `],"decryption":"none","encryption":"none"}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
}
|
||||
|
||||
// C1 — VLESS, TLS base, 2 externalProxy entries (forceTls tls + none). Locks
|
||||
// buildExternalProxyURLLinks, applyExternalProxyTLSParams, the none-strip path,
|
||||
// per-entry ordering, and the "\n" join.
|
||||
func TestChar_C1_VlessExternalProxy(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"tls",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
|
||||
"externalProxy":[
|
||||
{"forceTls":"tls","dest":"cdn1.example.com","port":8443,"remark":"R1","sni":"sni1.example.com","fingerprint":"firefox","alpn":["h3","h2"],"pinnedPeerCertSha256":["UElO"]},
|
||||
{"forceTls":"none","dest":"cdn2.example.com","port":80,"remark":"R2"}
|
||||
]
|
||||
}`
|
||||
s := &SubService{}
|
||||
got := s.genVlessLink(charVlessInbound(stream), "user")
|
||||
want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
|
||||
"vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2"
|
||||
if got != want {
|
||||
t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// C4 — VLESS reality base + 1 externalProxy with forceTls "same". Locks the
|
||||
// "same keeps the base security (reality)" passthrough. spx is randomized so the
|
||||
// fixed fields are asserted by Contains.
|
||||
func TestChar_C4_VlessRealitySame(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"reality",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"realitySettings":{"serverNames":["reality.example.com"],"shortIds":["ab12cd"],"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}},
|
||||
"externalProxy":[{"forceTls":"same","dest":"cdn.example.com","port":2053,"remark":"RS"}]
|
||||
}`
|
||||
s := &SubService{}
|
||||
got := s.genVlessLink(charVlessInbound(stream), "user")
|
||||
wants := []string{
|
||||
"vless://11111111-2222-4333-8444-555555555555@cdn.example.com:2053",
|
||||
"security=reality",
|
||||
"sni=reality.example.com",
|
||||
"pbk=PBKvalue",
|
||||
"sid=ab12cd",
|
||||
"fp=firefox",
|
||||
"#char-RS",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Fatalf("C4 missing %q\n got: %s", w, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, "\n") != 0 {
|
||||
t.Fatalf("C4 expected a single link, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// C2 — VMess, TLS base, 2 externalProxy entries (forceTls same + none). Locks
|
||||
// buildVmessExternalProxyLinks, cloneVmessShareObj strip, the obj["tls"] rewrite,
|
||||
// and the int port. Asserts on the decoded JSON objects.
|
||||
func TestChar_C2_VmessExternalProxy(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"tls",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"tlsSettings":{"serverName":"base.sni","alpn":["h2"],"settings":{"fingerprint":"chrome"}},
|
||||
"externalProxy":[
|
||||
{"forceTls":"same","dest":"vm1.example.com","port":8443,"remark":"V1","sni":"sni1.example.com"},
|
||||
{"forceTls":"none","dest":"vm2.example.com","port":80,"remark":"V2"}
|
||||
]
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VMESS,
|
||||
Remark: "char",
|
||||
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user","security":"auto"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{}
|
||||
got := s.genVmessLink(in, "user")
|
||||
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
|
||||
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
|
||||
if got != want {
|
||||
t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
// Sanity: decode both objects so a structural change is visible too.
|
||||
for i, part := range strings.Split(got, "\n") {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(part, "vmess://"))
|
||||
if err != nil {
|
||||
t.Fatalf("C2 link %d not base64: %v", i, err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("C2 link %d not json: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C3a — Trojan, TLS base, 1 externalProxy entry. Locks userinfo encoding through
|
||||
// the shared builder.
|
||||
func TestChar_C3_TrojanExternalProxy(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"tls",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
|
||||
"externalProxy":[{"forceTls":"tls","dest":"tj.example.com","port":8443,"remark":"TJ","sni":"tj.sni"}]
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.Trojan,
|
||||
Remark: "char",
|
||||
Settings: `{"clients":[{"password":"p@ss/w+rd=","email":"user"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{}
|
||||
got := s.genTrojanLink(in, "user")
|
||||
want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
|
||||
if got != want {
|
||||
t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// C3b — Shadowsocks 2022 (method[0]=='2'), TLS base, 1 externalProxy entry.
|
||||
// Locks the ss-2022 triple-segment userinfo path through the shared builder.
|
||||
func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"tls",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
|
||||
"externalProxy":[{"forceTls":"tls","dest":"ss.example.com","port":8443,"remark":"SS","sni":"ss.sni"}]
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.Shadowsocks,
|
||||
Remark: "char",
|
||||
Settings: `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{}
|
||||
got := s.genShadowsocksLink(in, "user")
|
||||
want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
|
||||
if got != want {
|
||||
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A TCP http header on Shadowsocks must be emitted as a SIP002 obfs-local
|
||||
// plugin (what v2rayN parses), not the xray-native type/headerType/host/path
|
||||
// params (which SIP002 clients silently ignore).
|
||||
func TestShadowsocksTcpHttpHeaderUsesObfsLocalPlugin(t *testing.T) {
|
||||
stream := `{
|
||||
"network":"tcp","security":"none",
|
||||
"tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 38143,
|
||||
Protocol: model.Shadowsocks,
|
||||
Remark: "ss",
|
||||
Settings: `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{}
|
||||
got := s.genShadowsocksLink(in, "user")
|
||||
if !strings.Contains(got, "plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dtest") {
|
||||
t.Fatalf("expected obfs-local plugin param, got: %q", got)
|
||||
}
|
||||
for _, leak := range []string{"headerType=", "type=tcp", "host=test", "path="} {
|
||||
if strings.Contains(got, leak) {
|
||||
t.Fatalf("xray-native param %q must not leak into SS link: %q", leak, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C6 — Hysteria2, TLS, 1 externalProxy entry with a cert pin. Guards that the
|
||||
// Hysteria generator stays on its own path (hex pinSHA256, not pcs) and is NOT
|
||||
// folded into the unified builder. Pin hex is derived, so Contains is used.
|
||||
func TestChar_C6_HysteriaExternalProxy(t *testing.T) {
|
||||
// base64 of 32 zero bytes -> a valid pin shape for hysteriaPinHex.
|
||||
pin := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
stream := `{
|
||||
"security":"tls",
|
||||
"tlsSettings":{"serverName":"hy.sni","alpn":["h3"],"settings":{"fingerprint":"chrome"}},
|
||||
"externalProxy":[{"forceTls":"same","dest":"hop.example.com","port":9443,"remark":"H1","pinnedPeerCertSha256":["` + pin + `"]}]
|
||||
}`
|
||||
in := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
Remark: "char",
|
||||
Settings: `{"version":2,"clients":[{"auth":"hyauth","email":"user"}]}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{}
|
||||
got := s.genHysteriaLink(in, "user")
|
||||
wants := []string{
|
||||
"hysteria2://hyauth@hop.example.com:9443",
|
||||
"security=tls",
|
||||
"sni=hy.sni",
|
||||
"pinSHA256=",
|
||||
"#char-H1",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Fatalf("C6 missing %q\n got: %s", w, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "pcs=") {
|
||||
t.Fatalf("C6 hysteria must not use pcs=, got: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ func NewSubClashService(enableRouting bool, clashRules string, subService *SubSe
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
subReq := s.SubService.ForRequest(host)
|
||||
subReq.subscriptionBody = true
|
||||
inbounds, err := subReq.getInboundsBySubId(subId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -44,6 +45,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
}
|
||||
for _, client := range clients {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
|
||||
@@ -163,6 +167,9 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
|
||||
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it becomes the proxy name.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy)
|
||||
workingInbound := *inbound
|
||||
workingInbound.Listen = extPrxy["dest"].(string)
|
||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||
@@ -185,24 +192,30 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
|
||||
}
|
||||
applyHostStreamOverrides(extPrxy, workingStream)
|
||||
|
||||
proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy["remark"].(string))
|
||||
proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy)
|
||||
if len(proxy) > 0 {
|
||||
// Host-only mihomo knob: ip-version is a top-level proxy field, set
|
||||
// last so it cannot be clobbered. Absent for legacy externalProxy.
|
||||
if v, _ := extPrxy["mihomoIpVersion"].(string); v != "" {
|
||||
proxy["ip-version"] = v
|
||||
}
|
||||
proxies = append(proxies, proxy)
|
||||
}
|
||||
}
|
||||
return proxies
|
||||
}
|
||||
|
||||
func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
|
||||
func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, ep map[string]any) map[string]any {
|
||||
// Hysteria has its own transport + TLS model, applyTransport /
|
||||
// applySecurity don't fit.
|
||||
if inbound.Protocol == model.Hysteria {
|
||||
return s.buildHysteriaProxy(subReq, inbound, client, extraRemark)
|
||||
return s.buildHysteriaProxy(subReq, inbound, client, ep)
|
||||
}
|
||||
|
||||
proxy := map[string]any{
|
||||
"name": subReq.genRemark(inbound, client.Email, extraRemark),
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"udp": true,
|
||||
@@ -273,7 +286,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
// directly instead of going through streamData/tlsData, because those
|
||||
// helpers prune fields (like `allowInsecure` / the salamander obfs
|
||||
// block) that the hysteria proxy wants preserved.
|
||||
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
|
||||
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
|
||||
@@ -285,7 +298,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
||||
}
|
||||
|
||||
proxy := map[string]any{
|
||||
"name": subReq.genRemark(inbound, client.Email, extraRemark),
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
||||
"type": proxyType,
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
@@ -482,6 +495,11 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
|
||||
proxy["alpn"] = out
|
||||
}
|
||||
}
|
||||
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
|
||||
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
|
||||
proxy["skip-cert-verify"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "reality":
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
|
||||
// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
|
||||
// proxy. The existing clash tests don't assert any of these.
|
||||
func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
svc := &SubClashService{SubService: &SubService{}}
|
||||
inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
|
||||
stream := map[string]any{
|
||||
@@ -56,7 +56,7 @@ func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
|
||||
"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
|
||||
if proxy == nil {
|
||||
t.Fatal("buildProxy returned nil for a valid reality stream")
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestApplyTransport_HTTPUpgrade(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
svc := &SubClashService{SubService: &SubService{}}
|
||||
encryption := "mlkem768x25519plus.native.0rtt.client"
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
@@ -199,7 +199,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
|
||||
|
||||
if proxy["encryption"] != encryption {
|
||||
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
|
||||
@@ -207,7 +207,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
svc := &SubClashService{SubService: &SubService{}}
|
||||
encryption := "mlkem768x25519plus.native.0rtt.client"
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
@@ -231,7 +231,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
|
||||
|
||||
if proxy["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
|
||||
@@ -239,7 +239,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
svc := &SubClashService{SubService: &SubService{}}
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
@@ -256,7 +256,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
|
||||
|
||||
if _, ok := proxy["flow"]; ok {
|
||||
t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
|
||||
@@ -264,7 +264,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
svc := &SubClashService{SubService: &SubService{}}
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
@@ -281,7 +281,7 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
|
||||
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
|
||||
|
||||
if _, ok := proxy["encryption"]; ok {
|
||||
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
|
||||
|
||||
@@ -74,8 +74,7 @@ func NewSUBController(
|
||||
jsonEnabled bool,
|
||||
clashEnabled bool,
|
||||
encrypt bool,
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
remarkTemplate string,
|
||||
update string,
|
||||
jsonMux string,
|
||||
jsonRules string,
|
||||
@@ -89,7 +88,7 @@ func NewSUBController(
|
||||
subEnableRouting bool,
|
||||
subRoutingRules string,
|
||||
) *SUBController {
|
||||
sub := NewSubService(showInfo, rModel)
|
||||
sub := NewSubService(remarkTemplate)
|
||||
a := &SUBController{
|
||||
subTitle: subTitle,
|
||||
subSupportUrl: subSupportUrl,
|
||||
@@ -138,6 +137,12 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||
subReq := a.subService.ForRequest(host)
|
||||
// The remark template's per-client info is for the content a client app
|
||||
// imports — the raw subscription body. A browser viewing the HTML info page
|
||||
// gets clean, name-only remarks (usage is shown in the page summary).
|
||||
accept := c.GetHeader("Accept")
|
||||
wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
|
||||
subReq.subscriptionBody = !wantsHTML
|
||||
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
||||
if err != nil || len(subs) == 0 {
|
||||
writeSubError(c, err)
|
||||
@@ -148,8 +153,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
}
|
||||
|
||||
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
|
||||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
if wantsHTML {
|
||||
subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||
if !a.jsonEnabled {
|
||||
subJsonURL = ""
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// ShareEndpoint is one render target for a subscription link: the address/port
|
||||
// to dial plus an optional set of TLS overrides. It unifies two sources behind
|
||||
// one type so the per-protocol link builders don't branch on where the override
|
||||
// came from:
|
||||
//
|
||||
// - a legacy externalProxy entry (Phase 1): the source map is carried in `ep`
|
||||
// and applied through the unchanged applyExternalProxyTLS* helpers, so the
|
||||
// emitted link is byte-identical to the pre-refactor output;
|
||||
// - a Host row (Phase 4): leaves `ep` nil and uses typed override fields.
|
||||
//
|
||||
// ForceTls is the verbatim "same"/"tls"/"none"/"" value — never pre-resolved,
|
||||
// because three behaviors branch on the raw string (keep-base, obj["tls"]
|
||||
// rewrite, none-strip).
|
||||
type ShareEndpoint struct {
|
||||
Address string
|
||||
Port int
|
||||
Remark string // extra remark slot fed to genRemark, not a rendered remark
|
||||
ForceTls string
|
||||
|
||||
// ep is the source externalProxy entry. nil for host/default endpoints.
|
||||
ep map[string]any
|
||||
}
|
||||
|
||||
// externalProxyToEndpoint maps one externalProxy entry to an endpoint that
|
||||
// carries the entry for delegated, provably-identical TLS application.
|
||||
func externalProxyToEndpoint(ep map[string]any) ShareEndpoint {
|
||||
e := ShareEndpoint{ep: ep}
|
||||
e.Address, _ = ep["dest"].(string)
|
||||
if p, ok := ep["port"].(float64); ok {
|
||||
e.Port = int(p)
|
||||
}
|
||||
e.Remark, _ = ep["remark"].(string)
|
||||
e.ForceTls, _ = ep["forceTls"].(string)
|
||||
return e
|
||||
}
|
||||
|
||||
// inboundDefaultEndpoint is the endpoint for an inbound's own resolved
|
||||
// address/port (the no-externalProxy default). forceTls "same" keeps the base
|
||||
// security; no per-endpoint TLS override.
|
||||
func (s *SubService) inboundDefaultEndpoint(inbound *model.Inbound) ShareEndpoint {
|
||||
return ShareEndpoint{
|
||||
Address: s.resolveInboundAddress(inbound),
|
||||
Port: inbound.Port,
|
||||
ForceTls: "same",
|
||||
}
|
||||
}
|
||||
|
||||
// applyEndpointTLSParams applies an endpoint's TLS overrides onto a URL-param
|
||||
// map. External-proxy endpoints delegate to the unchanged helper; host/default
|
||||
// endpoints carry no override yet (Phase 4).
|
||||
func applyEndpointTLSParams(e ShareEndpoint, params map[string]string, security string) {
|
||||
if e.ep != nil {
|
||||
applyExternalProxyTLSParams(e.ep, params, security)
|
||||
}
|
||||
}
|
||||
|
||||
// applyEndpointTLSObj is applyEndpointTLSParams for the VMess base64-JSON form.
|
||||
func applyEndpointTLSObj(e ShareEndpoint, obj map[string]any, security string) {
|
||||
if e.ep != nil {
|
||||
applyExternalProxyTLSObj(e.ep, obj, security)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEndpointLinks renders one URL-param link per endpoint (vless/trojan/ss).
|
||||
// securityToApply mirrors the legacy externalProxy loop: "same" keeps the base
|
||||
// security, otherwise the endpoint's forceTls wins; "none" strips TLS hint
|
||||
// fields at emit time.
|
||||
func (s *SubService) buildEndpointLinks(
|
||||
eps []ShareEndpoint,
|
||||
params map[string]string,
|
||||
baseSecurity string,
|
||||
makeLink func(dest string, port int) string,
|
||||
makeRemark func(e ShareEndpoint) string,
|
||||
) string {
|
||||
links := make([]string, 0, len(eps))
|
||||
for _, e := range eps {
|
||||
securityToApply := baseSecurity
|
||||
if e.ForceTls != "same" {
|
||||
securityToApply = e.ForceTls
|
||||
}
|
||||
nextParams := cloneStringMap(params)
|
||||
applyEndpointTLSParams(e, nextParams, securityToApply)
|
||||
applyEndpointRealityParams(e, nextParams, securityToApply)
|
||||
applyEndpointHostPath(e, nextParams)
|
||||
applyEndpointAllowInsecure(e, nextParams, securityToApply)
|
||||
links = append(links, buildLinkWithParamsAndSecurity(
|
||||
makeLink(e.Address, e.Port),
|
||||
nextParams,
|
||||
makeRemark(e),
|
||||
securityToApply,
|
||||
e.ForceTls == "none",
|
||||
))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
|
||||
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
var links strings.Builder
|
||||
for index, e := range eps {
|
||||
securityToApply, _ := baseObj["tls"].(string)
|
||||
if e.ForceTls != "same" {
|
||||
securityToApply = e.ForceTls
|
||||
}
|
||||
newObj := cloneVmessShareObj(baseObj, e.ForceTls)
|
||||
newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
|
||||
newObj["add"] = e.Address
|
||||
newObj["port"] = e.Port
|
||||
if e.ForceTls != "same" {
|
||||
newObj["tls"] = e.ForceTls
|
||||
}
|
||||
applyEndpointTLSObj(e, newObj, securityToApply)
|
||||
applyEndpointHostPathObj(e, newObj)
|
||||
if index > 0 {
|
||||
links.WriteString("\n")
|
||||
}
|
||||
links.WriteString(buildVmessLink(newObj))
|
||||
}
|
||||
return links.String()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// N1 — externalProxyToEndpoint maps the scalar fields and carries the source
|
||||
// entry so delegated TLS application reproduces the legacy presence-tracked
|
||||
// overrides (absent key never clobbers an upstream value).
|
||||
func TestExternalProxyToEndpoint(t *testing.T) {
|
||||
ep := map[string]any{
|
||||
"forceTls": "tls",
|
||||
"dest": "cdn.example.com",
|
||||
"port": float64(8443),
|
||||
"remark": "R",
|
||||
"sni": "s.example.com",
|
||||
}
|
||||
e := externalProxyToEndpoint(ep)
|
||||
if e.Address != "cdn.example.com" {
|
||||
t.Fatalf("Address = %q, want cdn.example.com", e.Address)
|
||||
}
|
||||
if e.Port != 8443 {
|
||||
t.Fatalf("Port = %d, want 8443", e.Port)
|
||||
}
|
||||
if e.ForceTls != "tls" {
|
||||
t.Fatalf("ForceTls = %q, want tls", e.ForceTls)
|
||||
}
|
||||
if e.Remark != "R" {
|
||||
t.Fatalf("Remark = %q, want R", e.Remark)
|
||||
}
|
||||
if e.ep == nil {
|
||||
t.Fatalf("ep not carried; delegated TLS application would lose the source entry")
|
||||
}
|
||||
// Delegation preserves the sni override and does not invent absent fields.
|
||||
params := map[string]string{}
|
||||
applyEndpointTLSParams(e, params, "tls")
|
||||
if params["sni"] != "s.example.com" {
|
||||
t.Fatalf("delegated sni = %q, want s.example.com", params["sni"])
|
||||
}
|
||||
if _, ok := params["fp"]; ok {
|
||||
t.Fatalf("absent fingerprint must not be set, got fp=%q", params["fp"])
|
||||
}
|
||||
}
|
||||
|
||||
// N2 — inboundDefaultEndpoint reproduces the no-externalProxy default: resolved
|
||||
// address + inbound port, forceTls "same", empty remark, no source entry.
|
||||
func TestInboundDefaultEndpoint(t *testing.T) {
|
||||
in := &model.Inbound{Listen: "198.51.100.7", Port: 8080}
|
||||
s := &SubService{}
|
||||
e := s.inboundDefaultEndpoint(in)
|
||||
if e.Address != "198.51.100.7" {
|
||||
t.Fatalf("Address = %q, want 198.51.100.7", e.Address)
|
||||
}
|
||||
if e.Port != 8080 {
|
||||
t.Fatalf("Port = %d, want 8080", e.Port)
|
||||
}
|
||||
if e.ForceTls != "same" {
|
||||
t.Fatalf("ForceTls = %q, want same", e.ForceTls)
|
||||
}
|
||||
if e.Remark != "" {
|
||||
t.Fatalf("Remark = %q, want empty", e.Remark)
|
||||
}
|
||||
if e.ep != nil {
|
||||
t.Fatalf("default endpoint must not carry a source externalProxy entry")
|
||||
}
|
||||
}
|
||||
|
||||
// N3 — buildEndpointLinks renders the param-form path: one link per endpoint,
|
||||
// TLS override applied for tls, fields stripped + security overridden for none,
|
||||
// joined by "\n", in order.
|
||||
func TestBuildEndpointLinks_ParamForm(t *testing.T) {
|
||||
s := &SubService{}
|
||||
in := &model.Inbound{Remark: "ib"}
|
||||
params := map[string]string{"type": "tcp", "security": "tls", "sni": "base.sni", "fp": "chrome"}
|
||||
eps := []ShareEndpoint{
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "tls", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
||||
}
|
||||
got := s.buildEndpointLinks(eps, params, "tls",
|
||||
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
|
||||
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark) },
|
||||
)
|
||||
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
|
||||
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
|
||||
if got != want {
|
||||
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// N4 — buildEndpointVmessLinks renders the object-form path: base obj cloned per
|
||||
// endpoint, add/port/tls rewritten, sni override applied, none-strip honored.
|
||||
func TestBuildEndpointVmessLinks(t *testing.T) {
|
||||
s := &SubService{}
|
||||
in := &model.Inbound{Remark: "ib"}
|
||||
baseObj := map[string]any{
|
||||
"v": "2", "add": "base.example.com", "port": 443, "type": "none",
|
||||
"id": "uid", "scy": "auto", "net": "tcp",
|
||||
"tls": "tls", "sni": "base.sni", "alpn": "h2", "fp": "chrome",
|
||||
}
|
||||
eps := []ShareEndpoint{
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
||||
}
|
||||
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user")
|
||||
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
|
||||
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
|
||||
if got != want {
|
||||
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
|
||||
|
||||
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
|
||||
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
||||
svc := NewSubClashService(false, "", NewSubService(false, "-io"))
|
||||
svc := NewSubClashService(false, "", NewSubService(""))
|
||||
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
|
||||
if proxy == nil {
|
||||
t.Fatal("expected a clash proxy, got nil")
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
|
||||
t.Fatalf("seed external link: %v", err)
|
||||
}
|
||||
|
||||
base := NewSubService(false, "-io")
|
||||
base := NewSubService("")
|
||||
|
||||
jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// hostEndpoints loads an inbound's enabled hosts for the given subscription
|
||||
// format ("raw"|"json"|"clash") and returns them as externalProxy-shaped maps so
|
||||
// the existing per-format renderers can fan out one link/proxy per host. Returns
|
||||
// nil when the inbound has no applicable host — the caller then uses the legacy
|
||||
// inbound/externalProxy path, preserving byte-identical output for zero-host
|
||||
// inbounds.
|
||||
func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
|
||||
var hosts []*model.Host
|
||||
if err := database.GetDB().
|
||||
Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
|
||||
Order("sort_order asc, id asc").
|
||||
Find(&hosts).Error; err != nil {
|
||||
logger.Warning("SubService - hostEndpoints:", err)
|
||||
return nil
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil
|
||||
}
|
||||
defaultDest := s.resolveInboundAddress(inbound)
|
||||
eps := make([]map[string]any, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
if slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
continue
|
||||
}
|
||||
eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port))
|
||||
}
|
||||
return eps
|
||||
}
|
||||
|
||||
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
|
||||
// raw/json/clash renderers already consume. Address/port fall back to the
|
||||
// inbound's own when the host leaves them blank (override-only host).
|
||||
func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) map[string]any {
|
||||
dest := h.Address
|
||||
if dest == "" {
|
||||
dest = defaultDest
|
||||
}
|
||||
port := h.Port
|
||||
if port == 0 {
|
||||
port = defaultPort
|
||||
}
|
||||
ep := map[string]any{
|
||||
"forceTls": hostSecurityToForceTls(h.Security),
|
||||
"dest": dest,
|
||||
"port": float64(port),
|
||||
"remark": h.Remark,
|
||||
// Marks this as a host (not a legacy externalProxy) entry so host-only
|
||||
// behaviors (e.g. reality SNI/fp override) apply without touching the
|
||||
// legacy externalProxy path. Not emitted into output.
|
||||
"isHost": true,
|
||||
}
|
||||
sni := h.Sni
|
||||
if h.OverrideSniFromAddress {
|
||||
sni = dest
|
||||
}
|
||||
if !h.KeepSniBlank && sni != "" {
|
||||
ep["sni"] = sni
|
||||
}
|
||||
if h.Fingerprint != "" {
|
||||
ep["fingerprint"] = h.Fingerprint
|
||||
}
|
||||
if len(h.Alpn) > 0 {
|
||||
ep["alpn"] = stringsToAnySlice(h.Alpn)
|
||||
}
|
||||
if len(h.PinnedPeerCertSha256) > 0 {
|
||||
ep["pinnedPeerCertSha256"] = stringsToAnySlice(h.PinnedPeerCertSha256)
|
||||
}
|
||||
if h.EchConfigList != "" {
|
||||
ep["echConfigList"] = h.EchConfigList
|
||||
}
|
||||
if h.AllowInsecure {
|
||||
ep["allowInsecure"] = true
|
||||
}
|
||||
if h.HostHeader != "" {
|
||||
ep["hostHeader"] = h.HostHeader
|
||||
}
|
||||
if h.Path != "" {
|
||||
ep["path"] = h.Path
|
||||
}
|
||||
if h.MihomoIpVersion != "" {
|
||||
ep["mihomoIpVersion"] = h.MihomoIpVersion
|
||||
}
|
||||
if h.SockoptParams != "" {
|
||||
ep["sockoptParams"] = h.SockoptParams
|
||||
}
|
||||
if h.MuxParams != "" {
|
||||
ep["muxParams"] = h.MuxParams
|
||||
}
|
||||
if h.FinalMask != "" {
|
||||
ep["finalMask"] = h.FinalMask
|
||||
}
|
||||
return ep
|
||||
}
|
||||
|
||||
// hostMuxOverride returns a host's muxParams when it is valid JSON, else "".
|
||||
// Used to override the JSON outbound's mux for that host.
|
||||
func hostMuxOverride(ep map[string]any) string {
|
||||
mp, ok := ep["muxParams"].(string)
|
||||
if ok && mp != "" && json.Valid([]byte(mp)) {
|
||||
return mp
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyHostStreamOverrides injects a host's free-JSON stream overrides into the
|
||||
// per-host stream the JSON/Clash renderers build: sockoptParams (re-added since
|
||||
// the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
|
||||
// entries (which never carry these keys), so existing output is unchanged.
|
||||
func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
|
||||
if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
|
||||
var sockopt map[string]any
|
||||
if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
|
||||
stream["sockopt"] = sockopt
|
||||
}
|
||||
}
|
||||
// Host finalmask: merge the host's masks into the stream's finalmask (the
|
||||
// JSON renderer consumes streamSettings["finalmask"]; clash ignores it).
|
||||
if fm, ok := ep["finalMask"].(string); ok && fm != "" {
|
||||
var masks map[string]any
|
||||
if json.Unmarshal([]byte(fm), &masks) == nil && len(masks) > 0 {
|
||||
merged := mergeFinalMask(stream["finalmask"], masks)
|
||||
if len(merged) > 0 {
|
||||
stream["finalmask"] = merged
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reality SNI override (host only): JSON realityData reads serverNames and
|
||||
// clash reads serverName, so set both forms.
|
||||
if isHostEndpoint(ep) {
|
||||
if sec, _ := stream["security"].(string); sec == "reality" {
|
||||
if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil {
|
||||
if sni, ok := externalProxySNI(ep); ok {
|
||||
rs["serverName"] = sni
|
||||
rs["serverNames"] = []any{sni}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hostSecurityToForceTls maps Host.Security onto the externalProxy forceTls
|
||||
// vocabulary. "reality"/"same"/"" all keep the inbound's base security ("same")
|
||||
// — reality parameters can only come from the inbound itself.
|
||||
func hostSecurityToForceTls(security string) string {
|
||||
switch security {
|
||||
case "tls", "none":
|
||||
return security
|
||||
default:
|
||||
return "same"
|
||||
}
|
||||
}
|
||||
|
||||
func stringsToAnySlice(in []string) []any {
|
||||
out := make([]any, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectExternalProxy rewrites the inbound's StreamSettings so its externalProxy
|
||||
// array is exactly eps. Host endpoints win over any legacy externalProxy.
|
||||
func injectExternalProxy(inbound *model.Inbound, eps []map[string]any) {
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
if stream == nil {
|
||||
stream = map[string]any{}
|
||||
}
|
||||
arr := make([]any, len(eps))
|
||||
for i := range eps {
|
||||
arr[i] = eps[i]
|
||||
}
|
||||
stream["externalProxy"] = arr
|
||||
if b, err := json.Marshal(stream); err == nil {
|
||||
inbound.StreamSettings = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
// linkFromHosts renders a (possibly multi-line) raw link for one client using
|
||||
// the given host endpoints. It renders ONLY the hosts: an empty eps yields ""
|
||||
// (no legacy fallback) — the caller decides when to take the legacy path. That
|
||||
// separation is what makes the zero-hosts fallback mutation-testable.
|
||||
func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client, eps []map[string]any) string {
|
||||
if len(eps) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Clone each ep before expanding its remark template: the eps slice is
|
||||
// shared across all clients of this inbound, so the rendered (per-client)
|
||||
// remark must not leak into the next client's links.
|
||||
rendered := make([]map[string]any, len(eps))
|
||||
for i, ep := range eps {
|
||||
cp := maps.Clone(ep)
|
||||
s.renderHostRemark(inbound, client, cp)
|
||||
rendered[i] = cp
|
||||
}
|
||||
clone := *inbound
|
||||
injectExternalProxy(&clone, rendered)
|
||||
return s.GetLink(&clone, client.Email)
|
||||
}
|
||||
|
||||
// renderHostRemark expands a host endpoint's {{VAR}} remark template for one
|
||||
// client in place and marks it final, so the downstream link/proxy/config
|
||||
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
|
||||
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
|
||||
// their output stays byte-identical.
|
||||
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any) {
|
||||
if !isHostEndpoint(ep) {
|
||||
return
|
||||
}
|
||||
tmpl, _ := ep["remark"].(string)
|
||||
ep["remark"] = s.genHostRemark(inbound, client, tmpl)
|
||||
ep["remarkFinal"] = true
|
||||
}
|
||||
|
||||
// endpointRemark returns the remark to stamp on an endpoint's link/proxy/config
|
||||
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
|
||||
// carries remarkFinal and is used verbatim; every other entry flows through the
|
||||
// standard genRemark composition unchanged.
|
||||
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any) string {
|
||||
if ep != nil {
|
||||
if final, _ := ep["remarkFinal"].(bool); final {
|
||||
r, _ := ep["remark"].(string)
|
||||
return r
|
||||
}
|
||||
}
|
||||
var extra string
|
||||
if ep != nil {
|
||||
extra, _ = ep["remark"].(string)
|
||||
}
|
||||
return s.genRemark(inbound, email, extra)
|
||||
}
|
||||
|
||||
// applyEndpointHostPath overrides the transport host header / path for a host
|
||||
// endpoint. It is a no-op for legacy externalProxy entries (which never carry
|
||||
// hostHeader/path) and only replaces keys the transport already emits, so it
|
||||
// cannot add spurious params to e.g. a tcp link.
|
||||
func applyEndpointHostPath(e ShareEndpoint, params map[string]string) {
|
||||
if e.ep == nil {
|
||||
return
|
||||
}
|
||||
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
|
||||
if _, exists := params["host"]; exists {
|
||||
params["host"] = h
|
||||
}
|
||||
}
|
||||
if p, ok := e.ep["path"].(string); ok && p != "" {
|
||||
if _, exists := params["path"]; exists {
|
||||
params["path"] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isHostEndpoint reports whether ep was synthesized from a Host (vs a legacy
|
||||
// externalProxy entry), so host-only overrides stay off the legacy path.
|
||||
func isHostEndpoint(ep map[string]any) bool {
|
||||
v, _ := ep["isHost"].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// applyEndpointRealityParams overrides a reality link's SNI + fingerprint from a
|
||||
// host (reality's pbk/sid are inherited from the inbound, so they aren't touched).
|
||||
// Host-only: legacy externalProxy reality links are unchanged.
|
||||
func applyEndpointRealityParams(e ShareEndpoint, params map[string]string, security string) {
|
||||
if security != "reality" || e.ep == nil || !isHostEndpoint(e.ep) {
|
||||
return
|
||||
}
|
||||
if sni, ok := externalProxySNI(e.ep); ok {
|
||||
params["sni"] = sni
|
||||
}
|
||||
if fp, ok := e.ep["fingerprint"].(string); ok && fp != "" {
|
||||
params["fp"] = fp
|
||||
}
|
||||
}
|
||||
|
||||
// applyEndpointAllowInsecure adds allowInsecure=1 to a TLS/Reality link when the
|
||||
// host opts into skipping cert verification. No-op for legacy externalProxy
|
||||
// entries (which never carry the key) and for plaintext (none) endpoints.
|
||||
func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, security string) {
|
||||
if e.ep == nil || security == "none" {
|
||||
return
|
||||
}
|
||||
if ai, ok := e.ep["allowInsecure"].(bool); ok && ai {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
}
|
||||
|
||||
// applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form.
|
||||
func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) {
|
||||
if e.ep == nil {
|
||||
return
|
||||
}
|
||||
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
|
||||
if _, exists := obj["host"]; exists {
|
||||
obj["host"] = h
|
||||
}
|
||||
}
|
||||
if p, ok := e.ep["path"].(string); ok && p != "" {
|
||||
if _, exists := obj["path"]; exists {
|
||||
obj["path"] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func seedSubDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
// seedSubInbound creates a VLESS inbound with one client wired into the
|
||||
// normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
|
||||
func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
|
||||
email := tag + "@e"
|
||||
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
||||
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
|
||||
SubSortIndex: subSortIndex,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client %s: %v", email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %s: %v", email, err)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
func seedHost(t *testing.T, h *model.Host) *model.Host {
|
||||
t.Helper()
|
||||
if err := database.GetDB().Create(h).Error; err != nil {
|
||||
t.Fatalf("seed host: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
|
||||
|
||||
// #1 — an inbound with no hosts renders identically to the legacy path: a single
|
||||
// link from the inbound's own address. Mutation-checks the zero-hosts fallback.
|
||||
func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("links = %d, want 1", len(links))
|
||||
}
|
||||
if !strings.Contains(links[0], "203.0.113.5:4431") {
|
||||
t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
|
||||
}
|
||||
if strings.Contains(links[0], "\n") {
|
||||
t.Fatalf("zero-hosts must be a single link: %s", links[0])
|
||||
}
|
||||
}
|
||||
|
||||
// #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
|
||||
// own address/port/sni and host-header/path override.
|
||||
func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", Sni: "b.sni", HostHeader: "b.host", Path: "/b"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls", Sni: "a.sni", HostHeader: "a.host", Path: "/a"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
|
||||
}
|
||||
if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
|
||||
!strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
|
||||
t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
|
||||
}
|
||||
if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
|
||||
!strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
|
||||
t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// #3 — a disabled host is omitted; the inbound falls back to its legacy link.
|
||||
func TestSub_DisabledHostSkipped(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if strings.Contains(joined, "off.cdn.com") {
|
||||
t.Fatalf("disabled host must not render: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "203.0.113.5:4433") {
|
||||
t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #4 — when both hosts and a legacy externalProxy are set, hosts win and the
|
||||
// externalProxy entry is ignored.
|
||||
func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
stream := `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.cdn.com","port":7443,"remark":"L"}]}`
|
||||
ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if !strings.Contains(joined, "host.cdn.com:8443") {
|
||||
t.Fatalf("host should win: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "legacy.cdn.com") {
|
||||
t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #5 — hosts that share a remark but differ in address/port are NOT deduped:
|
||||
// distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
|
||||
func TestSub_NHosts_NoDedup(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
parts := strings.Split(joined, "\n")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
|
||||
}
|
||||
if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
|
||||
t.Fatalf("both distinct host addresses must appear: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
|
||||
// SubSortIndex, hosts within an inbound by sort_order.
|
||||
func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
// inbound "second" has a higher SubSortIndex so it must come after "first".
|
||||
ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
|
||||
ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
|
||||
seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
firstAt := strings.Index(joined, "first-host.com")
|
||||
secondAt := strings.Index(joined, "second-host.com")
|
||||
if firstAt < 0 || secondAt < 0 {
|
||||
t.Fatalf("both inbound hosts should render: %s", joined)
|
||||
}
|
||||
if firstAt > secondAt {
|
||||
t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
|
||||
// address/sni win over the projected master stream.
|
||||
func TestSub_HostOverFallback(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
db := database.GetDB()
|
||||
master := &model.Inbound{
|
||||
UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
|
||||
Protocol: model.VLESS, Remark: "master",
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
|
||||
}
|
||||
if err := db.Create(master).Error; err != nil {
|
||||
t.Fatalf("seed master: %v", err)
|
||||
}
|
||||
// child listens internal-only so projection triggers.
|
||||
child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
|
||||
child.Listen = "127.0.0.1"
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
|
||||
t.Fatalf("set child listen: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
|
||||
t.Fatalf("host override must win over fallback master: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
|
||||
t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #8 — a client only gets hosts for inbounds it is actually on (the
|
||||
// clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
|
||||
func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream) // client on s1
|
||||
other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
|
||||
seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if strings.Contains(joined, "other-host.com") {
|
||||
t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// allowInsecure renders as allowInsecure=1 in the raw link and
|
||||
// skip-cert-verify: true in the Clash proxy.
|
||||
func TestSub_HostAllowInsecure(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
|
||||
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if !strings.Contains(yaml, "skip-cert-verify: true") {
|
||||
t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
|
||||
}
|
||||
}
|
||||
|
||||
// A host's sockoptParams is injected into the JSON output stream (sockopt is
|
||||
// stripped from the base stream, re-added per host).
|
||||
func TestSub_HostSockoptJSON(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "so", 4460, 1,
|
||||
`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
|
||||
SockoptParams: `{"tcpFastOpen":true}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
|
||||
t.Fatalf("json should include the host sockopt:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A host's muxParams override the JSON outbound's mux.
|
||||
func TestSub_HostMuxJSON(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
|
||||
MuxParams: `{"enabled":true,"concurrency":8}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "concurrency") {
|
||||
t.Fatalf("json should include the host mux override:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
|
||||
// inbound (reality keys can't be host-supplied).
|
||||
func TestSub_HostRealitySniOverride(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
||||
ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
|
||||
Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
|
||||
})
|
||||
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
joined := strings.Join(links, "\n")
|
||||
if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
|
||||
t.Fatalf("reality host base wrong: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
|
||||
t.Fatalf("reality host sni/fp override not applied: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "sni=base.reality.com") {
|
||||
t.Fatalf("base reality sni must be overridden: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
|
||||
t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
|
||||
// absent from GetClash but present in the raw GetSubs output.
|
||||
func TestSub_ExcludeFromSubTypes(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
|
||||
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
|
||||
|
||||
sub := NewSubService("")
|
||||
links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
|
||||
t.Fatalf("host not excluded from raw should appear in GetSubs")
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if strings.Contains(yaml, "clashless.cdn.com") {
|
||||
t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
|
||||
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
|
||||
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
|
||||
subReq := s.SubService.ForRequest(host)
|
||||
subReq.subscriptionBody = true
|
||||
inbounds, err := subReq.getInboundsBySubId(subId)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -82,6 +83,9 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||
continue
|
||||
}
|
||||
subReq.projectThroughFallbackMaster(inbound)
|
||||
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
|
||||
injectExternalProxy(inbound, hostEps)
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
@@ -163,6 +167,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it's used as the config remark.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy)
|
||||
inbound.Listen = extPrxy["dest"].(string)
|
||||
inbound.Port = int(extPrxy["port"].(float64))
|
||||
newStream := cloneStreamForExternalProxy(stream)
|
||||
@@ -182,17 +189,19 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, newStream, security)
|
||||
}
|
||||
applyHostStreamOverrides(extPrxy, newStream)
|
||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||
hostMux := hostMuxOverride(extPrxy)
|
||||
|
||||
var newOutbounds []json_util.RawMessage
|
||||
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client))
|
||||
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, hostMux))
|
||||
case "vless":
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, hostMux))
|
||||
case "trojan", "shadowsocks":
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, hostMux))
|
||||
case "hysteria":
|
||||
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
|
||||
}
|
||||
@@ -202,7 +211,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = subReq.genRemark(inbound, client.Email, extPrxy["remark"].(string))
|
||||
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
|
||||
|
||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
||||
newJsonArray = append(newJsonArray, newConfig)
|
||||
@@ -321,13 +330,21 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
||||
return rltyData
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
// jsonMux picks the per-host mux override when present, else the global mux.
|
||||
func jsonMux(global, override string) string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
if mux := jsonMux(s.mux, muxOverride); mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
@@ -347,12 +364,12 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
if mux := jsonMux(s.mux, muxOverride); mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
@@ -376,7 +393,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
serverData := make([]ServerSetting, 1)
|
||||
@@ -403,21 +420,27 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
if mux := jsonMux(s.mux, muxOverride); mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
settings := map[string]any{
|
||||
// Wrap the endpoint in a "servers" array (the standard Xray schema for
|
||||
// Shadowsocks/Trojan outbounds). The flat top-level form only parses on very
|
||||
// recent xray-core; older bundled cores (e.g. in v2rayN) reject it, so SS
|
||||
// links fail to connect. See genVnext/genVless for the VMess/VLESS shape.
|
||||
server := map[string]any{
|
||||
"address": serverData[0].Address,
|
||||
"port": serverData[0].Port,
|
||||
"password": serverData[0].Password,
|
||||
"level": 8,
|
||||
}
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
settings["method"] = serverData[0].Method
|
||||
server["method"] = serverData[0].Method
|
||||
}
|
||||
outbound.Settings = map[string]any{
|
||||
"servers": []any{server},
|
||||
}
|
||||
outbound.Settings = settings
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client, ""))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
||||
client := model.Client{ID: "uuid-2"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vmess outbound must not use vnext")
|
||||
}
|
||||
@@ -128,21 +128,32 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceServerFlattened(t *testing.T) {
|
||||
// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
|
||||
// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
|
||||
// works on very recent xray-core.
|
||||
func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
|
||||
if _, ok := settings["servers"]; ok {
|
||||
t.Fatal("trojan outbound must not use servers array")
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
|
||||
server := firstServer(settings)
|
||||
if server == nil {
|
||||
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
|
||||
}
|
||||
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
|
||||
t.Fatalf("flat trojan settings wrong: %#v", settings)
|
||||
if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
|
||||
t.Fatalf("trojan server entry wrong: %#v", server)
|
||||
}
|
||||
if _, ok := server["method"]; ok {
|
||||
t.Fatalf("trojan must not carry method: %#v", server)
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
|
||||
if ssSettings["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
|
||||
ssServer := firstServer(ssSettings)
|
||||
if ssServer == nil {
|
||||
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
|
||||
}
|
||||
if ssServer["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,8 @@ func NewLinkProvider() *LinkProvider {
|
||||
}
|
||||
|
||||
func (p *LinkProvider) build(host string) *SubService {
|
||||
showInfo, _ := p.settingService.GetSubShowInfo()
|
||||
rModel, err := p.settingService.GetRemarkModel()
|
||||
if err != nil {
|
||||
rModel = "-io"
|
||||
}
|
||||
svc := NewSubService(showInfo, rModel)
|
||||
remarkTemplate, _ := p.settingService.GetRemarkTemplate()
|
||||
svc := NewSubService(remarkTemplate)
|
||||
svc.PrepareForRequest(host)
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
|
||||
wantMux bool
|
||||
protocol model.Protocol
|
||||
}{
|
||||
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), true, model.VMESS},
|
||||
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), true, model.Trojan},
|
||||
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), false, model.VMESS},
|
||||
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), false, model.Trojan},
|
||||
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), true, model.VMESS},
|
||||
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), true, model.VLESS},
|
||||
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), true, model.Trojan},
|
||||
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
|
||||
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
|
||||
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -225,7 +225,7 @@ func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
|
||||
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
|
||||
StreamSettings: stream,
|
||||
}
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
|
||||
t.Fatalf("empty client flow must not produce a flow param, got %q", link)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"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/xray"
|
||||
)
|
||||
|
||||
// remarkContext carries the per-client data a remark template can interpolate.
|
||||
// stats holds the live traffic record when one exists; when it doesn't, the
|
||||
// caller synthesizes a minimal one from the client so expiry/total/status tokens
|
||||
// still resolve. hostRemark is the host endpoint's own remark: it takes priority
|
||||
// over the inbound's remark as the config name and backs the {{HOST}} token.
|
||||
type remarkContext struct {
|
||||
client model.Client
|
||||
stats xray.ClientTraffic
|
||||
inbound *model.Inbound
|
||||
hostRemark string
|
||||
}
|
||||
|
||||
// configName is the display name for a link: the host endpoint's own remark when
|
||||
// it has one, otherwise the inbound's remark.
|
||||
func (ctx remarkContext) configName() string {
|
||||
if ctx.hostRemark != "" {
|
||||
return ctx.hostRemark
|
||||
}
|
||||
if ctx.inbound != nil {
|
||||
return ctx.inbound.Remark
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// remarkVarRe matches a {{TOKEN}} placeholder. Tokens are uppercase letters and
|
||||
// underscores only, so ordinary braces in a remark are left untouched.
|
||||
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
|
||||
|
||||
// unlimitedMark is the value the human-readable quota/expiry tokens render when
|
||||
// the client has no limit. A segment built only around such a token carries no
|
||||
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
|
||||
const unlimitedMark = "∞"
|
||||
|
||||
// unlimitedDropTokens are the tokens that render unlimitedMark for an unlimited
|
||||
// client. A "|"-separated segment whose only value comes from one of these is
|
||||
// dropped whole when unlimited, so the operator never sees "📊∞|⏳∞D".
|
||||
var unlimitedDropTokens = map[string]bool{
|
||||
"TRAFFIC_LEFT": true,
|
||||
"TRAFFIC_TOTAL": true,
|
||||
"DAYS_LEFT": true,
|
||||
}
|
||||
|
||||
// expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
|
||||
// value. Unknown tokens resolve to "" (never the literal text). The template is
|
||||
// split on "|" into segments: a segment whose only value is an unlimited quota
|
||||
// or expiry (∞) drops out whole — decoration and separator included — so an
|
||||
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
|
||||
func expandRemarkVars(template string, ctx remarkContext) string {
|
||||
if !strings.Contains(template, "{{") {
|
||||
return template
|
||||
}
|
||||
segments := strings.Split(template, "|")
|
||||
kept := make([]string, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if out, drop := expandSegment(seg, ctx); !drop {
|
||||
kept = append(kept, out)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "|")
|
||||
}
|
||||
|
||||
// expandSegment expands one "|" segment and reports whether it should be dropped.
|
||||
// It drops only when the segment carries an unlimited (∞) quota/expiry token and
|
||||
// no other token in it resolves to a non-empty value — so a segment mixing, say,
|
||||
// {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
|
||||
func expandSegment(seg string, ctx remarkContext) (string, bool) {
|
||||
hasUnlimited, hasOtherValue := false, false
|
||||
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
|
||||
token := m[2 : len(m)-2]
|
||||
val := remarkVarValue(token, ctx)
|
||||
switch {
|
||||
case unlimitedDropTokens[token] && val == unlimitedMark:
|
||||
hasUnlimited = true
|
||||
case val != "":
|
||||
hasOtherValue = true
|
||||
}
|
||||
return val
|
||||
})
|
||||
return out, hasUnlimited && !hasOtherValue
|
||||
}
|
||||
|
||||
func remarkVarValue(token string, ctx remarkContext) string {
|
||||
c := ctx.client
|
||||
st := ctx.stats
|
||||
used := st.Up + st.Down
|
||||
switch token {
|
||||
case "EMAIL", "USERNAME":
|
||||
return c.Email
|
||||
case "INBOUND":
|
||||
return ctx.configName()
|
||||
case "HOST":
|
||||
return ctx.hostRemark
|
||||
case "ID":
|
||||
return c.ID
|
||||
case "SHORT_ID":
|
||||
if len(c.ID) >= 8 {
|
||||
return c.ID[:8]
|
||||
}
|
||||
return c.ID
|
||||
case "TELEGRAM_ID":
|
||||
if c.TgID != 0 {
|
||||
return strconv.FormatInt(c.TgID, 10)
|
||||
}
|
||||
return ""
|
||||
case "SUB_ID":
|
||||
return c.SubID
|
||||
case "COMMENT":
|
||||
return c.Comment
|
||||
case "STATUS":
|
||||
return clientStatus(st)
|
||||
case "DAYS_LEFT":
|
||||
return daysLeftLabel(st.ExpiryTime)
|
||||
case "EXPIRE_DATE":
|
||||
return expireDateLabel(st.ExpiryTime)
|
||||
case "EXPIRE_UNIX":
|
||||
if st.ExpiryTime <= 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatInt(st.ExpiryTime/1000, 10)
|
||||
case "CREATED_UNIX":
|
||||
if c.CreatedAt == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(c.CreatedAt/1000, 10)
|
||||
case "TRAFFIC_USED":
|
||||
return common.FormatTraffic(used)
|
||||
case "TRAFFIC_LEFT":
|
||||
if st.Total <= 0 {
|
||||
return unlimitedMark
|
||||
}
|
||||
return common.FormatTraffic(max64(st.Total-used, 0))
|
||||
case "TRAFFIC_TOTAL":
|
||||
if st.Total <= 0 {
|
||||
return unlimitedMark
|
||||
}
|
||||
return common.FormatTraffic(st.Total)
|
||||
case "TRAFFIC_USED_BYTES":
|
||||
return strconv.FormatInt(used, 10)
|
||||
case "TRAFFIC_LEFT_BYTES":
|
||||
if st.Total <= 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatInt(max64(st.Total-used, 0), 10)
|
||||
case "TRAFFIC_TOTAL_BYTES":
|
||||
return strconv.FormatInt(st.Total, 10)
|
||||
case "UP":
|
||||
return common.FormatTraffic(st.Up)
|
||||
case "DOWN":
|
||||
return common.FormatTraffic(st.Down)
|
||||
case "RESET_DAYS":
|
||||
if c.Reset > 0 {
|
||||
return strconv.Itoa(c.Reset)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clientStatus collapses enable/expiry/quota into a single word.
|
||||
func clientStatus(st xray.ClientTraffic) string {
|
||||
if !st.Enable {
|
||||
return "disabled"
|
||||
}
|
||||
if st.ExpiryTime > 0 && st.ExpiryTime/1000 < time.Now().Unix() {
|
||||
return "expired"
|
||||
}
|
||||
if st.Total > 0 && st.Up+st.Down >= st.Total {
|
||||
return "depleted"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
// daysLeftLabel is the whole-days form of remainingTimeLabel: "∞" for unlimited,
|
||||
// "0" once past expiry.
|
||||
func daysLeftLabel(expiryMs int64) string {
|
||||
if expiryMs == 0 {
|
||||
return unlimitedMark
|
||||
}
|
||||
exp := expiryMs / 1000
|
||||
var secs int64
|
||||
if exp > 0 {
|
||||
secs = exp - time.Now().Unix()
|
||||
} else {
|
||||
secs = -exp // delayed-start: value is the duration itself
|
||||
}
|
||||
days := secs / 86400
|
||||
if days < 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatInt(days, 10)
|
||||
}
|
||||
|
||||
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
|
||||
// delayed-start (no fixed calendar date yet) expiries yield "".
|
||||
func expireDateLabel(expiryMs int64) string {
|
||||
if expiryMs <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// statsForClient returns the client's live traffic record, or a minimal one
|
||||
// synthesized from the client (enable/expiry/total) when no live stats exist —
|
||||
// so expiry/total/status tokens still resolve on links that have no counters yet.
|
||||
func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) xray.ClientTraffic {
|
||||
if stats, ok := s.findClientStats(inbound, client.Email); ok {
|
||||
return stats
|
||||
}
|
||||
return xray.ClientTraffic{
|
||||
Enable: client.Enable,
|
||||
ExpiryTime: client.ExpiryTime,
|
||||
Total: client.TotalGB,
|
||||
}
|
||||
}
|
||||
|
||||
// lookupClient resolves the full client (TgID, SubID, comment, …) for an email,
|
||||
// needed when a global remark template references client-only tokens. Falls back
|
||||
// to an email-only client if not found.
|
||||
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
|
||||
clients, _ := s.inboundService.GetClients(inbound)
|
||||
for _, c := range clients {
|
||||
if c.Email == email {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return model.Client{Email: email}
|
||||
}
|
||||
|
||||
// usageInfoTokens are the per-client status tokens. On every link of a
|
||||
// subscription except the client's first, these (and the decoration leading
|
||||
// into them) are dropped, so the traffic/expiry info shows once instead of on
|
||||
// every server.
|
||||
var usageInfoTokens = []string{
|
||||
"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
|
||||
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
|
||||
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
|
||||
}
|
||||
|
||||
// nameOnlyTemplate returns template with the trailing per-client info part
|
||||
// removed: everything from the first usage token (and the decoration — emojis,
|
||||
// spaces, separators — leading into it) onward is dropped, leaving the config
|
||||
// name. Returns "" when the template is info-only.
|
||||
func nameOnlyTemplate(template string) string {
|
||||
idx := -1
|
||||
for _, tok := range usageInfoTokens {
|
||||
if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return template
|
||||
}
|
||||
return strings.TrimRightFunc(template[:idx], func(r rune) bool {
|
||||
return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// effectiveTemplate picks which template to expand for one body link: the full
|
||||
// template (with the per-client info) for a client's first link, and the
|
||||
// name-only template for every link thereafter — so the info shows once. Only
|
||||
// called in the subscription-body context (displays bypass the template).
|
||||
func (s *SubService) effectiveTemplate(email string) string {
|
||||
if s.usageShown == nil {
|
||||
s.usageShown = map[string]bool{}
|
||||
}
|
||||
if s.usageShown[email] {
|
||||
return nameOnlyTemplate(s.remarkTemplate)
|
||||
}
|
||||
s.usageShown[email] = true
|
||||
return s.remarkTemplate
|
||||
}
|
||||
|
||||
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
||||
// the host endpoint's remark (empty for a plain inbound); it takes priority over
|
||||
// the inbound remark for the config name and backs the {{HOST}} token.
|
||||
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
ctx := remarkContext{
|
||||
client: client,
|
||||
stats: s.statsForClient(inbound, client),
|
||||
inbound: inbound,
|
||||
hostRemark: hostRemark,
|
||||
}
|
||||
tmpl := s.effectiveTemplate(client.Email)
|
||||
// Fall back to the config name when the template is empty or expands to
|
||||
// nothing (e.g. an all-unlimited template whose only segments dropped out).
|
||||
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
|
||||
return out
|
||||
}
|
||||
return ctx.configName()
|
||||
}
|
||||
|
||||
// genHostRemark builds one host endpoint's remark for a specific client. The
|
||||
// config name is the host endpoint's own remark when set, otherwise the inbound's
|
||||
// remark. In the subscription body the rest of the remark template still applies;
|
||||
// displays show just the config name.
|
||||
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
if !s.subscriptionBody {
|
||||
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
|
||||
}
|
||||
return s.genTemplatedRemark(inbound, client, hostRemark)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const gb = int64(1024 * 1024 * 1024)
|
||||
|
||||
// expandCtx builds a remarkContext from explicit pieces for token tests.
|
||||
func expandCtx(client model.Client, stats xray.ClientTraffic, inbound *model.Inbound) remarkContext {
|
||||
return remarkContext{client: client, stats: stats, inbound: inbound}
|
||||
}
|
||||
|
||||
func TestExpandRemarkVars(t *testing.T) {
|
||||
inbound := &model.Inbound{Remark: "Germany"}
|
||||
client := model.Client{
|
||||
Email: "john@example.com",
|
||||
ID: "3f2a9c1b-aaaa-bbbb-cccc-1234567890ab",
|
||||
TgID: 123456789,
|
||||
SubID: "subABC",
|
||||
Comment: "vip",
|
||||
Reset: 30,
|
||||
CreatedAt: 1_700_000_000_000,
|
||||
}
|
||||
// 50GB total, 8GB used (5 up + 3 down), enabled, no expiry.
|
||||
stats := xray.ClientTraffic{
|
||||
Enable: true,
|
||||
Total: 50 * gb,
|
||||
Up: 5 * gb,
|
||||
Down: 3 * gb,
|
||||
}
|
||||
ctx := expandCtx(client, stats, inbound)
|
||||
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{{EMAIL}}", "john@example.com"},
|
||||
{"{{USERNAME}}", "john@example.com"},
|
||||
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
|
||||
{"{{HOST}}", ""}, // no host remark in ctx → empty
|
||||
{"{{ID}}", client.ID},
|
||||
{"{{SHORT_ID}}", "3f2a9c1b"},
|
||||
{"{{TELEGRAM_ID}}", "123456789"},
|
||||
{"{{SUB_ID}}", "subABC"},
|
||||
{"{{COMMENT}}", "vip"},
|
||||
{"{{RESET_DAYS}}", "30"},
|
||||
{"{{CREATED_UNIX}}", "1700000000"},
|
||||
{"{{TRAFFIC_USED}}", "8.00GB"},
|
||||
{"{{TRAFFIC_LEFT}}", "42.00GB"},
|
||||
{"{{TRAFFIC_TOTAL}}", "50.00GB"},
|
||||
{"{{TRAFFIC_USED_BYTES}}", "8589934592"},
|
||||
{"{{TRAFFIC_TOTAL_BYTES}}", "53687091200"},
|
||||
{"{{UP}}", "5.00GB"},
|
||||
{"{{DOWN}}", "3.00GB"},
|
||||
{"{{STATUS}}", "active"},
|
||||
{"{{EXPIRE_UNIX}}", "0"}, // no expiry
|
||||
{"{{EXPIRE_DATE}}", ""}, // no fixed date
|
||||
{"{{UNKNOWN_TOKEN}}", ""}, // unknown → empty, never literal
|
||||
{"DE {{EMAIL}} ok", "DE john@example.com ok"},
|
||||
{"{{EMAIL}}-{{SHORT_ID}}", "john@example.com-3f2a9c1b"},
|
||||
{"no tokens here", "no tokens here"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
|
||||
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
|
||||
}
|
||||
}
|
||||
// The unlimited tokens still render ∞ at the value layer; expandRemarkVars
|
||||
// is what drops an all-unlimited segment (see TestExpandRemarkVars_DropUnlimitedSegments).
|
||||
if got := remarkVarValue("DAYS_LEFT", ctx); got != "∞" {
|
||||
t.Errorf("remarkVarValue(DAYS_LEFT) = %q, want ∞", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRemarkVars_EdgeCases(t *testing.T) {
|
||||
// Unlimited total → ∞ for human forms, 0 bytes for *_BYTES left. Checked at
|
||||
// the value layer: expandRemarkVars would drop a bare ∞ segment.
|
||||
unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 0, Up: gb}, nil)
|
||||
if got := remarkVarValue("TRAFFIC_TOTAL", unlimited); got != "∞" {
|
||||
t.Errorf("unlimited TRAFFIC_TOTAL = %q, want ∞", got)
|
||||
}
|
||||
if got := remarkVarValue("TRAFFIC_LEFT", unlimited); got != "∞" {
|
||||
t.Errorf("unlimited TRAFFIC_LEFT = %q, want ∞", got)
|
||||
}
|
||||
if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", unlimited); got != "0" {
|
||||
t.Errorf("unlimited TRAFFIC_LEFT_BYTES = %q, want 0", got)
|
||||
}
|
||||
// TgID zero → empty.
|
||||
if got := expandRemarkVars("{{TELEGRAM_ID}}", unlimited); got != "" {
|
||||
t.Errorf("zero TgID = %q, want empty", got)
|
||||
}
|
||||
// Over-quota usage clamps left to 0, not negative.
|
||||
over := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: gb, Up: 2 * gb}, nil)
|
||||
if got := expandRemarkVars("{{TRAFFIC_LEFT_BYTES}}", over); got != "0" {
|
||||
t.Errorf("over-quota TRAFFIC_LEFT_BYTES = %q, want 0", got)
|
||||
}
|
||||
// Delayed-start (negative expiry) gives deterministic whole days.
|
||||
delayed := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, ExpiryTime: -864_000_000}, nil)
|
||||
if got := expandRemarkVars("{{DAYS_LEFT}}", delayed); got != "10" {
|
||||
t.Errorf("delayed-start DAYS_LEFT = %q, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An unlimited client drops the quota/expiry segments whole — decoration and the
|
||||
// "|" separator included — instead of printing "📊∞|⏳∞D".
|
||||
func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
|
||||
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
|
||||
inbound := &model.Inbound{Remark: "host"}
|
||||
|
||||
// No limit at all → only the name segment survives.
|
||||
unlimited := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
|
||||
if got := expandRemarkVars(tmpl, unlimited); got != "host" {
|
||||
t.Errorf("fully unlimited = %q, want %q", got, "host")
|
||||
}
|
||||
|
||||
// Limited traffic but no expiry → traffic stays, the expiry segment drops.
|
||||
noExpiry := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true, Total: 50 * gb, Up: 8 * gb}, inbound)
|
||||
if got := expandRemarkVars(tmpl, noExpiry); got != "host|📊42.00GB" {
|
||||
t.Errorf("no-expiry = %q, want %q", got, "host|📊42.00GB")
|
||||
}
|
||||
|
||||
// A segment mixing an unlimited token with another value is kept whole,
|
||||
// decoration and ∞ included — only all-unlimited segments drop.
|
||||
mixed := expandCtx(model.Client{Email: "john"}, xray.ClientTraffic{Enable: true}, inbound)
|
||||
if got := expandRemarkVars("{{EMAIL}} 📊{{TRAFFIC_LEFT}}", mixed); got != "john 📊∞" {
|
||||
t.Errorf("mixed segment = %q, want %q", got, "john 📊∞")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
st xray.ClientTraffic
|
||||
want string
|
||||
}{
|
||||
{"disabled", xray.ClientTraffic{Enable: false}, "disabled"},
|
||||
{"active", xray.ClientTraffic{Enable: true}, "active"},
|
||||
{"expired", xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "expired"}, // 1s past epoch
|
||||
{"depleted", xray.ClientTraffic{Enable: true, Total: gb, Up: gb}, "depleted"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := clientStatus(c.st); got != c.want {
|
||||
t.Errorf("%s: clientStatus = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hostRemarkService builds a SubService + inbound + client/stats for remark tests.
|
||||
func hostRemarkService(template string) (*SubService, *model.Inbound, model.Client) {
|
||||
s := &SubService{remarkTemplate: template, subscriptionBody: true}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
ClientStats: []xray.ClientTraffic{{
|
||||
Email: "john@example.com",
|
||||
Enable: true,
|
||||
Total: 100 * gb,
|
||||
Up: 15 * gb,
|
||||
Down: 5 * gb,
|
||||
ExpiryTime: -864_000_000, // delayed-start: deterministic 10 days
|
||||
}},
|
||||
}
|
||||
client := model.Client{Email: "john@example.com"}
|
||||
return s, inbound, client
|
||||
}
|
||||
|
||||
// The config name prefers the host endpoint's own remark; the inbound's remark is
|
||||
// the fallback, used only when the host has none.
|
||||
func TestGenHostRemark_ConfigNameHostWins(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("") // no template → config name only
|
||||
if got := s.genHostRemark(inbound, client, "Relay"); got != "Relay" {
|
||||
t.Fatalf("genHostRemark = %q, want %q (host remark wins)", got, "Relay")
|
||||
}
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q (inbound fallback)", got, "DE")
|
||||
}
|
||||
}
|
||||
|
||||
// In the body the template applies: {{INBOUND}} is the config name (host remark
|
||||
// first, inbound fallback) and {{HOST}} is always the host's own remark.
|
||||
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
||||
// Host remark set → {{INBOUND}} resolves to it (host wins over the inbound).
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN | 80.00GB | 10d" {
|
||||
t.Fatalf("global template (host wins) = %q", got)
|
||||
}
|
||||
// No host remark → {{INBOUND}} falls back to the inbound's own remark.
|
||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}}")
|
||||
if got := s2.genHostRemark(inbound2, client2, ""); got != "DE | 80.00GB" {
|
||||
t.Fatalf("global template (inbound fallback) = %q", got)
|
||||
}
|
||||
// {{HOST}} is the host's own remark even when the inbound has one of its own.
|
||||
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
|
||||
if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
|
||||
t.Fatalf("{{HOST}} token = %q, want CDN", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A global template also drives non-host links via genRemark; {{HOST}} = the
|
||||
// legacy externalProxy remark passed as extra.
|
||||
func TestGenRemark_GlobalTemplate(t *testing.T) {
|
||||
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
|
||||
got := s.genRemark(inbound, "john@example.com", "")
|
||||
if got != "john@example.com | 80.00GB" {
|
||||
t.Fatalf("global template (non-host) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no template, genRemark composes the fallback model and adds no suffix.
|
||||
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
|
||||
s, inbound, _ := hostRemarkService("")
|
||||
got := s.genRemark(inbound, "john@example.com", "Relay")
|
||||
if got != "DE-Relay" {
|
||||
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
|
||||
}
|
||||
}
|
||||
|
||||
// The per-client info part of the template renders only on a client's first
|
||||
// link of the request; later links show the name-only template.
|
||||
func TestUsageOnFirstLinkOnly(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
first := s.genHostRemark(inbound, client, "")
|
||||
second := s.genHostRemark(inbound, client, "")
|
||||
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
|
||||
t.Fatalf("first link should carry usage: %q", first)
|
||||
}
|
||||
if strings.ContainsAny(second, "📊⏳") {
|
||||
t.Fatalf("second link must not carry usage: %q", second)
|
||||
}
|
||||
if second != "DE" {
|
||||
t.Fatalf("second link = %q, want name-only %q", second, "DE")
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the subscription body (panel link/QR displays, sub info page) the
|
||||
// template is bypassed entirely — links show just the config name, with no
|
||||
// per-client email or usage info.
|
||||
func TestRemarkInDisplayContext(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
s.subscriptionBody = false
|
||||
// A host link in a display shows only the config name — host remark wins, with
|
||||
// no per-client email or usage info.
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "CDN" {
|
||||
t.Fatalf("display host link = %q, want config name %q (host wins)", got, "CDN")
|
||||
}
|
||||
// With no host remark, the config name is the inbound's own remark.
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
|
||||
}
|
||||
// genRemark (non-host) likewise drops the template in display context.
|
||||
if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, "DE")
|
||||
}
|
||||
}
|
||||
|
||||
// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
|
||||
func TestNameOnlyTemplate(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // the default → name only
|
||||
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
|
||||
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
|
||||
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
|
||||
"{{TRAFFIC_LEFT}}": "", // info only → empty
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := nameOnlyTemplate(tmpl); got != want {
|
||||
t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two clients through the same global template get distinct, per-client remarks.
|
||||
func TestGenHostRemark_PerClient(t *testing.T) {
|
||||
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
|
||||
inbound := &model.Inbound{}
|
||||
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
|
||||
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
|
||||
if a != "alice@x" || b != "bob@x" {
|
||||
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
|
||||
}
|
||||
}
|
||||
+97
-143
@@ -28,10 +28,18 @@ import (
|
||||
// SubService provides business logic for generating subscription links and managing subscription data.
|
||||
type SubService struct {
|
||||
address string
|
||||
showInfo bool
|
||||
remarkModel string
|
||||
remarkTemplate string
|
||||
datepicker string
|
||||
emailInRemark bool
|
||||
// subscriptionBody is true only when rendering the actual subscription
|
||||
// content a client app imports (raw /sub fetch, /json, /clash). The remark
|
||||
// template's per-client info is emitted there (on the first link); every
|
||||
// other context — the sub info page, the panel's link/QR displays — renders
|
||||
// the name-only template, like Remnawave.
|
||||
subscriptionBody bool
|
||||
// usageShown tracks, per client email, whether the info part of the template
|
||||
// has already been emitted this request, so it appears on the first body
|
||||
// link only. Per-request state; reset in PrepareForRequest.
|
||||
usageShown map[string]bool
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
// nodesByID is populated per request from the Node table so
|
||||
@@ -42,10 +50,9 @@ type SubService struct {
|
||||
}
|
||||
|
||||
// NewSubService creates a new subscription service with the given configuration.
|
||||
func NewSubService(showInfo bool, remarkModel string) *SubService {
|
||||
func NewSubService(remarkTemplate string) *SubService {
|
||||
return &SubService{
|
||||
showInfo: showInfo,
|
||||
remarkModel: remarkModel,
|
||||
remarkTemplate: remarkTemplate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,24 +77,21 @@ func (s *SubService) PrepareForRequest(host string) {
|
||||
}
|
||||
}
|
||||
s.address = host
|
||||
s.usageShown = map[string]bool{}
|
||||
s.loadNodes()
|
||||
s.loadRemarkSettings()
|
||||
}
|
||||
|
||||
// loadRemarkSettings populates the per-request remark formatting state so
|
||||
// every subscription format — raw, JSON, Clash — renders remarks the same
|
||||
// way. genRemark reads emailInRemark and the date formatter reads datepicker;
|
||||
// loading these only in getSubs left JSON/Clash with the zero values.
|
||||
// every subscription format — raw, JSON, Clash — renders remarks the same way
|
||||
// (the date formatter reads datepicker). Loading it only in getSubs left
|
||||
// JSON/Clash with the zero value.
|
||||
func (s *SubService) loadRemarkSettings() {
|
||||
var err error
|
||||
s.datepicker, err = s.settingService.GetDatepicker()
|
||||
if err != nil {
|
||||
s.datepicker = "gregorian"
|
||||
}
|
||||
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
|
||||
if err != nil {
|
||||
s.emailInRemark = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubService) configuredPublicHost() string {
|
||||
@@ -191,11 +195,20 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
continue
|
||||
}
|
||||
s.projectThroughFallbackMaster(inbound)
|
||||
// Host overrides apply AFTER fallback projection so a host's
|
||||
// address/TLS wins over the projected master stream.
|
||||
hostEps := s.hostEndpoints(inbound, "raw")
|
||||
for _, client := range clients {
|
||||
if client.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
result = append(result, s.GetLink(inbound, client.Email))
|
||||
var link string
|
||||
if len(hostEps) > 0 {
|
||||
link = s.linkFromHosts(inbound, client, hostEps)
|
||||
} else {
|
||||
link = s.GetLink(inbound, client.Email)
|
||||
}
|
||||
result = append(result, link)
|
||||
emails = append(emails, client.Email)
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
}
|
||||
@@ -584,7 +597,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.genRemark(inbound, email, ep["remark"].(string))
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -635,7 +648,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.genRemark(inbound, email, ep["remark"].(string))
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -691,6 +704,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
applyShareTLSParams(stream, params)
|
||||
}
|
||||
|
||||
// SIP002 clients (v2rayN) ignore the xray-native type/headerType/host/path
|
||||
// params and only read `plugin`. Re-encode a TCP http header as obfs-local so
|
||||
// they build a matching tcp/http outbound (v2rayN forces request path "/").
|
||||
if streamNetwork == "tcp" && params["headerType"] == "http" {
|
||||
host := params["host"]
|
||||
delete(params, "type")
|
||||
delete(params, "headerType")
|
||||
delete(params, "host")
|
||||
delete(params, "path")
|
||||
params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host
|
||||
}
|
||||
|
||||
encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)
|
||||
if method[0] == '2' {
|
||||
encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password)
|
||||
@@ -709,7 +734,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.genRemark(inbound, email, ep["remark"].(string))
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -814,13 +839,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
if dest == "" || !okPort {
|
||||
continue
|
||||
}
|
||||
epRemark, _ := ep["remark"].(string)
|
||||
|
||||
epParams := cloneStringMap(params)
|
||||
applyExternalProxyHysteriaParams(ep, epParams)
|
||||
|
||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
|
||||
links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
|
||||
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
@@ -1326,6 +1349,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
||||
}
|
||||
settings["echConfigList"] = ech
|
||||
}
|
||||
if ai, ok := ep["allowInsecure"].(bool); ok && ai {
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["allowInsecure"] = true
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||
@@ -1432,30 +1463,16 @@ func joinAnyStrings(items []any) string {
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
|
||||
// externalProxy entries to []ShareEndpoint and renders them through the unified
|
||||
// endpoint path. Kept so genVmessLink's call site is unchanged.
|
||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
var links strings.Builder
|
||||
for index, externalProxy := range externalProxies {
|
||||
eps := make([]ShareEndpoint, 0, len(externalProxies))
|
||||
for _, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
newSecurity, _ := ep["forceTls"].(string)
|
||||
securityToApply := baseObj["tls"].(string)
|
||||
if newSecurity != "same" {
|
||||
securityToApply = newSecurity
|
||||
}
|
||||
newObj := cloneVmessShareObj(baseObj, newSecurity)
|
||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
|
||||
newObj["add"] = ep["dest"].(string)
|
||||
newObj["port"] = int(ep["port"].(float64))
|
||||
|
||||
if newSecurity != "same" {
|
||||
newObj["tls"] = newSecurity
|
||||
}
|
||||
applyExternalProxyTLSObj(ep, newObj, securityToApply)
|
||||
if index > 0 {
|
||||
links.WriteString("\n")
|
||||
}
|
||||
links.WriteString(buildVmessLink(newObj))
|
||||
eps = append(eps, externalProxyToEndpoint(ep))
|
||||
}
|
||||
return links.String()
|
||||
return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
|
||||
}
|
||||
|
||||
// buildLinkWithParams appends ?query and #fragment to a pre-built
|
||||
@@ -1512,6 +1529,9 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildExternalProxyURLLinks is a thin adapter: it maps the legacy externalProxy
|
||||
// entries to []ShareEndpoint and renders them through the unified endpoint path.
|
||||
// Kept so the genVless/genTrojan/genShadowsocks call sites are unchanged.
|
||||
func (s *SubService) buildExternalProxyURLLinks(
|
||||
externalProxies []any,
|
||||
params map[string]string,
|
||||
@@ -1519,33 +1539,14 @@ func (s *SubService) buildExternalProxyURLLinks(
|
||||
makeLink func(dest string, port int) string,
|
||||
makeRemark func(ep map[string]any) string,
|
||||
) string {
|
||||
links := make([]string, 0, len(externalProxies))
|
||||
eps := make([]ShareEndpoint, 0, len(externalProxies))
|
||||
for _, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
newSecurity, _ := ep["forceTls"].(string)
|
||||
dest, _ := ep["dest"].(string)
|
||||
port := int(ep["port"].(float64))
|
||||
|
||||
securityToApply := baseSecurity
|
||||
if newSecurity != "same" {
|
||||
securityToApply = newSecurity
|
||||
}
|
||||
|
||||
nextParams := cloneStringMap(params)
|
||||
applyExternalProxyTLSParams(ep, nextParams, securityToApply)
|
||||
|
||||
links = append(
|
||||
links,
|
||||
buildLinkWithParamsAndSecurity(
|
||||
makeLink(dest, port),
|
||||
nextParams,
|
||||
makeRemark(ep),
|
||||
securityToApply,
|
||||
newSecurity == "none",
|
||||
),
|
||||
)
|
||||
eps = append(eps, externalProxyToEndpoint(ep))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
return s.buildEndpointLinks(eps, params, baseSecurity, makeLink, func(e ShareEndpoint) string {
|
||||
return makeRemark(e.ep)
|
||||
})
|
||||
}
|
||||
|
||||
func cloneStringMap(source map[string]string) map[string]string {
|
||||
@@ -1554,89 +1555,42 @@ func cloneStringMap(source map[string]string) map[string]string {
|
||||
return cloned
|
||||
}
|
||||
|
||||
// genRemark builds the remark for a non-host link (raw default / legacy
|
||||
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
|
||||
// remark template takes over; otherwise (and in every display context) the
|
||||
// remark is just the config name (inbound remark, then extra).
|
||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
||||
separationChar := string(s.remarkModel[0])
|
||||
orderChars := s.remarkModel[1:]
|
||||
orders := map[byte]string{
|
||||
'i': "",
|
||||
'e': "",
|
||||
'o': "",
|
||||
}
|
||||
if len(email) > 0 && s.emailInRemark {
|
||||
orders['e'] = email
|
||||
}
|
||||
if len(inbound.Remark) > 0 {
|
||||
orders['i'] = inbound.Remark
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
orders['o'] = extra
|
||||
if s.remarkTemplate != "" && s.subscriptionBody {
|
||||
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
|
||||
}
|
||||
// Sub info page + panel link/QR displays: just the config name (no template,
|
||||
// so no per-client email/usage leaks into the shown remark).
|
||||
return fallbackRemark(inbound.Remark, extra)
|
||||
}
|
||||
|
||||
var remark []string
|
||||
for i := 0; i < len(orderChars); i++ {
|
||||
char := orderChars[i]
|
||||
order, exists := orders[char]
|
||||
if exists && order != "" {
|
||||
remark = append(remark, order)
|
||||
// fallbackRemark is the minimal remark used only when no template is configured
|
||||
// (an operator explicitly cleared it): the inbound remark and the host/extra
|
||||
// remark joined by "-", skipping empties. The configurable remark model was
|
||||
// removed in favour of the template, whose default already includes the email.
|
||||
func fallbackRemark(inboundRemark, extra string) string {
|
||||
switch {
|
||||
case inboundRemark == "":
|
||||
return extra
|
||||
case extra == "":
|
||||
return inboundRemark
|
||||
default:
|
||||
return inboundRemark + "-" + extra
|
||||
}
|
||||
}
|
||||
|
||||
// findClientStats returns the inbound's traffic record for email, if present.
|
||||
func (s *SubService) findClientStats(inbound *model.Inbound, email string) (xray.ClientTraffic, bool) {
|
||||
for _, clientStat := range inbound.ClientStats {
|
||||
if clientStat.Email == email {
|
||||
return clientStat, true
|
||||
}
|
||||
}
|
||||
|
||||
if s.showInfo {
|
||||
statsExist := false
|
||||
var stats xray.ClientTraffic
|
||||
for _, clientStat := range inbound.ClientStats {
|
||||
if clientStat.Email == email {
|
||||
stats = clientStat
|
||||
statsExist = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Get remained days
|
||||
if statsExist {
|
||||
if !stats.Enable {
|
||||
return fmt.Sprintf("⛔️N/A%s%s", separationChar, strings.Join(remark, separationChar))
|
||||
}
|
||||
if vol := stats.Total - (stats.Up + stats.Down); vol > 0 {
|
||||
remark = append(remark, fmt.Sprintf("%s%s", common.FormatTraffic(vol), "📊"))
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
switch exp := stats.ExpiryTime / 1000; {
|
||||
case exp > 0:
|
||||
remainingSeconds := exp - now
|
||||
days := remainingSeconds / 86400
|
||||
hours := (remainingSeconds % 86400) / 3600
|
||||
minutes := (remainingSeconds % 3600) / 60
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
remark = append(remark, fmt.Sprintf("%dD,%dH⏳", days, hours))
|
||||
} else {
|
||||
remark = append(remark, fmt.Sprintf("%dD⏳", days))
|
||||
}
|
||||
} else if hours > 0 {
|
||||
remark = append(remark, fmt.Sprintf("%dH⏳", hours))
|
||||
} else {
|
||||
remark = append(remark, fmt.Sprintf("%dM⏳", minutes))
|
||||
}
|
||||
case exp < 0:
|
||||
days := exp / -86400
|
||||
hours := (exp % -86400) / 3600
|
||||
minutes := (exp % -3600) / 60
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
remark = append(remark, fmt.Sprintf("%dD,%dH⏳", days, hours))
|
||||
} else {
|
||||
remark = append(remark, fmt.Sprintf("%dD⏳", days))
|
||||
}
|
||||
} else if hours > 0 {
|
||||
remark = append(remark, fmt.Sprintf("%dH⏳", hours))
|
||||
} else {
|
||||
remark = append(remark, fmt.Sprintf("%dM⏳", minutes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(remark, separationChar)
|
||||
return xray.ClientTraffic{}, false
|
||||
}
|
||||
|
||||
func searchKey(data any, key string) (any, bool) {
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
|
||||
t.Fatalf("seed client_inbound: %v", err)
|
||||
}
|
||||
|
||||
s := NewSubService(false, "-ieo")
|
||||
s := NewSubService("")
|
||||
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
|
||||
@@ -66,7 +66,7 @@ const xhttpRealityStream = `{
|
||||
}`
|
||||
|
||||
func TestGenVlessLink_FlowXhttpRealityVlessenc(t *testing.T) {
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(flowTestInbound(xhttpRealityStream, testMlkemEncryption), "user")
|
||||
if !strings.Contains(link, "flow=xtls-rprx-vision") {
|
||||
t.Fatalf("xhttp+reality+vlessenc link must carry the vision flow (#5232), got %q", link)
|
||||
@@ -74,7 +74,7 @@ func TestGenVlessLink_FlowXhttpRealityVlessenc(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenVlessLink_NoFlowXhttpRealityWithoutVlessenc(t *testing.T) {
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(flowTestInbound(xhttpRealityStream, "none"), "user")
|
||||
if strings.Contains(link, "flow=") {
|
||||
t.Fatalf("xhttp+reality without vlessenc must not carry a flow, got %q", link)
|
||||
@@ -92,7 +92,7 @@ func TestGenVlessLink_FlowTcpRealityStillWorks(t *testing.T) {
|
||||
"settings": {"publicKey": "pub", "fingerprint": "chrome"}
|
||||
}
|
||||
}`
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(flowTestInbound(stream, "none"), "user")
|
||||
if !strings.Contains(link, "flow=xtls-rprx-vision") {
|
||||
t.Fatalf("tcp+reality link must keep the vision flow, got %q", link)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
|
||||
"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
|
||||
}
|
||||
}`
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(shareLinkInbound(stream), "user")
|
||||
|
||||
// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
|
||||
@@ -66,7 +66,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
||||
}
|
||||
}`
|
||||
s := &SubService{remarkModel: "-ieo"}
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(shareLinkInbound(stream), "user")
|
||||
|
||||
wants := []string{
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s := NewSubService(false, "-ieo")
|
||||
s := NewSubService("")
|
||||
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs: %v", err)
|
||||
|
||||
@@ -32,8 +32,7 @@ func TestSubscriptionExpiryFromClient(t *testing.T) {
|
||||
func TestGenRemarkOmitsNodeName(t *testing.T) {
|
||||
nodeID := 7
|
||||
s := &SubService{
|
||||
remarkModel: "-ieo",
|
||||
nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
|
||||
nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
|
||||
}
|
||||
ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
|
||||
if got := s.genRemark(ib, "", ""); got != "vless-tcp" {
|
||||
|
||||
+3
-8
@@ -105,14 +105,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ShowInfo, err := s.settingService.GetSubShowInfo()
|
||||
RemarkTemplate, err := s.settingService.GetRemarkTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
RemarkModel, err := s.settingService.GetRemarkModel()
|
||||
if err != nil {
|
||||
RemarkModel = "-io"
|
||||
RemarkTemplate = ""
|
||||
}
|
||||
|
||||
SubUpdates, err := s.settingService.GetSubUpdates()
|
||||
@@ -230,7 +225,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
g := engine.Group("/")
|
||||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
|
||||
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type APIController struct {
|
||||
inboundController *InboundController
|
||||
serverController *ServerController
|
||||
nodeController *NodeController
|
||||
hostController *HostController
|
||||
settingController *SettingController
|
||||
xraySettingController *XraySettingController
|
||||
settingService service.SettingService
|
||||
@@ -95,6 +96,10 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
nodes := api.Group("/nodes")
|
||||
a.nodeController = NewNodeController(nodes)
|
||||
|
||||
// Hosts API — per-inbound override endpoints for subscription links
|
||||
hosts := api.Group("/hosts")
|
||||
a.hostController = NewHostController(hosts)
|
||||
|
||||
// Settings + Xray config management live under the API surface too, so the
|
||||
// same API token drives them. Paths are /panel/api/setting/* and
|
||||
// /panel/api/xray/*.
|
||||
|
||||
@@ -95,6 +95,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||
basePath = "/panel/api/server"
|
||||
case "node.go":
|
||||
basePath = "/panel/api/nodes"
|
||||
case "host.go":
|
||||
basePath = "/panel/api/hosts"
|
||||
case "setting.go":
|
||||
basePath = "/panel/api/setting"
|
||||
case "xray_setting.go":
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HostController exposes CRUD + ordering for Host override endpoints under
|
||||
// /panel/api/hosts. Thin HTTP layer over HostService; mirrors NodeController.
|
||||
type HostController struct {
|
||||
hostService service.HostService
|
||||
}
|
||||
|
||||
func NewHostController(g *gin.RouterGroup) *HostController {
|
||||
a := &HostController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *HostController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/byInbound/:inboundId", a.byInbound)
|
||||
g.GET("/tags", a.tags)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
g.POST("/reorder", a.reorder)
|
||||
g.POST("/bulk/setEnable", a.bulkSetEnable)
|
||||
g.POST("/bulk/del", a.bulkDel)
|
||||
}
|
||||
|
||||
func (a *HostController) list(c *gin.Context) {
|
||||
hosts, err := a.hostService.GetHosts()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, hosts, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, err := a.hostService.GetHost(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, h, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) byInbound(c *gin.Context) {
|
||||
inboundId, err := strconv.Atoi(c.Param("inboundId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
hosts, err := a.hostService.GetHostsByInbound(inboundId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, hosts, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) tags(c *gin.Context) {
|
||||
tags, err := a.hostService.GetAllTags()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, tags, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) add(c *gin.Context) {
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := a.hostService.AddHost(h)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.add"), created, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := a.hostService.UpdateHost(id, h)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.update"), updated, nil)
|
||||
}
|
||||
|
||||
func (a *HostController) del(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHost(id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), nil)
|
||||
}
|
||||
|
||||
func (a *HostController) setEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
body := struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}{}
|
||||
if err := c.ShouldBind(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostEnable(id, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *HostController) reorder(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.ReorderHosts(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *HostController) bulkSetEnable(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostsEnable(req.Ids, req.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *HostController) bulkDel(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHosts(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), nil)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
func newHostTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
// I18nWeb logs a warning when the localizer is absent (as in tests); the
|
||||
// logger must be initialised so that warning does not nil-panic.
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
type hostEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj json.RawMessage `json:"obj"`
|
||||
}
|
||||
|
||||
func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any) hostEnvelope {
|
||||
t.Helper()
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s: status %d, body=%s", method, path, w.Code, w.Body.String())
|
||||
}
|
||||
var env hostEnvelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%s %s: decode envelope: %v body=%s", method, path, err, w.Body.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
|
||||
// the {success,msg,obj} envelope convention through the registered routes.
|
||||
func TestHostController_AddListGetDelete(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
NewHostController(engine.Group("/panel/api/hosts"))
|
||||
|
||||
ib := &model.Inbound{Tag: "ctl", Enable: true, Port: 5443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
// add
|
||||
add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
|
||||
"inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443,
|
||||
})
|
||||
if !add.Success {
|
||||
t.Fatalf("add not successful: %s", add.Msg)
|
||||
}
|
||||
var created model.Host
|
||||
if err := json.Unmarshal(add.Obj, &created); err != nil {
|
||||
t.Fatalf("decode created host: %v", err)
|
||||
}
|
||||
if created.Id == 0 || created.Remark != "h1" {
|
||||
t.Fatalf("created host = %+v", created)
|
||||
}
|
||||
|
||||
// list
|
||||
list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts []model.Host
|
||||
if err := json.Unmarshal(list.Obj, &hosts); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 || hosts[0].Id != created.Id {
|
||||
t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id)
|
||||
}
|
||||
|
||||
// get
|
||||
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
|
||||
if !get.Success {
|
||||
t.Fatalf("get not successful: %s", get.Msg)
|
||||
}
|
||||
|
||||
// del
|
||||
del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil)
|
||||
if !del.Success {
|
||||
t.Fatalf("del not successful: %s", del.Msg)
|
||||
}
|
||||
list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts2 []model.Host
|
||||
_ = json.Unmarshal(list2.Obj, &hosts2)
|
||||
if len(hosts2) != 0 {
|
||||
t.Fatalf("after delete, list = %+v, want empty", hosts2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostController_AuthInherited mirrors production wiring: the hosts group is
|
||||
// nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
|
||||
// to a hosts route is rejected (401) — the auth is inherited, not re-declared.
|
||||
func TestHostController_AuthInherited(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
store := cookie.NewStore([]byte("host-auth-test-secret"))
|
||||
engine.Use(sessions.Sessions("3x-ui", store))
|
||||
|
||||
a := &APIController{}
|
||||
api := engine.Group("/panel/api")
|
||||
api.Use(a.checkAPIAuth)
|
||||
NewHostController(api.Group("/hosts"))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
@@ -32,11 +32,11 @@ type AllSetting struct {
|
||||
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"` // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
|
||||
|
||||
// UI settings
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"` // Number of items per page in lists (0 disables pagination)
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
|
||||
RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
|
||||
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"` // Number of items per page in lists (0 disables pagination)
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"` // Subscription remark template ({{VAR}} tokens) rendered per client
|
||||
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||
|
||||
// Telegram bot settings
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
||||
@@ -86,8 +86,6 @@ type AllSetting struct {
|
||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
|
||||
SubEmailInRemark bool `json:"subEmailInRemark" form:"subEmailInRemark"` // Include email in subscription remark/name
|
||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
|
||||
@@ -193,6 +193,49 @@ func shadowsocksKeyBytes(method string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password
|
||||
// whose decoded length no longer matches settings.method, which happens after the
|
||||
// inbound method is switched between ciphers of different key sizes (e.g.
|
||||
// aes-256↔aes-128). A wrong-length uPSK makes xray reject the user, so the link
|
||||
// fails to connect; regenerating restores a valid key (clients must re-fetch).
|
||||
// Non-Shadowsocks / legacy-SS settings pass through unchanged.
|
||||
func normalizeShadowsocksClientKeys(settings string) (string, bool) {
|
||||
method := shadowsocksMethodFromSettings(settings)
|
||||
if shadowsocksKeyBytes(method) == 0 {
|
||||
return settings, false
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &m); err != nil {
|
||||
return settings, false
|
||||
}
|
||||
clients, ok := m["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
}
|
||||
changed := false
|
||||
for i := range clients {
|
||||
c, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if pw, _ := c["password"].(string); validShadowsocksClientKey(method, pw) {
|
||||
continue
|
||||
}
|
||||
c["password"] = randomShadowsocksClientKey(method)
|
||||
clients[i] = c
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
m["clients"] = clients
|
||||
bs, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(bs), true
|
||||
}
|
||||
|
||||
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
||||
method, _ := settings["method"].(string)
|
||||
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// HostService manages Host rows (override endpoints attached to an inbound).
|
||||
// Mirrors the empty-struct + database.GetDB() shape of ClientService.
|
||||
type HostService struct{}
|
||||
|
||||
// GetHosts returns every host, grouped by inbound then ordered by sort_order.
|
||||
func (s *HostService) GetHosts() ([]*model.Host, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error
|
||||
return hosts, err
|
||||
}
|
||||
|
||||
// GetHostsByInbound returns one inbound's hosts ordered by sort_order then id.
|
||||
func (s *HostService) GetHostsByInbound(inboundId int) ([]*model.Host, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Where("inbound_id = ?", inboundId).Order("sort_order asc, id asc").Find(&hosts).Error
|
||||
return hosts, err
|
||||
}
|
||||
|
||||
func (s *HostService) GetHost(id int) (*model.Host, error) {
|
||||
host := &model.Host{}
|
||||
if err := database.GetDB().First(host, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// AddHost creates a host after confirming its inbound exists (no hard FK).
|
||||
func (s *HostService) AddHost(host *model.Host) (*model.Host, error) {
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", host.InboundId).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, common.NewError("inbound not found")
|
||||
}
|
||||
host.Id = 0
|
||||
if err := db.Create(host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// UpdateHost overwrites a host's content. InboundId and SortOrder are immutable
|
||||
// here — the inbound is fixed at creation and ordering is owned by ReorderHosts.
|
||||
func (s *HostService) UpdateHost(id int, host *model.Host) (*model.Host, error) {
|
||||
db := database.GetDB()
|
||||
existing := &model.Host{}
|
||||
if err := db.First(existing, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host.Id = id
|
||||
host.InboundId = existing.InboundId
|
||||
host.SortOrder = existing.SortOrder
|
||||
host.CreatedAt = existing.CreatedAt
|
||||
if err := db.Save(host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetHost(id)
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHost(id int) error {
|
||||
return database.GetDB().Delete(&model.Host{}, id).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostEnable(id int, enable bool) error {
|
||||
return database.GetDB().Model(&model.Host{}).Where("id = ?", id).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostsEnable(ids []int, enable bool) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(&model.Host{}).Where("id IN ?", ids).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHosts(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("id IN ?", ids).Delete(&model.Host{}).Error
|
||||
}
|
||||
|
||||
// ReorderHosts assigns sort_order by the position of each id in ids, in a single
|
||||
// transaction (driver-safe on SQLite and Postgres).
|
||||
func (s *HostService) ReorderHosts(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx := database.GetDB().Begin()
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Host{}).Where("id = ?", id).Update("sort_order", i).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// GetAllTags returns the distinct, sorted set of tags across all hosts.
|
||||
func (s *HostService) GetAllTags() ([]string, error) {
|
||||
hosts, err := s.GetHosts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]struct{})
|
||||
for _, h := range hosts {
|
||||
for _, tag := range h.Tags {
|
||||
if tag != "" {
|
||||
set[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for tag := range set {
|
||||
out = append(out, tag)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *model.Host {
|
||||
t.Helper()
|
||||
h, err := svc.AddHost(&model.Host{
|
||||
InboundId: inboundId,
|
||||
Remark: remark,
|
||||
SortOrder: order,
|
||||
Address: remark + ".example.com",
|
||||
Port: 8443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddHost %s: %v", remark, err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// TestAddHost_GetHostsByInbound: create persists; query returns by inbound,
|
||||
// ordered by sort_order then id.
|
||||
func TestAddHost_GetHostsByInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
h1 := mkHost(t, svc, ib.Id, "b", 2)
|
||||
h2 := mkHost(t, svc, ib.Id, "a", 1)
|
||||
|
||||
got, err := svc.GetHostsByInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostsByInbound: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Id != h2.Id || got[1].Id != h1.Id {
|
||||
t.Fatalf("order = [%d,%d], want [%d,%d] (sort_order asc)", got[0].Id, got[1].Id, h2.Id, h1.Id)
|
||||
}
|
||||
if got[0].Address != "a.example.com" {
|
||||
t.Fatalf("address not persisted: %q", got[0].Address)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddHost_RejectsUnknownInbound: a host whose inbound does not exist is refused.
|
||||
func TestAddHost_RejectsUnknownInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: 99999, Remark: "x"}); err == nil {
|
||||
t.Fatalf("expected error adding host to unknown inbound")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReorderHosts: reorder updates sort_order and re-query reflects new order.
|
||||
func TestReorderHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 0)
|
||||
h3 := mkHost(t, svc, ib.Id, "h3", 0)
|
||||
|
||||
want := []int{h3.Id, h1.Id, h2.Id}
|
||||
if err := svc.ReorderHosts(want); err != nil {
|
||||
t.Fatalf("ReorderHosts: %v", err)
|
||||
}
|
||||
got, _ := svc.GetHostsByInbound(ib.Id)
|
||||
for i, h := range got {
|
||||
if h.Id != want[i] {
|
||||
t.Fatalf("position %d = %d, want %d", i, h.Id, want[i])
|
||||
}
|
||||
if h.SortOrder != i {
|
||||
t.Fatalf("host %d sort_order = %d, want %d", h.Id, h.SortOrder, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetHostEnableAndBulk: per-row and bulk enable/disable toggles persist.
|
||||
func TestSetHostEnableAndBulk(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 1)
|
||||
|
||||
if err := svc.SetHostEnable(h1.Id, false); err != nil {
|
||||
t.Fatalf("SetHostEnable: %v", err)
|
||||
}
|
||||
if g, _ := svc.GetHost(h1.Id); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("h1 should be disabled after SetHostEnable(false)")
|
||||
}
|
||||
|
||||
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, true); err != nil {
|
||||
t.Fatalf("SetHostsEnable(true): %v", err)
|
||||
}
|
||||
for _, id := range []int{h1.Id, h2.Id} {
|
||||
if g, _ := svc.GetHost(id); g == nil || g.IsDisabled {
|
||||
t.Fatalf("host %d should be enabled", id)
|
||||
}
|
||||
}
|
||||
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, false); err != nil {
|
||||
t.Fatalf("SetHostsEnable(false): %v", err)
|
||||
}
|
||||
for _, id := range []int{h1.Id, h2.Id} {
|
||||
if g, _ := svc.GetHost(id); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("host %d should be disabled", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteHosts: bulk delete removes exactly the named rows.
|
||||
func TestDeleteHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 1)
|
||||
h3 := mkHost(t, svc, ib.Id, "h3", 2)
|
||||
|
||||
if err := svc.DeleteHosts([]int{h1.Id, h3.Id}); err != nil {
|
||||
t.Fatalf("DeleteHosts: %v", err)
|
||||
}
|
||||
got, _ := svc.GetHostsByInbound(ib.Id)
|
||||
if len(got) != 1 || got[0].Id != h2.Id {
|
||||
t.Fatalf("remaining = %v, want only h2 (%d)", got, h2.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteInboundCascadesHosts: deleting an inbound deletes its hosts.
|
||||
func TestDeleteInboundCascadesHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
inboundSvc := &InboundService{}
|
||||
// Disabled local inbound so DelInbound skips the runtime push.
|
||||
ib := &model.Inbound{Tag: "casc", Enable: false, Port: 4443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
mkHost(t, svc, ib.Id, "h1", 0)
|
||||
mkHost(t, svc, ib.Id, "h2", 1)
|
||||
|
||||
if _, err := inboundSvc.DelInbound(ib.Id); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
got, _ := svc.GetHostsByInbound(ib.Id)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("hosts not cascaded on inbound delete, len = %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAllTags: distinct, sorted tags across all hosts.
|
||||
func TestGetAllTags(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h1", Tags: []string{"EU", "CDN"}}); err != nil {
|
||||
t.Fatalf("AddHost: %v", err)
|
||||
}
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h2", Tags: []string{"CDN", "FAST"}}); err != nil {
|
||||
t.Fatalf("AddHost: %v", err)
|
||||
}
|
||||
tags, err := svc.GetAllTags()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllTags: %v", err)
|
||||
}
|
||||
want := []string{"CDN", "EU", "FAST"}
|
||||
if len(tags) != len(want) {
|
||||
t.Fatalf("tags = %v, want %v", tags, want)
|
||||
}
|
||||
for i := range want {
|
||||
if tags[i] != want[i] {
|
||||
t.Fatalf("tags = %v, want %v", tags, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user