feat(frontend): stream tab external-proxy + sockopt sections (Pattern A)

External Proxy: Switch driven by externalProxy array length. Toggling
on seeds one row with the window hostname + the inbound's current port;
toggling off clears the array. Each row is a Form.List item with
forceTls/dest/port/remark inline, and a nested SNI/Fingerprint/ALPN
row that conditionally renders on forceTls === 'tls' via a
shouldUpdate-closure that watches the per-row forceTls path.

Sockopt: Switch driven by whether the sockopt object exists in form
state. Toggling on calls SockoptStreamSettingsSchema.parse({}) so every
default the schema declares (mark=0, tproxy='off', domainStrategy='UseIP',
tcpcongestion='bbr', etc.) flows into the form; toggling off sets to
undefined.

Renders the seventeen sockopt fields directly bound to
['streamSettings', 'sockopt', X] paths. Option lists pull from the
primitives const dictionaries (UTLS_FINGERPRINT, ALPN_OPTION,
DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) rather than the
schema's .options to keep one source of truth for UI label strings.
This commit is contained in:
MHSanaei 2026-05-26 02:30:09 +02:00
parent 54a2d32343
commit 6f0bcaf97d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A

View file

@ -32,7 +32,15 @@ import {
type InboundFormValues,
} from '@/schemas/forms/inbound-form';
import { antdRule } from '@/utils/zodForm';
import { Protocols, SNIFFING_OPTION } from '@/schemas/primitives';
import {
ALPN_OPTION,
DOMAIN_STRATEGY_OPTION,
Protocols,
SNIFFING_OPTION,
TCP_CONGESTION_OPTION,
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import DateTimePicker from '@/components/DateTimePicker';
import InputAddon from '@/components/InputAddon';
import type { DBInbound } from '@/models/dbinbound';
@ -112,6 +120,38 @@ export default function InboundFormModalNew({
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
const externalProxyArr = Form.useWatch(['streamSettings', 'externalProxy'], form);
const externalProxyOn = Array.isArray(externalProxyArr) && externalProxyArr.length > 0;
const sockoptValue = Form.useWatch(['streamSettings', 'sockopt'], form);
const sockoptOn = !!sockoptValue && typeof sockoptValue === 'object' && Object.keys(sockoptValue as object).length > 0;
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: [],
}]);
} else {
form.setFieldValue(['streamSettings', 'externalProxy'], []);
}
};
const toggleSockopt = (on: boolean) => {
if (on) {
form.setFieldValue(
['streamSettings', 'sockopt'],
SockoptStreamSettingsSchema.parse({}),
);
} else {
form.setFieldValue(['streamSettings', 'sockopt'], undefined);
}
};
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
@ -1106,6 +1146,218 @@ export default function InboundFormModalNew({
</Form.Item>
</>
)}
<Form.Item label="External Proxy">
<Switch checked={externalProxyOn} onChange={toggleExternalProxy} />
</Form.Item>
{externalProxyOn && (
<Form.List name={['streamSettings', 'externalProxy']}>
{(fields, { add, remove }) => (
<>
<Form.Item label=" " colon={false}>
<Button
size="small"
type="primary"
onClick={() => add({
forceTls: 'same',
dest: '',
port: 443,
remark: '',
sni: '',
fingerprint: '',
alpn: [],
})}
>
<PlusOutlined />
</Button>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{fields.map((field) => (
<div key={field.key} style={{ margin: '8px 0' }}>
<Space.Compact block>
<Form.Item name={[field.name, 'forceTls']} noStyle>
<Select style={{ width: '20%' }}>
<Select.Option value="same">{t('pages.inbounds.same')}</Select.Option>
<Select.Option value="none">{t('none')}</Select.Option>
<Select.Option value="tls">TLS</Select.Option>
</Select>
</Form.Item>
<Form.Item name={[field.name, 'dest']} noStyle>
<Input style={{ width: '30%' }} placeholder={t('host')} />
</Form.Item>
<Form.Item name={[field.name, 'port']} noStyle>
<InputNumber style={{ width: '15%' }} min={1} max={65535} />
</Form.Item>
<Form.Item name={[field.name, 'remark']} noStyle>
<Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
<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 (
<Space.Compact style={{ marginTop: 6 }} block>
<Form.Item name={[field.name, 'sni']} noStyle>
<Input style={{ width: '30%' }} placeholder="SNI (defaults to host)" />
</Form.Item>
<Form.Item name={[field.name, 'fingerprint']} noStyle>
<Select style={{ width: '30%' }} placeholder="Fingerprint">
<Select.Option value="">Default</Select.Option>
{Object.values(UTLS_FINGERPRINT).map((fp) => (
<Select.Option key={fp} value={fp}>{fp}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={[field.name, 'alpn']} noStyle>
<Select mode="multiple" style={{ width: '40%' }} placeholder="ALPN">
{Object.values(ALPN_OPTION).map((a) => (
<Select.Option key={a} value={a}>{a}</Select.Option>
))}
</Select>
</Form.Item>
</Space.Compact>
);
}}
</Form.Item>
</div>
))}
</Form.Item>
</>
)}
</Form.List>
)}
<Form.Item label="Sockopt">
<Switch checked={sockoptOn} onChange={toggleSockopt} />
</Form.Item>
{sockoptOn && (
<>
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label="Route Mark">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
label="TCP Keep Alive Interval"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
label="TCP Keep Alive Idle"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label="TCP Max Seg">
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
label="TCP User Timeout"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label="TCP Window Clamp"
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
label="Proxy Protocol"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
label="TCP Fast Open"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpMptcp']}
label="Multipath TCP"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'penetrate']}
label="Penetrate"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'V6Only']}
label="V6 Only"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'domainStrategy']}
label="Domain Strategy"
>
<Select style={{ width: '50%' }}>
{Object.values(DOMAIN_STRATEGY_OPTION).map((d) => (
<Select.Option key={d} value={d}>{d}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label="TCP Congestion"
>
<Select style={{ width: '50%' }}>
{Object.values(TCP_CONGESTION_OPTION).map((c) => (
<Select.Option key={c} value={c}>{c}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select style={{ width: '50%' }}>
<Select.Option value="off">Off</Select.Option>
<Select.Option value="redirect">Redirect</Select.Option>
<Select.Option value="tproxy">TProxy</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label="Dialer Proxy">
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'interfaceName']}
label="Interface Name"
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label="Trusted X-Forwarded-For"
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']}>
<Select.Option value="CF-Connecting-IP">CF-Connecting-IP</Select.Option>
<Select.Option value="X-Real-IP">X-Real-IP</Select.Option>
<Select.Option value="True-Client-IP">True-Client-IP</Select.Option>
<Select.Option value="X-Client-IP">X-Client-IP</Select.Option>
</Select>
</Form.Item>
</>
)}
</>
);