🐛 fix: Implement encryption and obfuscation features in protocol configuration

This commit is contained in:
web
2025-09-15 09:35:03 -07:00
parent 597a01885b
commit 54de16bdd5
31 changed files with 694 additions and 126 deletions
+181 -45
View File
@@ -1,4 +1,6 @@
import { z } from 'zod';
import { generatePassword, generateRealityKeyPair, generateRealityShortId } from './generate';
import { generateVlessX25519Pair } from './generate/mlkem768x25519plus';
export const protocols = [
'shadowsocks',
@@ -25,9 +27,12 @@ export type FieldConfig = {
max?: number;
step?: number;
suffix?: string;
password?: number;
generate?: {
function: () => any;
updateFields?: Record<string, string>;
};
condition?: (protocol: any, values: any) => boolean;
group?: 'basic' | 'transport' | 'security' | 'reality' | 'plugin';
group?: 'basic' | 'transport' | 'security' | 'reality' | 'obfs' | 'encryption';
gridSpan?: 1 | 2;
};
@@ -55,13 +60,9 @@ export const LABELS = {
'360': '360',
'qq': 'QQ',
// multiplex
'off': 'Off',
'low': 'Low',
'middle': 'Middle',
'high': 'High',
// ss plugins
'v2ray-plugin': 'V2Ray Plugin',
'simple-obfs': 'Simple Obfs',
} as const;
// Flat arrays for enum-like sets
@@ -75,8 +76,6 @@ export const SS_CIPHERS = [
'2022-blake3-chacha20-poly1305',
] as const;
export const SS_PLUGINS = ['none', 'simple-obfs', 'v2ray-plugin'] as const;
export const TRANSPORTS = {
vmess: ['tcp', 'websocket', 'grpc'] as const,
vless: ['tcp', 'websocket', 'grpc', 'mkcp', 'httpupgrade', 'xhttp'] as const,
@@ -101,6 +100,10 @@ export const FLOWS = {
export const TUIC_UDP_RELAY_MODES = ['native', 'quic'] as const;
export const TUIC_CONGESTION = ['bbr', 'cubic', 'new_reno'] as const;
export const XHTTP_MODES = ['auto', 'packet-up', 'stream-up', 'stream-one'] as const;
export const ENCRYPTION_TYPES = ['none', 'mlkem768x25519plus'] as const;
export const ENCRYPTION_MODES = ['native', 'xorpub', 'random'] as const;
export const ENCRYPTION_RTT = ['0rtt', '1rtt'] as const;
export const FINGERPRINTS = [
'chrome',
'firefox',
@@ -112,7 +115,7 @@ export const FINGERPRINTS = [
'qq',
] as const;
export const multiplexLevels = ['off', 'low', 'middle', 'high'] as const;
export const multiplexLevels = ['none', 'low', 'middle', 'high'] as const;
export function getLabel(value: string): string {
const label = (LABELS as Record<string, string>)[value];
@@ -129,8 +132,9 @@ 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_options: nullableString,
obfs: z.enum(['none', 'http', 'tls'] as const).nullish(),
obfs_host: nullableString,
obfs_path: nullableString,
});
const vmess = z.object({
@@ -163,6 +167,16 @@ const vless = z.object({
reality_private_key: nullableString,
reality_public_key: nullableString,
reality_short_id: nullableString,
mode: nullableString,
extra: nullableString,
encryption: z.enum(ENCRYPTION_TYPES as any).nullish(),
encryption_mode: z.enum(ENCRYPTION_MODES as any).nullish(),
encryption_rtt: z.enum(ENCRYPTION_RTT as any).nullish(),
encryption_ticket: nullableString,
encryption_server_padding: nullableString,
encryption_private_key: nullableString,
encryption_client_padding: nullableString,
encryption_password: nullableString,
});
const trojan = z.object({
@@ -183,6 +197,7 @@ const hysteria2 = z.object({
hop_ports: nullableString,
hop_interval: z.number().nullish(),
obfs_password: nullableString,
obfs: z.enum(['none', 'salamander'] as const).nullish(),
port: nullablePort,
security: z.enum(SECURITY.hysteria2 as any).nullish(),
sni: nullableString,
@@ -281,8 +296,9 @@ export function getProtocolDefaultConfig(proto: ProtocolType) {
port: null,
cipher: 'chacha20-ietf-poly1305',
server_key: null,
plugin: 'none',
plugin_opts: null,
obfs: 'none',
obfs_host: null,
obfs_path: null,
} as any;
case 'vmess':
return { type: 'vmess', port: null, transport: 'tcp', security: 'none' } as any;
@@ -296,6 +312,7 @@ export function getProtocolDefaultConfig(proto: ProtocolType) {
port: null,
hop_ports: null,
hop_interval: null,
obfs: 'none',
obfs_password: null,
security: 'tls',
up_mbps: null,
@@ -335,7 +352,7 @@ export function getProtocolDefaultConfig(proto: ProtocolType) {
return {
type: 'meru',
port: null,
multiplex: 'off',
multiplex: 'none',
transport: 'tcp',
} as any;
case 'anytls':
@@ -367,7 +384,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
{
name: 'cipher',
type: 'select',
label: 'encryption_method',
label: 'cipher',
options: SS_CIPHERS,
defaultValue: 'chacha20-ietf-poly1305',
group: 'basic',
@@ -376,7 +393,9 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'server_key',
type: 'input',
label: 'server_key',
password: 32,
generate: {
function: () => generatePassword(32),
},
group: 'basic',
condition: (p) =>
[
@@ -386,29 +405,28 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
].includes(p.cipher),
},
{
name: 'plugin',
name: 'obfs',
type: 'select',
label: 'plugin',
options: SS_PLUGINS,
label: 'obfs',
options: ['none', 'http', 'tls'],
defaultValue: 'none',
group: 'plugin',
group: 'obfs',
},
{
name: 'plugin_opts',
type: 'textarea',
label: 'plugin_opts',
placeholder: (t: (key: string) => string, p: any) => {
switch (p.plugin) {
case 'simple-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) => ['simple-obfs', 'v2ray-plugin'].includes(p.plugin),
name: 'obfs_host',
type: 'input',
label: 'obfs_host',
placeholder: 'e.g. www.bing.com',
group: 'obfs',
condition: (p) => p.obfs && p.obfs !== 'none',
},
{
name: 'obfs_path',
type: 'input',
label: 'obfs_path',
placeholder: 'e.g. /path/to/obfs',
group: 'obfs',
condition: (p) => p.obfs && p.obfs !== 'none',
},
],
vmess: [
@@ -441,6 +459,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'host',
type: 'input',
label: 'host',
placeholder: 'e.g. www.bing.com',
group: 'transport',
condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport),
},
@@ -448,6 +467,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'path',
type: 'input',
label: 'path',
placeholder: 'e.g. /path/to/obfs',
group: 'transport',
condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport),
},
@@ -492,14 +512,6 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
placeholder: '1-65535',
group: 'basic',
},
{
name: 'flow',
type: 'select',
label: 'flow',
options: FLOWS.vless,
defaultValue: 'none',
group: 'basic',
},
{
name: 'transport',
type: 'select',
@@ -508,6 +520,15 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
defaultValue: 'tcp',
group: 'transport',
},
{
name: 'flow',
type: 'select',
label: 'flow',
options: FLOWS.vless,
defaultValue: 'none',
group: 'transport',
condition: (p) => p.transport === 'tcp',
},
{
name: 'security',
type: 'select',
@@ -520,6 +541,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'host',
type: 'input',
label: 'host',
placeholder: 'e.g. www.bing.com',
group: 'transport',
condition: (p) => ['websocket', 'mkcp', 'httpupgrade', 'xhttp'].includes(p.transport),
},
@@ -527,6 +549,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'path',
type: 'input',
label: 'path',
placeholder: 'e.g. /path/to/obfs',
group: 'transport',
condition: (p) => ['websocket', 'mkcp', 'httpupgrade', 'xhttp'].includes(p.transport),
},
@@ -537,6 +560,23 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
group: 'transport',
condition: (p) => p.transport === 'grpc',
},
{
name: 'mode',
type: 'select',
label: 'mode',
options: XHTTP_MODES,
defaultValue: 'auto',
group: 'transport',
condition: (p) => p.transport === 'xhttp',
},
{
name: 'extra',
type: 'textarea',
label: 'extra',
placeholder: '{}',
group: 'transport',
condition: (p) => p.transport === 'xhttp',
},
{
name: 'sni',
type: 'input',
@@ -584,6 +624,13 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
label: 'security_private_key',
placeholder: (t) => t('security_private_key_placeholder'),
group: 'reality',
generate: {
function: generateRealityKeyPair,
updateFields: {
reality_private_key: 'privateKey',
reality_public_key: 'publicKey',
},
},
condition: (p) => p.security === 'reality',
},
{
@@ -599,8 +646,84 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
type: 'input',
label: 'security_short_id',
group: 'reality',
generate: {
function: generateRealityShortId,
},
condition: (p) => p.security === 'reality',
},
{
name: 'encryption',
type: 'select',
label: 'encryption',
options: ENCRYPTION_TYPES,
defaultValue: 'none',
group: 'encryption',
},
{
name: 'encryption_mode',
type: 'select',
label: 'encryption_mode',
options: ENCRYPTION_MODES,
defaultValue: 'native',
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
{
name: 'encryption_rtt',
type: 'select',
label: 'encryption_rtt',
options: ENCRYPTION_RTT,
defaultValue: '1rtt',
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
{
name: 'encryption_ticket',
type: 'input',
label: 'encryption_ticket',
placeholder: 'e.g. 600s',
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus' && p.encryption_rtt === '0rtt',
},
{
name: 'encryption_server_padding',
type: 'input',
label: 'encryption_server_padding',
placeholder: 'e.g. 100-111-1111.75-0-111.50-0-3333',
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
{
name: 'encryption_private_key',
type: 'input',
label: 'encryption_private_key',
placeholder: (t) => t('encryption_private_key_placeholder'),
group: 'encryption',
generate: {
function: () => generateVlessX25519Pair(),
updateFields: {
encryption_private_key: 'privateKeyB64',
encryption_password: 'passwordB64',
},
},
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
{
name: 'encryption_client_padding',
type: 'input',
label: 'encryption_client_padding',
placeholder: 'e.g. 100-111-1111.75-0-111.50-0-3333',
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
{
name: 'encryption_password',
type: 'input',
label: 'encryption_password',
placeholder: (t) => t('encryption_password_placeholder'),
group: 'encryption',
condition: (p) => p.encryption === 'mlkem768x25519plus',
},
],
trojan: [
{
@@ -632,6 +755,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'host',
type: 'input',
label: 'host',
placeholder: 'e.g. www.bing.com',
group: 'transport',
condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport),
},
@@ -639,6 +763,7 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
name: 'path',
type: 'input',
label: 'path',
placeholder: 'e.g. /path/to/obfs',
group: 'transport',
condition: (p) => ['websocket', 'xhttp', 'httpupgrade'].includes(p.transport),
},
@@ -699,13 +824,24 @@ export const PROTOCOL_FIELDS: Record<string, FieldConfig[]> = {
suffix: 'S',
group: 'basic',
},
{
name: 'obfs',
type: 'select',
label: 'obfs',
options: ['none', 'salamander'],
defaultValue: 'none',
group: 'obfs',
},
{
name: 'obfs_password',
type: 'input',
label: 'obfs_password',
placeholder: (t) => t('obfs_password_placeholder'),
password: 16,
group: 'basic',
generate: {
function: () => generatePassword(16),
},
group: 'obfs',
condition: (p) => p.obfs && p.obfs !== 'none',
},
{
name: 'up_mbps',
@@ -0,0 +1,6 @@
export { generatePassword } from './random';
export {
generateRealityKeyPair,
generateRealityShortId,
publicKeyFromPrivate,
} from './reality-key';
@@ -0,0 +1,21 @@
import { x25519 } from '@noble/curves/ed25519';
const toB64 = (u8: Uint8Array): string => {
if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64');
let s = '';
for (const b of u8) s += String.fromCharCode(b);
return btoa(s);
};
export type VlessX25519Pair = {
passwordB64: string;
privateKeyB64: string;
};
export function generateVlessX25519Pair(): VlessX25519Pair {
const { secretKey, publicKey } = x25519.keygen();
return {
passwordB64: toB64(publicKey),
privateKeyB64: toB64(secretKey),
};
}
@@ -0,0 +1,11 @@
import { uid } from 'radash';
/**
* Generate a random password
* @param length Length of the password
* @param charset Character set to use (defaults to alphanumeric)
* @returns Randomly generated password
*/
export function generatePassword(length = 16, charset?: string) {
return uid(length, charset).toLowerCase();
}
@@ -0,0 +1,49 @@
import { x25519 } from '@noble/curves/ed25519.js';
function toB64Url(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function fromB64Url(s: string) {
const b64 = s
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(s.length / 4) * 4, '=');
const bin = atob(b64);
return new Uint8Array([...bin].map((c) => c.charCodeAt(0)));
}
/**
* Generate a Reality key pair
* @returns An object containing the private and public keys in base64url format
*/
export function generateRealityKeyPair() {
const { secretKey, publicKey } = x25519.keygen();
return { privateKey: toB64Url(secretKey), publicKey: toB64Url(publicKey) };
}
/**
* Derive public key from private key
* @param privateKeyB64Url Private key in base64url format
* @returns Public key in base64url format
*/
export function publicKeyFromPrivate(privateKeyB64Url: string) {
return toB64Url(x25519.getPublicKey(fromB64Url(privateKeyB64Url)));
}
/**
* Generate a short ID for Reality
* @returns A random hexadecimal string of length 2, 4, 6, 8, 10, 12, 14, or 16
*/
export function generateRealityShortId() {
const hex = '0123456789abcdef';
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
const idx = Math.floor(Math.random() * lengths.length);
const len = lengths[idx] ?? 16;
let out = '';
for (let i = 0; i < len; i++) {
out += hex.charAt(Math.floor(Math.random() * hex.length));
}
return out;
}
@@ -38,7 +38,6 @@ import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { cn } from '@workspace/ui/lib/utils';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
@@ -55,12 +54,14 @@ import {
function DynamicField({
field,
control,
form,
protocolIndex,
protocolData,
t,
}: {
field: FieldConfig;
control: any;
form: any;
protocolIndex: number;
protocolData: any;
t: (key: string) => string;
@@ -97,17 +98,29 @@ function DynamicField({
}
onValueChange={(v) => fieldProps.onChange(v)}
suffix={
field.password ? (
field.generate ? (
<Button
type='button'
variant='ghost'
onClick={() => {
const length = field.password || 16;
const result = uid(length).toLowerCase();
fieldProps.onChange(result);
const result = field.generate!.function();
if (typeof result === 'string') {
fieldProps.onChange(result);
} else if (field.generate!.updateFields) {
Object.entries(field.generate!.updateFields).forEach(
([fieldName, resultKey]) => {
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
form.setValue(fullFieldName, (result as any)[resultKey]);
},
);
} else {
if (result.privateKey) {
fieldProps.onChange(result.privateKey);
}
}
}}
>
<Icon icon='mdi:refresh' className='h-4 w-4' />
<Icon icon='mdi:key' className='h-4 w-4' />
</Button>
) : (
field.suffix
@@ -225,7 +238,7 @@ function DynamicField({
field.placeholder
? typeof field.placeholder === 'function'
? field.placeholder(t, protocolData)
: t(field.placeholder)
: field.placeholder
: undefined
}
onChange={(e) => fieldProps.onChange(e.target.value)}
@@ -246,6 +259,7 @@ function renderFieldsByGroup(
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any,
t: (key: string) => string,
@@ -260,6 +274,7 @@ function renderFieldsByGroup(
key={field.name}
field={field}
control={control}
form={form}
protocolIndex={protocolIndex}
protocolData={protocolData}
t={t}
@@ -274,6 +289,7 @@ function renderGroupCard(
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any,
t: (key: string) => string,
@@ -294,7 +310,7 @@ function renderGroupCard(
{t(title)}
</legend>
<div className='p-4 pt-2'>
{renderFieldsByGroup(fields, group, control, protocolIndex, protocolData, t)}
{renderFieldsByGroup(fields, group, control, form, protocolIndex, protocolData, t)}
</div>
</fieldset>
</div>
@@ -541,19 +557,48 @@ export default function ServerForm(props: {
</AccordionTrigger>
<AccordionContent className='px-4 pb-4 pt-0'>
<div className='-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4'>
{renderGroupCard('basic', fields, 'basic', control, i, current, t)}
{renderGroupCard('plugin', fields, 'plugin', control, i, current, t)}
{renderGroupCard('basic', fields, 'basic', control, form, i, current, t)}
{renderGroupCard('obfs', fields, 'obfs', control, form, i, current, t)}
{renderGroupCard(
'transport',
fields,
'transport',
control,
form,
i,
current,
t,
)}
{renderGroupCard(
'security',
fields,
'security',
control,
form,
i,
current,
t,
)}
{renderGroupCard(
'reality',
fields,
'reality',
control,
form,
i,
current,
t,
)}
{renderGroupCard(
'encryption',
fields,
'encryption',
control,
form,
i,
current,
t,
)}
{renderGroupCard('security', fields, 'security', control, i, current, t)}
{renderGroupCard('reality', fields, 'reality', control, i, current, t)}
</div>
</AccordionContent>
</AccordionItem>