diff --git a/apps/admin/app/dashboard/servers/form-schema.ts b/apps/admin/app/dashboard/servers/form-schema.ts index 8dbea90..1d789d1 100644 --- a/apps/admin/app/dashboard/servers/form-schema.ts +++ b/apps/admin/app/dashboard/servers/form-schema.ts @@ -8,16 +8,42 @@ export const protocols = [ 'hysteria2', 'tuic', 'anytls', + 'socks', + 'naive', + 'http', + 'meru', ] as const; +// 字段配置类型 +export type FieldConfig = { + name: string; + type: 'input' | 'select' | 'switch' | 'number' | 'textarea'; + label: string; + placeholder?: string | ((t: (key: string) => string, protocol: any) => string); + options?: readonly string[]; + defaultValue?: any; + min?: number; + max?: number; + step?: number; + suffix?: string; + condition?: (protocol: any, values: any) => boolean; + group?: 'basic' | 'transport' | 'security' | 'reality' | 'plugin'; + gridSpan?: 1 | 2; +}; + // Global label map for display; fallback to raw value if missing export const LABELS = { // transport 'tcp': 'TCP', + 'udp': 'UDP', 'websocket': 'WebSocket', - 'http2': 'HTTP/2', - 'httpupgrade': 'HTTP Upgrade', 'grpc': 'gRPC', + 'mkcp': 'mKCP', + 'httpupgrade': 'HTTP Upgrade', + 'xhttp': 'XHTTP', + // vless flow + 'xtls-rprx-direct': 'XTLS-RPRX-Direct', + 'xtls-rprx-splice': 'XTLS-RPRX-Splice', 'xtls-rprx-vision': 'XTLS-RPRX-Vision', // security 'none': 'NONE', @@ -32,6 +58,14 @@ export const LABELS = { 'edge': 'edge', '360': '360', 'qq': 'QQ', + // multiplex + 'off': 'Off', + 'low': 'Low', + 'middle': 'Middle', + 'high': 'High', + // ss plugins + 'obfs': 'Simple Obfs', + 'v2ray-plugin': 'V2Ray Plugin', } as const; // Flat arrays for enum-like sets @@ -45,10 +79,13 @@ export const SS_CIPHERS = [ '2022-blake3-chacha20-poly1305', ] as const; +export const SS_PLUGINS = ['none', 'obfs', 'v2ray-plugin'] as const; + export const TRANSPORTS = { - vmess: ['tcp', 'websocket', 'grpc', 'httpupgrade'] as const, - vless: ['tcp', 'websocket', 'grpc', 'httpupgrade', 'http2'] as const, + vmess: ['tcp', 'websocket', 'grpc'] as const, + vless: ['tcp', 'websocket', 'grpc', 'mkcp', 'httpupgrade', 'xhttp'] as const, trojan: ['tcp', 'websocket', 'grpc'] as const, + meru: ['tcp', 'udp'] as const, } as const; export const SECURITY = { @@ -56,13 +93,15 @@ export const SECURITY = { vless: ['none', 'tls', 'reality'] as const, trojan: ['tls'] as const, hysteria2: ['tls'] as const, + naive: ['none', 'tls'] as const, + http: ['none', 'tls'] as const, } as const; export const FLOWS = { - vless: ['none', 'xtls-rprx-vision'] as const, + vless: ['none', 'xtls-rprx-direct', 'xtls-rprx-splice', 'xtls-rprx-vision'] as const, } as const; -export const TUIC_UDP_RELAY_MODES = ['native', 'quic', 'none'] as const; +export const TUIC_UDP_RELAY_MODES = ['native', 'quic'] as const; export const TUIC_CONGESTION = ['bbr', 'cubic', 'new_reno'] as const; export const FINGERPRINTS = [ 'chrome', @@ -75,6 +114,8 @@ export const FINGERPRINTS = [ 'qq', ] as const; +export const multiplexLevels = ['off', 'low', 'middle', 'high'] as const; + export function getLabel(value: string): string { return (LABELS as Record)[value] ?? value; } @@ -89,6 +130,8 @@ const ss = z.object({ port: nullablePort, cipher: z.enum(SS_CIPHERS as any).nullish(), server_key: nullableString, + plugin: z.enum(SS_PLUGINS as any).nullish(), + plugin_opts: nullableString, }); const vmess = z.object({ @@ -162,13 +205,43 @@ const tuic = z.object({ fingerprint: nullableString, }); +const socks = z.object({ + type: z.literal('socks'), + port: nullablePort, +}); + +const naive = z.object({ + type: z.literal('naive'), + port: nullablePort, + security: z.enum(SECURITY.naive as any).nullish(), + sni: nullableString, + allow_insecure: nullableBool, + fingerprint: nullableString, +}); + +const http = z.object({ + type: z.literal('http'), + port: nullablePort, + security: z.enum(SECURITY.http as any).nullish(), + sni: nullableString, + allow_insecure: nullableBool, + fingerprint: nullableString, +}); + +const meru = z.object({ + type: z.literal('meru'), + port: nullablePort, + multiplex: z.enum(multiplexLevels).nullish(), + transport: z.enum(TRANSPORTS.meru as any).nullish(), +}); + const anytls = z.object({ type: z.literal('anytls'), - host: nullableString, port: nullablePort, sni: nullableString, allow_insecure: nullableBool, fingerprint: nullableString, + padding_scheme: nullableString, }); export const protocolApiScheme = z.discriminatedUnion('type', [ @@ -179,6 +252,10 @@ export const protocolApiScheme = z.discriminatedUnion('type', [ hysteria2, tuic, anytls, + socks, + naive, + http, + meru, ]); export const formSchema = z.object({ @@ -202,6 +279,8 @@ export function getProtocolDefaultConfig(proto: ProtocolType) { port: null, cipher: 'chacha20-ietf-poly1305', server_key: null, + plugin: 'none', + plugin_opts: null, } as any; case 'vmess': return { type: 'vmess', port: null, transport: 'tcp', security: 'none' } as any; @@ -227,9 +306,587 @@ export function getProtocolDefaultConfig(proto: ProtocolType) { udp_relay_mode: 'native', congestion_controller: 'bbr', } as any; + case 'socks': + return { + type: 'socks', + port: null, + } as any; + case 'naive': + return { + type: 'naive', + port: null, + security: 'none', + } as any; + case 'http': + return { + type: 'http', + port: null, + security: 'none', + } as any; + case 'meru': + return { + type: 'meru', + port: null, + multiplex: 'off', + transport: 'tcp', + } as any; case 'anytls': - return { type: 'anytls', port: null } as any; + return { type: 'anytls', port: null, padding_scheme: null } as any; default: return {} as any; } } + +// 协议字段配置 +export const PROTOCOL_FIELDS: Record = { + shadowsocks: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'cipher', + type: 'select', + label: 'encryption_method', + options: SS_CIPHERS, + defaultValue: 'chacha20-ietf-poly1305', + group: 'basic', + }, + { + name: 'server_key', + type: 'input', + label: 'server_key', + group: 'basic', + condition: (p) => + [ + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305', + ].includes(p.cipher), + }, + { + name: 'plugin', + type: 'select', + label: 'plugin', + options: SS_PLUGINS, + defaultValue: 'none', + group: 'plugin', + }, + { + name: 'plugin_opts', + type: 'textarea', + label: 'plugin_opts', + placeholder: (t: (key: string) => string, p: any) => { + switch (p.plugin) { + case 'obfs': + return 'obfs=http;obfs-host=www.bing.com;path=/'; + case 'v2ray-plugin': + return 'WebSocket: mode=websocket;host=mydomain.me;path=/;tls=true\n\nQUIC: mode=quic;host=mydomain.me'; + default: + return 'key=value;key2=value2'; + } + }, + group: 'plugin', + condition: (p) => p.plugin !== 'none', + }, + ], + vmess: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'transport', + type: 'select', + label: 'transport', + options: TRANSPORTS.vmess, + defaultValue: 'tcp', + group: 'transport', + }, + { + name: 'security', + type: 'select', + label: 'security', + options: SECURITY.vmess, + defaultValue: 'none', + group: 'security', + }, + { + name: 'host', + type: 'input', + label: 'host', + group: 'transport', + condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport), + }, + { + name: 'path', + type: 'input', + label: 'path', + group: 'transport', + condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport), + }, + { + name: 'service_name', + type: 'input', + label: 'service_name', + group: 'transport', + condition: (p) => p.transport === 'grpc', + }, + { + name: 'sni', + type: 'input', + label: 'security_sni', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'allow_insecure', + type: 'switch', + label: 'security_allow_insecure', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + condition: (p) => p.security !== 'none', + }, + ], + vless: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'flow', + type: 'select', + label: 'flow', + options: FLOWS.vless, + defaultValue: 'none', + group: 'basic', + }, + { + name: 'transport', + type: 'select', + label: 'transport', + options: TRANSPORTS.vless, + defaultValue: 'tcp', + group: 'transport', + }, + { + name: 'security', + type: 'select', + label: 'security', + options: SECURITY.vless, + defaultValue: 'none', + group: 'security', + }, + { + name: 'host', + type: 'input', + label: 'host', + group: 'transport', + condition: (p) => ['websocket', 'mkcp', 'httpupgrade', 'xhttp'].includes(p.transport), + }, + { + name: 'path', + type: 'input', + label: 'path', + group: 'transport', + condition: (p) => ['websocket', 'mkcp', 'httpupgrade', 'xhttp'].includes(p.transport), + }, + { + name: 'service_name', + type: 'input', + label: 'service_name', + group: 'transport', + condition: (p) => p.transport === 'grpc', + }, + { + name: 'sni', + type: 'input', + label: 'security_sni', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'allow_insecure', + type: 'switch', + label: 'security_allow_insecure', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'reality_server_addr', + type: 'input', + label: 'security_server_address', + placeholder: (t) => t('security_server_address_placeholder'), + group: 'reality', + condition: (p) => p.security === 'reality', + }, + { + name: 'reality_server_port', + type: 'number', + label: 'security_server_port', + min: 1, + max: 65535, + placeholder: '1-65535', + group: 'reality', + condition: (p) => p.security === 'reality', + }, + { + name: 'reality_private_key', + type: 'input', + label: 'security_private_key', + placeholder: (t) => t('security_private_key_placeholder'), + group: 'reality', + condition: (p) => p.security === 'reality', + }, + { + name: 'reality_public_key', + type: 'input', + label: 'security_public_key', + placeholder: (t) => t('security_public_key_placeholder'), + group: 'reality', + condition: (p) => p.security === 'reality', + }, + { + name: 'reality_short_id', + type: 'input', + label: 'security_short_id', + group: 'reality', + condition: (p) => p.security === 'reality', + }, + ], + trojan: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'transport', + type: 'select', + label: 'transport', + options: TRANSPORTS.trojan, + defaultValue: 'tcp', + group: 'transport', + }, + { + name: 'security', + type: 'select', + label: 'security', + options: SECURITY.trojan, + defaultValue: 'tls', + group: 'security', + }, + { + name: 'host', + type: 'input', + label: 'host', + group: 'transport', + condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport), + }, + { + name: 'path', + type: 'input', + label: 'path', + group: 'transport', + condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport), + }, + { + name: 'service_name', + type: 'input', + label: 'service_name', + group: 'transport', + condition: (p) => p.transport === 'grpc', + }, + { + name: 'sni', + type: 'input', + label: 'security_sni', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'allow_insecure', + type: 'switch', + label: 'security_allow_insecure', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + condition: (p) => p.security !== 'none', + }, + ], + hysteria2: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'obfs_password', + type: 'input', + label: 'obfs_password', + placeholder: (t) => t('obfs_password_placeholder'), + group: 'basic', + }, + { + name: 'hop_ports', + type: 'input', + label: 'hop_ports', + placeholder: (t) => t('hop_ports_placeholder'), + group: 'basic', + }, + { + name: 'hop_interval', + type: 'number', + label: 'hop_interval', + min: 0, + suffix: 'S', + group: 'basic', + }, + { name: 'sni', type: 'input', label: 'security_sni', group: 'security' }, + { name: 'allow_insecure', type: 'switch', label: 'security_allow_insecure', group: 'security' }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + }, + ], + tuic: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'udp_relay_mode', + type: 'select', + label: 'udp_relay_mode', + options: TUIC_UDP_RELAY_MODES, + defaultValue: 'native', + group: 'basic', + }, + { + name: 'congestion_controller', + type: 'select', + label: 'congestion_controller', + options: TUIC_CONGESTION, + defaultValue: 'bbr', + group: 'basic', + }, + { name: 'disable_sni', type: 'switch', label: 'disable_sni', group: 'basic' }, + { name: 'reduce_rtt', type: 'switch', label: 'reduce_rtt', group: 'basic' }, + { name: 'sni', type: 'input', label: 'security_sni', group: 'security' }, + { name: 'allow_insecure', type: 'switch', label: 'security_allow_insecure', group: 'security' }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + }, + ], + socks: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + ], + naive: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'security', + type: 'select', + label: 'security', + options: SECURITY.naive, + defaultValue: 'none', + group: 'security', + }, + { + name: 'sni', + type: 'input', + label: 'security_sni', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'allow_insecure', + type: 'switch', + label: 'security_allow_insecure', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + condition: (p) => p.security !== 'none', + }, + ], + http: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'security', + type: 'select', + label: 'security', + options: SECURITY.http, + defaultValue: 'none', + group: 'security', + }, + { + name: 'sni', + type: 'input', + label: 'security_sni', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'allow_insecure', + type: 'switch', + label: 'security_allow_insecure', + group: 'security', + condition: (p) => p.security !== 'none', + }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + condition: (p) => p.security !== 'none', + }, + ], + meru: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'multiplex', + type: 'select', + label: 'multiplex', + options: multiplexLevels, + defaultValue: 'off', + group: 'basic', + }, + { + name: 'transport', + type: 'select', + label: 'transport', + options: TRANSPORTS.meru, + defaultValue: 'tcp', + group: 'transport', + }, + ], + anytls: [ + { + name: 'port', + type: 'number', + label: 'port', + min: 0, + max: 65535, + placeholder: '1-65535', + group: 'basic', + }, + { + name: 'padding_scheme', + type: 'textarea', + label: 'padding_scheme', + placeholder: (t: (key: string) => string) => t('padding_scheme_placeholder'), + group: 'basic', + }, + { name: 'sni', type: 'input', label: 'security_sni', group: 'security' }, + { name: 'allow_insecure', type: 'switch', label: 'security_allow_insecure', group: 'security' }, + { + name: 'fingerprint', + type: 'select', + label: 'security_fingerprint', + options: FINGERPRINTS, + defaultValue: 'chrome', + group: 'security', + }, + ], +}; diff --git a/apps/admin/app/dashboard/servers/server-form.tsx b/apps/admin/app/dashboard/servers/server-form.tsx index f288957..de582e3 100644 --- a/apps/admin/app/dashboard/servers/server-form.tsx +++ b/apps/admin/app/dashboard/servers/server-form.tsx @@ -1,8 +1,13 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@workspace/ui/components/accordion'; import { Button } from '@workspace/ui/components/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; import { Form, FormControl, @@ -28,7 +33,6 @@ import { SheetTrigger, } from '@workspace/ui/components/sheet'; import { Switch } from '@workspace/ui/components/switch'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { Icon } from '@workspace/ui/custom-components/icon'; import { useTranslations } from 'next-intl'; @@ -36,25 +40,246 @@ import { useEffect, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { toast } from 'sonner'; import { - FINGERPRINTS, - FLOWS, + FieldConfig, formSchema, getLabel, getProtocolDefaultConfig, - LABELS, + PROTOCOL_FIELDS, protocols as PROTOCOLS, - SECURITY, ServerFormValues, - SS_CIPHERS, - TRANSPORTS, - TUIC_CONGESTION, - TUIC_UDP_RELAY_MODES, } from './form-schema'; function titleCase(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); } +function DynamicField({ + field, + control, + protocolIndex, + protocolData, + t, +}: { + field: FieldConfig; + control: any; + protocolIndex: number; + protocolData: any; + t: (key: string) => string; +}) { + const fieldName = `protocols.${protocolIndex}.${field.name}` as const; + + // 检查字段是否应该显示 + if (field.condition && !field.condition(protocolData, {})) { + return null; + } + + const commonProps = { + control, + name: fieldName, + }; + + switch (field.type) { + case 'input': + return ( + ( + + {t(field.label)} + + fieldProps.onChange(v)} + /> + + + + )} + /> + ); + + case 'number': + return ( + ( + + {t(field.label)} + + fieldProps.onChange(v)} + /> + + + + )} + /> + ); + + case 'select': + // 如果选项只有一个,直接隐藏该字段 + if (!field.options || field.options.length <= 1) { + return null; + } + + return ( + ( + + {t(field.label)} + + + + + + )} + /> + ); + + case 'switch': + return ( + ( + + {t(field.label)} + +
+ fieldProps.onChange(checked)} + /> +
+
+ +
+ )} + /> + ); + + case 'textarea': + return ( + ( + + {t(field.label)} + +