refactor(inbounds): drop the single-user shadowsocks inbound paths

isSSMultiUser treated 2022-blake3-chacha20-poly1305 as a single-user
inbound, and the inbounds page grew a row QR modal, a separate link
panel in the info modal and a client-count skip for that case. The
method is no longer allowed on inbounds, so every shadowsocks inbound
carries clients and those paths cannot be reached: remove them along
with the inbound-level QrCodeModal they alone opened.
This commit is contained in:
MHSanaei
2026-10-11 21:24:25 +02:00
parent f1ed1a9b17
commit c20082d254
15 changed files with 9 additions and 533 deletions
+2 -5
View File
@@ -1387,11 +1387,8 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
}
// Returns the client array for protocols that have one. SS returns its
// clients only in 2022-blake3 multi-user mode (matches the legacy
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
// clients, and any protocol without a clients array.
// Returns the client array for protocols that have one; null for http, mixed,
// tunnel, wireguard, hysteria2-without-clients, and any protocol without one.
type ClientShape = {
id?: string;
uuid?: string;
+1 -11
View File
@@ -1,6 +1,6 @@
// Pure-function ports of the legacy Inbound class capability predicates
// (canEnableTls, canEnableReality, canEnableTlsFlow, canEnableStream,
// canEnableVisionSeed, isSS2022, isSSMultiUser). Each accepts the minimal
// canEnableVisionSeed, isSS2022). Each accepts the minimal
// slice of an InboundFormValues it needs, so the same predicate can be
// called against a partial-row, a full form value, or a hand-built test
// fixture without the caller projecting a whole object.
@@ -21,7 +21,6 @@ const STREAM_PROTOCOLS = [
];
const VISION_FLOW = 'xtls-rprx-vision';
const SS_2022_PREFIX = '2022';
const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
export interface CapabilityProtocolSlice {
protocol: string;
@@ -103,15 +102,6 @@ export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {
return clients.some((c) => c?.flow === VISION_FLOW);
}
// Why: legacy returns true on non-SS protocols too (the method getter
// resolves to "" and "" !== blake3-chacha20-poly1305). Preserved for
// parity with the legacy class; in practice the callers all narrow on
// protocol === shadowsocks before checking.
export function isSSMultiUser(values: CapabilityShadowsocksSlice): boolean {
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
return method !== SS_BLAKE3_CHACHA20;
}
export function isSS2022(values: CapabilityShadowsocksSlice): boolean {
const method = values.protocol === 'shadowsocks' ? (values.settings?.method ?? '') : '';
return method.substring(0, 4) === SS_2022_PREFIX;
@@ -51,7 +51,6 @@ import { LazyMount } from '@/components/utility';
const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
const CloneInboundModal = lazy(() => import('./CloneInboundModal'));
const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
@@ -60,7 +59,6 @@ const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupMod
type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
@@ -167,9 +165,6 @@ export default function InboundsPage() {
const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
const [infoClientIndex, setInfoClientIndex] = useState(0);
const [qrOpen, setQrOpen] = useState(false);
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
const [attachOpen, setAttachOpen] = useState(false);
const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
const [attachExistingOpen, setAttachExistingOpen] = useState(false);
@@ -213,7 +208,6 @@ export default function InboundsPage() {
() => hostOverrideFor(infoDbInbound),
[infoDbInbound, hostOverrideFor],
);
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
const openText = useCallback(
(opts: {
@@ -637,7 +631,6 @@ export default function InboundsPage() {
const hydratingKeys: RowAction[] = [
'edit',
'showInfo',
'qrcode',
'export',
'subs',
'clipboard',
@@ -659,10 +652,6 @@ export default function InboundsPage() {
setInfoClientIndex(findClientIndex(target, null));
setInfoOpen(true);
break;
case 'qrcode':
setQrDbInbound(checkFallback(target));
setQrOpen(true);
break;
case 'export':
exportInboundLinks(target);
break;
@@ -847,17 +836,6 @@ export default function InboundsPage() {
nodeAddress={infoNodeAddress}
/>
</LazyMount>
<LazyMount when={qrOpen}>
<QrCodeModal
open={qrOpen}
onClose={() => setQrOpen(false)}
dbInbound={qrDbInbound}
client={null}
nodeAddress={qrNodeAddress}
subSettings={subSettings}
hosts={hosts}
/>
</LazyMount>
<LazyMount when={attachOpen}>
<AttachClientsModal
open={attachOpen}
@@ -1302,28 +1302,6 @@ export default function InboundInfoModal({
))}
</>
)}
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => (
<div key={idx} className="link-panel">
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
aria-label={t('copy')}
onClick={() => copyText(link.link, t)}
/>
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
</div>
))}
</>
)}
</>
);
+1 -9
View File
@@ -2,11 +2,7 @@ import { getMessage } from '@/utils/messageBus';
import { ColorUtils, ClipboardManager, FileManager } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import { coerceInboundJsonField } from '@/models/dbinbound';
import {
canEnableTlsFlow,
isSS2022 as isSS2022Helper,
isSSMultiUser as isSSMultiUserHelper,
} from '@/lib/xray/protocol-capabilities';
import { canEnableTlsFlow, isSS2022 as isSS2022Helper } from '@/lib/xray/protocol-capabilities';
import type { ClientSetting, ClientStats, DBInboundLike, InboundInfo } from './types';
@@ -123,10 +119,6 @@ export function buildInboundInfo(dbInbound: DBInboundLike): InboundInfo {
isHttpupgrade: network === 'httpupgrade',
isXHTTP: network === 'xhttp',
isGrpc: network === 'grpc',
isSSMultiUser: isSSMultiUserHelper({
protocol: dbInbound.protocol,
settings: settings as { method?: string },
}),
isSS2022: isSS2022Helper({
protocol: dbInbound.protocol,
settings: settings as { method?: string },
@@ -36,7 +36,6 @@ export interface InboundInfo {
isHttpupgrade: boolean;
isXHTTP: boolean;
isGrpc: boolean;
isSSMultiUser: boolean;
isSS2022: boolean;
isVlessTlsFlow: boolean;
host: string | null;
@@ -3,7 +3,6 @@ import { Button, Dropdown, type MenuProps } from 'antd';
import {
MoreOutlined,
EditOutlined,
QrcodeOutlined,
CopyOutlined,
ExportOutlined,
RetweetOutlined,
@@ -15,7 +14,7 @@ import {
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { isInboundMultiUser, showQrCodeMenu } from './helpers';
import { isInboundMultiUser } from './helpers';
import type { DBInboundRecord, RowAction } from './types';
interface RowActionsMenuProps {
@@ -43,9 +42,6 @@ export function buildRowActionsMenu({
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
}
if (showQrCodeMenu(record)) {
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
}
if (isInboundMultiUser(record)) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
if (subEnable) {
+3 -12
View File
@@ -1,8 +1,7 @@
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { coerceInboundJsonField } from '@/models/dbinbound';
import type { HostRecord } from '@/schemas/api/host';
import type { DBInboundRecord, StreamHints } from './types';
import type { StreamHints } from './types';
export function readStreamHints(streamSettings: unknown): StreamHints {
const stream = coerceInboundJsonField(streamSettings) as { network?: string; security?: string };
@@ -82,11 +81,12 @@ export function readSettings(settings: unknown): {
};
}
export function isInboundMultiUser(record: { protocol: string; settings: unknown }): boolean {
export function isInboundMultiUser(record: { protocol: string }): boolean {
switch (record.protocol) {
case 'vmess':
case 'vless':
case 'trojan':
case 'shadowsocks':
case 'hysteria':
case 'mtproto':
case 'wireguard':
@@ -94,20 +94,11 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'tuic':
case 'masque':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
default:
return false;
}
}
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isSS) {
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
}
return false;
}
/** Max host remarks shown inline before truncating with "+N". */
export const HOST_REMARK_VISIBLE_LIMIT = 2;
@@ -55,7 +55,6 @@ export interface InboundSpeedEntry {
export type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
@@ -1,266 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Modal } from 'antd';
import type { CollapseProps } from 'antd';
import { Protocols } from '@/schemas/primitives';
import {
genAllLinks,
genAmneziaWGPeerConfigs,
genAmneziaWGPeerLinks,
genWireguardPeerConfigs,
genWireguardPeerLinks,
isPostQuantumLink,
preferPublicHost,
} from '@/lib/xray/inbound-link';
import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
import { withHostEndpoints } from '@/lib/hosts/host-link';
import type { HostRecord } from '@/schemas/api/host';
import QrPanel from './QrPanel';
import { peerConfFileName } from '../info/helpers';
import type { SubSettings } from '../useInbounds';
interface ClientSetting {
email?: string;
subId?: string;
[k: string]: unknown;
}
interface QrCodeModalProps {
open: boolean;
onClose: () => void;
dbInbound: (DbInboundLike & { id: number; remark?: string }) | null;
client?: ClientSetting | null;
nodeAddress?: string;
subSettings?: SubSettings;
hosts?: HostRecord[];
}
interface QrItem {
key: string;
header: string;
value: string;
downloadName?: string;
showQr?: boolean;
}
const EMPTY_HOSTS: HostRecord[] = [];
export default function QrCodeModal({
open,
onClose,
dbInbound,
client = null,
nodeAddress = '',
subSettings,
hosts = EMPTY_HOSTS,
}: QrCodeModalProps) {
const { t } = useTranslation();
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
const [wireguardConfigs, setWireguardConfigs] = useState<string[][]>([]);
const [wireguardLinks, setWireguardLinks] = useState<string[][]>([]);
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[][]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[][]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([]);
// Building the links is a pure function of the props, so it runs during
// render; an effect would paint the previous inbound's QR first.
const [syncedProps, setSyncedProps] = useState<{
dbInbound: typeof dbInbound;
client: typeof client;
nodeAddress: typeof nodeAddress;
subSettings: typeof subSettings;
hosts: typeof hosts;
} | null>(null);
if (
open &&
dbInbound &&
(syncedProps === null ||
syncedProps.dbInbound !== dbInbound ||
syncedProps.client !== client ||
syncedProps.nodeAddress !== nodeAddress ||
syncedProps.subSettings !== subSettings ||
syncedProps.hosts !== hosts)
) {
setSyncedProps({ dbInbound, client, nodeAddress, subSettings, hosts });
const fallbackHostname = preferPublicHost(
window.location.hostname,
subSettings?.publicHost ?? '',
);
const inbound = withHostEndpoints(
inboundFromDb(dbInbound),
dbInbound.id,
hosts,
nodeAddress,
fallbackHostname,
);
if (inbound.protocol === Protocols.WIREGUARD) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
: dbInbound.remark || '';
setWireguardConfigs(
genWireguardPeerConfigs({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setWireguardLinks(
genWireguardPeerLinks({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
setLinks([]);
} else if (inbound.protocol === Protocols.AMNEZIAWG) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
: dbInbound.remark || '';
setAmneziawgConfigs(
genAmneziaWGPeerConfigs({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setAmneziawgLinks(
genAmneziaWGPeerLinks({
inbound,
remark: peerRemark,
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setWireguardConfigs([]);
setWireguardLinks([]);
setLinks([]);
} else {
setLinks(
genAllLinks({
inbound,
remark: dbInbound.remark || '',
client: client ?? {},
hostOverride: nodeAddress,
fallbackHostname,
}),
);
setWireguardConfigs([]);
setWireguardLinks([]);
setAmneziawgConfigs([]);
setAmneziawgLinks([]);
}
const subId = client?.subId;
let nextSub = '';
let nextSubJson = '';
if (subSettings?.enable && subId) {
nextSub = (subSettings.subURI || '') + subId;
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
}
setSubLink(nextSub);
setSubJsonLink(nextSubJson);
}
const qrItems = useMemo<QrItem[]>(() => {
const items: QrItem[] = [];
if (subLink) {
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
}
if (subJsonLink) {
items.push({
key: 'sub-json',
header: `${t('subscription.title')} (JSON)`,
value: subJsonLink,
});
}
links.forEach((link, idx) => {
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
});
const pushTunnelPeers = (prefix: string, configs: string[][], peerLinks: string[][]) => {
configs.forEach((peerConfigs, idx) => {
peerConfigs.forEach((cfg, j) => {
const multi = peerConfigs.length > 1 ? ` #${j + 1}` : '';
items.push({
key: `${prefix}c${idx}-${j}`,
header: `Peer ${idx + 1} config${multi}`,
value: cfg,
downloadName: peerConfFileName(idx, j, peerConfigs.length),
});
const link = peerLinks[idx]?.[j];
if (link) {
items.push({
key: `${prefix}l${idx}-${j}`,
header: `Peer ${idx + 1} link${multi}`,
value: link,
showQr: false,
});
}
});
});
};
pushTunnelPeers('w', wireguardConfigs, wireguardLinks);
pushTunnelPeers('a', amneziawgConfigs, amneziawgLinks);
return items;
}, [
subLink,
subJsonLink,
links,
wireguardConfigs,
wireguardLinks,
amneziawgConfigs,
amneziawgLinks,
t,
]);
const collapseItems: CollapseProps['items'] = useMemo(
() =>
qrItems.map((item) => ({
key: item.key,
label: item.header,
children: (
<QrPanel
value={item.value}
remark={item.header}
downloadName={item.downloadName || ''}
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
/>
),
})),
[qrItems],
);
const firstKey = open && qrItems.length > 0 ? qrItems[0].key : null;
const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
if (firstKey !== syncedFirstKey) {
setSyncedFirstKey(firstKey);
setActiveKey(firstKey ? [firstKey] : []);
}
return (
<Modal
open={open}
onCancel={onClose}
title={t('qrCode')}
footer={null}
width={420}
destroyOnHidden
>
{dbInbound && collapseItems && collapseItems.length > 0 && (
<Collapse
ghost
activeKey={activeKey}
onChange={(keys) => setActiveKey(typeof keys === 'string' ? [keys] : (keys as string[]))}
items={collapseItems}
/>
)}
</Modal>
);
}
-1
View File
@@ -1,2 +1 @@
export { default as QrPanel } from './QrPanel';
export { default as QrCodeModal } from './QrCodeModal';
@@ -6,7 +6,6 @@ import { parseMsg } from '@/utils/zodValidate';
import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
import type { ClientStats, DBInboundInit } from '@/models/dbinbound';
import { Protocols } from '@/schemas/primitives';
import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { setDatepicker } from '@/hooks/useDatepicker';
import { keys } from '@/api/queryKeys';
import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
@@ -351,10 +350,8 @@ export function useInbounds() {
const protocol = dbInbound.protocol;
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
const settings = coerceInboundJsonField(dbInbound.settings) as {
method?: string;
clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
};
if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
}
return counts;
File diff suppressed because it is too large Load Diff
+1 -19
View File
@@ -8,7 +8,7 @@ import {
genWireguardLinks,
getInboundClients,
} from '@/lib/xray/inbound-link';
import { canEnableTlsFlow, isSS2022, isSSMultiUser } from '@/lib/xray/protocol-capabilities';
import { canEnableTlsFlow, isSS2022 } from '@/lib/xray/protocol-capabilities';
const FALLBACK_HOST = 'panel.example.test';
@@ -171,24 +171,6 @@ describe('inboundFromDb', () => {
});
describe('protocol-capability helpers with raw coerced shapes', () => {
it('isSSMultiUser returns true for legacy SS methods', () => {
expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(
true,
);
expect(
isSSMultiUser({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } }),
).toBe(true);
});
it('isSSMultiUser returns false for single-user blake3-chacha20 method', () => {
expect(
isSSMultiUser({
protocol: 'shadowsocks',
settings: { method: '2022-blake3-chacha20-poly1305' },
}),
).toBe(false);
});
it('isSS2022 detects 2022-blake3 family', () => {
expect(
isSS2022({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } }),
@@ -8,7 +8,6 @@ import {
canEnableStream,
canEnableVisionSeed,
isSS2022,
isSSMultiUser,
} from '@/lib/xray/protocol-capabilities';
// Pure-function tests for the capability predicates. Each fixture × stream
@@ -67,7 +66,6 @@ describe('protocol capability predicates', () => {
canEnableStream: canEnableStream(values),
canEnableVisionSeed: canEnableVisionSeed(values),
isSS2022: isSS2022(values),
isSSMultiUser: isSSMultiUser(values),
};
expect(result).toMatchSnapshot();
});