mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 19:37:13 +00:00
Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
This commit is contained in:
@@ -25,10 +25,7 @@ const DISABLED_STROKE = {
|
||||
|
||||
const UNLIMITED_STROKE = '#722ed1';
|
||||
|
||||
export function computeTrafficDisplay(
|
||||
input: TrafficDisplayInput,
|
||||
isDark: boolean,
|
||||
): TrafficDisplay {
|
||||
export function computeTrafficDisplay(input: TrafficDisplayInput, isDark: boolean): TrafficDisplay {
|
||||
const up = input.up || 0;
|
||||
const down = input.down || 0;
|
||||
const used = up + down;
|
||||
|
||||
@@ -46,7 +46,9 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
|
||||
fingerprint: host.fingerprint,
|
||||
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
|
||||
pinnedPeerCertSha256:
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0
|
||||
? host.pinnedPeerCertSha256
|
||||
: undefined,
|
||||
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
|
||||
echConfigList: host.echConfigList || undefined,
|
||||
vlessRoute: host.vlessRoute || undefined,
|
||||
|
||||
@@ -52,13 +52,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
|
||||
v.token === 'EMAIL'
|
||||
|| v.token === 'ID'
|
||||
|| v.token === 'SHORT_ID'
|
||||
|| v.token === 'TELEGRAM_ID'
|
||||
|| v.token === 'SUB_ID'
|
||||
));
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter(
|
||||
(v) =>
|
||||
v.token === 'EMAIL' ||
|
||||
v.token === 'ID' ||
|
||||
v.token === 'SHORT_ID' ||
|
||||
v.token === 'TELEGRAM_ID' ||
|
||||
v.token === 'SUB_ID',
|
||||
);
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||
@@ -82,7 +83,11 @@ export function hasRemarkTokens(template: string): boolean {
|
||||
* tokens collapse to empty by default; metadata fields can keep unsupported
|
||||
* tokens literal because the backend does the same for backwards compatibility.
|
||||
*/
|
||||
export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
|
||||
export function previewRemark(
|
||||
template: string,
|
||||
variables: RemarkVar[] = REMARK_VARIABLES,
|
||||
keepUnknown = false,
|
||||
): string {
|
||||
if (!hasRemarkTokens(template)) return template;
|
||||
const allowed = new Set(variables.map((v) => v.token));
|
||||
return template.replace(TOKEN_RE, (match, tok: string) => {
|
||||
|
||||
@@ -55,11 +55,27 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.sniffingIpsExcluded')} name={[...name, 'ipsExcluded']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} placeholder="IP/CIDR/geoip:*/ext:*" style={{ width: '100%' }} />
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingIpsExcluded')}
|
||||
name={[...name, 'ipsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.sniffingDomainsExcluded')} name={[...name, 'domainsExcluded']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} placeholder="domain:*/ext:*" style={{ width: '100%' }} />
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingDomainsExcluded')}
|
||||
name={[...name, 'domainsExcluded']}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="domain:*/ext:*"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,13 @@ interface FinalMaskFieldProps {
|
||||
|
||||
const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
|
||||
|
||||
export default function FinalMaskField({ value, onChange, network, protocol, showAll }: FinalMaskFieldProps) {
|
||||
export default function FinalMaskField({
|
||||
value,
|
||||
onChange,
|
||||
network,
|
||||
protocol,
|
||||
showAll,
|
||||
}: FinalMaskFieldProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => value ?? EMPTY);
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -41,7 +47,13 @@ export default function FinalMaskField({ value, onChange, network, protocol, sho
|
||||
labelWrap
|
||||
initialValues={{ finalmask: initial }}
|
||||
>
|
||||
<FinalMaskForm name="finalmask" network={network} protocol={protocol} form={form} showAll={showAll} />
|
||||
<FinalMaskForm
|
||||
name="finalmask"
|
||||
network={network}
|
||||
protocol={protocol}
|
||||
form={form}
|
||||
showAll={showAll}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
@@ -9,7 +19,10 @@ import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
|
||||
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
|
||||
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
export interface FinalMaskFormProps {
|
||||
name: NamePath;
|
||||
@@ -37,8 +50,11 @@ export function parseGeckoPacketSize(value: unknown): { min: number; max: number
|
||||
const min = Number(match[1]);
|
||||
const max = Number(match[2]);
|
||||
if (
|
||||
!Number.isSafeInteger(min) || !Number.isSafeInteger(max)
|
||||
|| min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
|
||||
!Number.isSafeInteger(min) ||
|
||||
!Number.isSafeInteger(max) ||
|
||||
min < GECKO_MIN_PACKET_SIZE ||
|
||||
max < min ||
|
||||
max > GECKO_MAX_PACKET_SIZE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -59,9 +75,11 @@ function splitGeckoPacketSize(value: unknown): { min: number | null; max: number
|
||||
|
||||
function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
|
||||
if (parseGeckoPacketSize(value)) return Promise.resolve();
|
||||
return Promise.reject(new Error(
|
||||
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
|
||||
));
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function asPath(name: NamePath): (string | number)[] {
|
||||
@@ -76,13 +94,21 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||
return { packets: '1-3', lengths: ['100-200'], delays: [], maxSplit: '' };
|
||||
case 'sudoku':
|
||||
return {
|
||||
password: '', ascii: '', customTable: '', customTables: [],
|
||||
paddingMin: 0, paddingMax: 0,
|
||||
password: '',
|
||||
ascii: '',
|
||||
customTable: '',
|
||||
customTables: [],
|
||||
paddingMin: 0,
|
||||
paddingMax: 0,
|
||||
};
|
||||
case 'header-custom':
|
||||
return { clients: [], servers: [] };
|
||||
case 'xmc':
|
||||
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
|
||||
return {
|
||||
hostname: '',
|
||||
profiles: [defaultXmcProfile()],
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -99,7 +125,10 @@ function defaultXmcProfile(): Record<string, unknown> {
|
||||
// legacy username cannot be upgraded automatically — carry it into a profile
|
||||
// stub instead, which keeps the operator's player names visible and leaves the
|
||||
// per-field validators pointing at exactly what still has to be filled in.
|
||||
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||
export function migrateXmcSettings(settings: Record<string, unknown>): {
|
||||
next: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const out: Record<string, unknown> = { ...settings };
|
||||
let changed = false;
|
||||
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
|
||||
@@ -123,7 +152,10 @@ export function migrateXmcSettings(settings: Record<string, unknown>): { next: R
|
||||
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
|
||||
// fallback). Lift any legacy singular value into a one-element array so the
|
||||
// list UI shows it, and drop the singular key so we never emit both.
|
||||
function migrateFragmentSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
|
||||
function migrateFragmentSettings(settings: Record<string, unknown>): {
|
||||
next: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const out: Record<string, unknown> = { ...settings };
|
||||
let changed = false;
|
||||
if (!Array.isArray(out.lengths) && typeof out.length === 'string' && out.length.trim() !== '') {
|
||||
@@ -176,7 +208,11 @@ function defaultUdpClientServerItem(): Record<string, unknown> {
|
||||
|
||||
function defaultNoiseItem(): Record<string, unknown> {
|
||||
return {
|
||||
rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20',
|
||||
rand: '1-8192',
|
||||
randRange: '0-255',
|
||||
type: 'array',
|
||||
packet: [],
|
||||
delay: '10-20',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,7 +235,13 @@ function defaultUdpHop(): Record<string, unknown> {
|
||||
return { ports: '20000-50000', interval: '5-10' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||
export default function FinalMaskForm({
|
||||
name,
|
||||
network,
|
||||
protocol,
|
||||
form,
|
||||
showAll = false,
|
||||
}: FinalMaskFormProps) {
|
||||
const base = asPath(name);
|
||||
|
||||
// Migrate legacy TCP mask shapes once on mount so configs saved before
|
||||
@@ -217,9 +259,8 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
|
||||
if (!m.settings || typeof m.settings !== 'object') return mask;
|
||||
const settings = m.settings as Record<string, unknown>;
|
||||
const { next: migrated, changed } = m.type === 'fragment'
|
||||
? migrateFragmentSettings(settings)
|
||||
: migrateXmcSettings(settings);
|
||||
const { next: migrated, changed } =
|
||||
m.type === 'fragment' ? migrateFragmentSettings(settings) : migrateXmcSettings(settings);
|
||||
if (!changed) return mask;
|
||||
anyChanged = true;
|
||||
return { ...m, settings: migrated };
|
||||
@@ -244,7 +285,15 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
return (
|
||||
<>
|
||||
{showTcp && <TcpMasksList base={base} form={form} />}
|
||||
{showUdp && <UdpMasksList base={base} form={form} isHysteria={isHysteria} isWireguard={isWireguard} network={network} />}
|
||||
{showUdp && (
|
||||
<UdpMasksList
|
||||
base={base}
|
||||
form={form}
|
||||
isHysteria={isHysteria}
|
||||
isWireguard={isWireguard}
|
||||
network={network}
|
||||
/>
|
||||
)}
|
||||
{showQuic && (
|
||||
<>
|
||||
<Form.Item label="QUIC Params">
|
||||
@@ -274,7 +323,9 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
|
||||
onClick={() =>
|
||||
add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, mIdx) => (
|
||||
@@ -294,7 +345,11 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
|
||||
}
|
||||
|
||||
function TcpMaskItem({
|
||||
fieldName, displayIndex, form, listPath, onRemove,
|
||||
fieldName,
|
||||
displayIndex,
|
||||
form,
|
||||
listPath,
|
||||
onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
displayIndex: number;
|
||||
@@ -385,9 +440,15 @@ function TcpMaskItem({
|
||||
if (type === 'sudoku') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
@@ -423,15 +484,20 @@ function TcpMaskItem({
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Password is required' }]}
|
||||
>
|
||||
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input
|
||||
placeholder="Obfuscation password"
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)}
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -458,7 +524,9 @@ function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void>
|
||||
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
if (str.length === 0) {
|
||||
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
|
||||
return Promise.reject(
|
||||
new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'),
|
||||
);
|
||||
}
|
||||
const min = Number(str.split('-')[0]);
|
||||
if (!Number.isFinite(min) || min <= 0) {
|
||||
@@ -473,7 +541,9 @@ function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||
function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<void> {
|
||||
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
if (str.length === 0) {
|
||||
return Promise.reject(new Error("Delay is required — remove the row if you don't want a delay"));
|
||||
return Promise.reject(
|
||||
new Error("Delay is required — remove the row if you don't want a delay"),
|
||||
);
|
||||
}
|
||||
if (!/^\d+(?:-\d+)?$/.test(str)) {
|
||||
return Promise.reject(new Error('Use a delay in ms, e.g. 10 or 10-20'));
|
||||
@@ -486,7 +556,11 @@ function validateFragmentDelayEntry(_rule: unknown, value: unknown): Promise<voi
|
||||
// fragment segment N, clamping to the last entry. `minItems` keeps at least
|
||||
// one length row so the config never collapses to an empty (rejected) list.
|
||||
function FragmentRangeList({
|
||||
listName, label, placeholder, validator, minItems = 0,
|
||||
listName,
|
||||
label,
|
||||
placeholder,
|
||||
validator,
|
||||
minItems = 0,
|
||||
}: {
|
||||
listName: (string | number)[];
|
||||
label: string;
|
||||
@@ -500,7 +574,13 @@ function FragmentRangeList({
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={label}>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add('')}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<Form.Item
|
||||
@@ -511,8 +591,8 @@ function FragmentRangeList({
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
suffix={fields.length > minItems
|
||||
? (
|
||||
suffix={
|
||||
fields.length > minItems ? (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
@@ -521,8 +601,8 @@ function FragmentRangeList({
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
@@ -560,7 +640,8 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
||||
// Mojang hands the profile UUID back undashed from the session server and
|
||||
// dashed from most other endpoints; xray-core parses either, so accept both
|
||||
// rather than forcing the operator to reformat what they pasted.
|
||||
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
const XMC_UUID_PATTERN =
|
||||
/^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
|
||||
@@ -626,14 +707,20 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||
name={[profile.name, 'texturesValue']}
|
||||
rules={[{ required: true, message: 'Textures value is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="Base64 value from the session profile"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Textures Signature"
|
||||
name={[profile.name, 'texturesSignature']}
|
||||
rules={[{ required: true, message: 'Textures signature is required' }]}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder="Base64 signature from the session profile"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
@@ -644,7 +731,9 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
tcpFieldName, form, absoluteSettingsPath,
|
||||
tcpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
tcpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -695,7 +784,12 @@ function HeaderCustomGroups({
|
||||
key={item.key}
|
||||
fieldName={item.name}
|
||||
form={form}
|
||||
absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]}
|
||||
absoluteItemPath={[
|
||||
...absoluteSettingsPath,
|
||||
groupKey,
|
||||
group.name,
|
||||
item.name,
|
||||
]}
|
||||
delayMode="number"
|
||||
onRemove={() => removeItem(item.name)}
|
||||
/>
|
||||
@@ -714,8 +808,18 @@ function HeaderCustomGroups({
|
||||
}
|
||||
|
||||
function UdpMasksList({
|
||||
base, form, isHysteria, isWireguard, network,
|
||||
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
|
||||
base,
|
||||
form,
|
||||
isHysteria,
|
||||
isWireguard,
|
||||
network,
|
||||
}: {
|
||||
base: (string | number)[];
|
||||
form: FormInstance;
|
||||
isHysteria: boolean;
|
||||
isWireguard: boolean;
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={[...base, 'udp']}>
|
||||
@@ -753,7 +857,14 @@ function UdpMasksList({
|
||||
}
|
||||
|
||||
function UdpMaskItem({
|
||||
fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove,
|
||||
fieldName,
|
||||
displayIndex,
|
||||
form,
|
||||
listPath,
|
||||
isHysteria,
|
||||
isWireguard,
|
||||
network,
|
||||
onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
displayIndex: number;
|
||||
@@ -778,16 +889,16 @@ function UdpMaskItem({
|
||||
const options = isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
// Salamander is the mask xray-core's own wireguard finalmask example
|
||||
// uses; it stays hysteria-only elsewhere to keep legacy parity.
|
||||
...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
// Salamander is the mask xray-core's own wireguard finalmask example
|
||||
// uses; it stays hysteria-only elsewhere to keep legacy parity.
|
||||
...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []),
|
||||
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'realm', label: 'Realm' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -809,12 +920,20 @@ function UdpMaskItem({
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])}
|
||||
shouldUpdate={(prev, curr) =>
|
||||
getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
|
||||
if (type === 'salamander') {
|
||||
return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
|
||||
return (
|
||||
<SalamanderUdpMaskSettings
|
||||
fieldName={fieldName}
|
||||
form={form}
|
||||
absolutePath={absolutePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'mkcp-legacy') {
|
||||
return (
|
||||
@@ -848,7 +967,11 @@ function UdpMaskItem({
|
||||
if (type === 'xicmp') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Dgram" name={[fieldName, 'settings', 'dgram']} valuePropName="checked">
|
||||
<Form.Item
|
||||
label="Dgram"
|
||||
name={[fieldName, 'settings', 'dgram']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="IPs" name={[fieldName, 'settings', 'ips']}>
|
||||
@@ -864,10 +987,20 @@ function UdpMaskItem({
|
||||
<Input placeholder="realm://token@host:port/id" />
|
||||
</Form.Item>
|
||||
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="host:port"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
|
||||
<Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
|
||||
<Divider plain style={{ margin: '8px 0' }}>
|
||||
TLS (optional)
|
||||
</Divider>
|
||||
<Form.Item
|
||||
label="Server Name"
|
||||
name={[fieldName, 'settings', 'tlsConfig', 'serverName']}
|
||||
>
|
||||
<Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
|
||||
@@ -881,12 +1014,11 @@ function UdpMaskItem({
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={UTLS_FINGERPRINT_OPTIONS}
|
||||
/>
|
||||
<Form.Item
|
||||
label="Fingerprint"
|
||||
name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}
|
||||
>
|
||||
<Select allowClear style={{ width: '100%' }} options={UTLS_FINGERPRINT_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Allow Insecure"
|
||||
@@ -924,7 +1056,9 @@ function UdpMaskItem({
|
||||
}
|
||||
|
||||
function SalamanderUdpMaskSettings({
|
||||
fieldName, form, absolutePath,
|
||||
fieldName,
|
||||
form,
|
||||
absolutePath,
|
||||
}: {
|
||||
fieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -939,9 +1073,11 @@ function SalamanderUdpMaskSettings({
|
||||
<>
|
||||
<Form.Item
|
||||
label="Mode"
|
||||
extra={mode === 'gecko'
|
||||
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
|
||||
: 'Scrambles each packet into random-looking bytes.'}
|
||||
extra={
|
||||
mode === 'gecko'
|
||||
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
|
||||
: 'Scrambles each packet into random-looking bytes.'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={mode}
|
||||
@@ -952,7 +1088,10 @@ function SalamanderUdpMaskSettings({
|
||||
packetSizePath,
|
||||
parseGeckoPacketSize(current)
|
||||
? current
|
||||
: formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
|
||||
: formatGeckoPacketSize(
|
||||
DEFAULT_GECKO_PACKET_SIZE.min,
|
||||
DEFAULT_GECKO_PACKET_SIZE.max,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
form.setFieldValue(packetSizePath, undefined);
|
||||
@@ -973,10 +1112,12 @@ function SalamanderUdpMaskSettings({
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)}
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -1031,7 +1172,9 @@ function GeckoPacketSizeInput({
|
||||
}
|
||||
|
||||
function UdpHeaderCustom({
|
||||
udpFieldName, form, absoluteSettingsPath,
|
||||
udpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
udpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1083,7 +1226,9 @@ function UdpHeaderCustom({
|
||||
}
|
||||
|
||||
function NoiseItems({
|
||||
udpFieldName, form, absoluteSettingsPath,
|
||||
udpFieldName,
|
||||
form,
|
||||
absoluteSettingsPath,
|
||||
}: {
|
||||
udpFieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1137,7 +1282,11 @@ function NoiseItems({
|
||||
}
|
||||
|
||||
function ItemEditor({
|
||||
fieldName, form, absoluteItemPath, delayMode, onRemove: _onRemove,
|
||||
fieldName,
|
||||
form,
|
||||
absoluteItemPath,
|
||||
delayMode,
|
||||
onRemove: _onRemove,
|
||||
}: {
|
||||
fieldName: number;
|
||||
form: FormInstance;
|
||||
@@ -1190,7 +1339,10 @@ function ItemEditor({
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => getDeep(prev, [...absoluteItemPath, 'type']) !== getDeep(curr, [...absoluteItemPath, 'type'])}
|
||||
shouldUpdate={(prev, curr) =>
|
||||
getDeep(prev, [...absoluteItemPath, 'type']) !==
|
||||
getDeep(curr, [...absoluteItemPath, 'type'])
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue([...absoluteItemPath, 'type']) as string | undefined;
|
||||
@@ -1228,7 +1380,9 @@ function ItemEditor({
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
|
||||
onClick={() =>
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -1247,7 +1401,9 @@ function ItemEditor({
|
||||
|
||||
function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormInstance }) {
|
||||
const congestion = Form.useWatch([...base, 'congestion'], form) as string | undefined;
|
||||
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as Record<string, unknown> | undefined;
|
||||
const udpHop = Form.useWatch([...base, 'udpHop'], { form, preserve: true }) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hasUdpHop = udpHop != null;
|
||||
|
||||
return (
|
||||
@@ -1315,7 +1471,11 @@ function QuicParamsForm({ base, form }: { base: (string | number)[]; form: FormI
|
||||
<Form.Item label="Keep Alive Period (s)" name={[...base, 'keepAlivePeriod']}>
|
||||
<InputNumber min={2} max={60} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Disable Path MTU Dis" name={[...base, 'disablePathMTUDiscovery']} valuePropName="checked">
|
||||
<Form.Item
|
||||
label="Disable Path MTU Dis"
|
||||
name={[...base, 'disablePathMTUDiscovery']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ export function selectionFromValue(value: string, known: ReadonlySet<string>): s
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function mergeSelection(value: string, selected: string[], known: ReadonlySet<string>): string {
|
||||
export function mergeSelection(
|
||||
value: string,
|
||||
selected: string[],
|
||||
known: ReadonlySet<string>,
|
||||
): string {
|
||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
||||
const kept = new Set(
|
||||
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
|
||||
|
||||
@@ -20,12 +20,14 @@ export function buildClonePayload(dbInbound: DBInbound, port: number, nodeId: nu
|
||||
const fallback = createDefaultInboundSettings(dbInbound.protocol);
|
||||
clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
|
||||
}
|
||||
const streamSettingsString = typeof dbInbound.streamSettings === 'string'
|
||||
? dbInbound.streamSettings
|
||||
: JSON.stringify(dbInbound.streamSettings ?? {});
|
||||
const sniffingString = typeof dbInbound.sniffing === 'string'
|
||||
? dbInbound.sniffing
|
||||
: JSON.stringify(dbInbound.sniffing ?? {});
|
||||
const streamSettingsString =
|
||||
typeof dbInbound.streamSettings === 'string'
|
||||
? dbInbound.streamSettings
|
||||
: JSON.stringify(dbInbound.streamSettings ?? {});
|
||||
const sniffingString =
|
||||
typeof dbInbound.sniffing === 'string'
|
||||
? dbInbound.sniffing
|
||||
: JSON.stringify(dbInbound.sniffing ?? {});
|
||||
return {
|
||||
up: 0,
|
||||
down: 0,
|
||||
@@ -59,7 +61,9 @@ export function pickClonePort(used: Set<number> | undefined): number {
|
||||
port = RandomUtil.randomInteger(10000, 60000);
|
||||
}
|
||||
if (used.has(port)) {
|
||||
for (port = 10000; port <= 60000 && used.has(port); port++) { /* dense-range scan */ }
|
||||
for (port = 10000; port <= 60000 && used.has(port); port++) {
|
||||
/* dense-range scan */
|
||||
}
|
||||
if (port > 60000) port = RandomUtil.randomInteger(10000, 60000);
|
||||
}
|
||||
return port;
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||
import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
|
||||
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import type {
|
||||
ShadowsocksClient,
|
||||
ShadowsocksInboundSettings,
|
||||
} from '@/schemas/protocols/inbound/shadowsocks';
|
||||
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
|
||||
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
|
||||
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
|
||||
@@ -107,9 +110,13 @@ export interface ShadowsocksClientSeed extends ClientBaseSeed {
|
||||
// (the parent inbound's method is authoritative); only 2022-blake3 multi-
|
||||
// user inbounds use the per-client method. Callers pass `ssMethod` to seed
|
||||
// a method-specific password length when creating a multi-user client.
|
||||
export function createDefaultShadowsocksClient(seed: ShadowsocksClientSeed = {}): ShadowsocksClient {
|
||||
export function createDefaultShadowsocksClient(
|
||||
seed: ShadowsocksClientSeed = {},
|
||||
): ShadowsocksClient {
|
||||
const method = seed.method ?? '';
|
||||
const password = seed.password ?? RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
|
||||
const password =
|
||||
seed.password ??
|
||||
RandomUtil.randomShadowsocksPassword(seed.ssMethod ?? '2022-blake3-aes-256-gcm');
|
||||
return {
|
||||
method,
|
||||
password,
|
||||
@@ -294,17 +301,29 @@ export type AnyInboundSettings =
|
||||
|
||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return createDefaultVlessInboundSettings();
|
||||
case 'vmess': return createDefaultVmessInboundSettings();
|
||||
case 'trojan': return createDefaultTrojanInboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaInboundSettings();
|
||||
case 'http': return createDefaultHttpInboundSettings();
|
||||
case 'mixed': return createDefaultMixedInboundSettings();
|
||||
case 'tunnel': return createDefaultTunnelInboundSettings();
|
||||
case 'tun': return createDefaultTunInboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardInboundSettings();
|
||||
case 'mtproto': return createDefaultMtprotoInboundSettings();
|
||||
default: return null;
|
||||
case 'vless':
|
||||
return createDefaultVlessInboundSettings();
|
||||
case 'vmess':
|
||||
return createDefaultVmessInboundSettings();
|
||||
case 'trojan':
|
||||
return createDefaultTrojanInboundSettings();
|
||||
case 'shadowsocks':
|
||||
return createDefaultShadowsocksInboundSettings();
|
||||
case 'hysteria':
|
||||
return createDefaultHysteriaInboundSettings();
|
||||
case 'http':
|
||||
return createDefaultHttpInboundSettings();
|
||||
case 'mixed':
|
||||
return createDefaultMixedInboundSettings();
|
||||
case 'tunnel':
|
||||
return createDefaultTunnelInboundSettings();
|
||||
case 'tun':
|
||||
return createDefaultTunInboundSettings();
|
||||
case 'wireguard':
|
||||
return createDefaultWireguardInboundSettings();
|
||||
case 'mtproto':
|
||||
return createDefaultMtprotoInboundSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form';
|
||||
import type {
|
||||
InboundFormValues,
|
||||
ShareAddrStrategy,
|
||||
TrafficReset,
|
||||
} from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import {
|
||||
HysteriaClientSchema,
|
||||
@@ -143,7 +147,7 @@ function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
||||
|
||||
function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const tls = stream.tlsSettings as { certificates?: unknown } | undefined;
|
||||
return Array.isArray(tls?.certificates) ? tls.certificates as Record<string, unknown>[] : [];
|
||||
return Array.isArray(tls?.certificates) ? (tls.certificates as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
|
||||
@@ -165,9 +169,8 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const protocol = (row.protocol || 'vless') as InboundSettings['protocol'];
|
||||
const settings = coerceJsonObject(row.settings) as InboundSettings['settings'];
|
||||
const rawStream = coerceJsonObject(row.streamSettings);
|
||||
const streamSettings = Object.keys(rawStream).length > 0
|
||||
? (rawStream as StreamSettings)
|
||||
: undefined;
|
||||
const streamSettings =
|
||||
Object.keys(rawStream).length > 0 ? (rawStream as StreamSettings) : undefined;
|
||||
if (streamSettings) {
|
||||
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
|
||||
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
|
||||
@@ -251,14 +254,22 @@ export function pruneEmpty(value: unknown): unknown {
|
||||
// gives us the canonical projection.
|
||||
function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||
switch (protocol) {
|
||||
case 'vless': return VlessClientSchema;
|
||||
case 'vmess': return VmessClientSchema;
|
||||
case 'trojan': return TrojanClientSchema;
|
||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||
case 'hysteria': return HysteriaClientSchema;
|
||||
case 'wireguard': return WireguardClientSchema;
|
||||
case 'mtproto': return MtprotoClientSchema;
|
||||
default: return null;
|
||||
case 'vless':
|
||||
return VlessClientSchema;
|
||||
case 'vmess':
|
||||
return VmessClientSchema;
|
||||
case 'trojan':
|
||||
return TrojanClientSchema;
|
||||
case 'shadowsocks':
|
||||
return ShadowsocksClientSchema;
|
||||
case 'hysteria':
|
||||
return HysteriaClientSchema;
|
||||
case 'wireguard':
|
||||
return WireguardClientSchema;
|
||||
case 'mtproto':
|
||||
return MtprotoClientSchema;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +316,9 @@ export function dropLegacyOptionalEmpties(
|
||||
// sub-fields are empty; otherwise drop only the empty sub-arrays so
|
||||
// the wire payload doesn't carry a stray `"tcp": []` next to a
|
||||
// populated UDP mask list (and vice versa).
|
||||
const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown } | undefined;
|
||||
const fm = stream.finalmask as
|
||||
| { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown }
|
||||
| undefined;
|
||||
if (fm && typeof fm === 'object') {
|
||||
const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
|
||||
const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
|
||||
@@ -359,7 +372,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
|
||||
// mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
|
||||
// rather than the default { enabled: false } so the row carries no sniffing.
|
||||
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
|
||||
sniffing: canEnableSniffing({ protocol: values.protocol })
|
||||
? JSON.stringify(normalizeSniffing(values.sniffing))
|
||||
: '',
|
||||
tag: values.tag,
|
||||
shareAddrStrategy: values.shareAddrStrategy,
|
||||
shareAddr: values.shareAddr,
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface DbInboundLike {
|
||||
shareAddr?: string;
|
||||
}
|
||||
|
||||
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
|
||||
function fillProtocolSettingsDefaults(
|
||||
protocol: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
|
||||
if (parsed.success) {
|
||||
const tagged = parsed.data as { settings: Record<string, unknown> };
|
||||
@@ -36,9 +39,10 @@ export function inboundFromDb(raw: DbInboundLike): Inbound {
|
||||
const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings);
|
||||
const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings);
|
||||
const sniffing = coerceInboundJsonField(raw.sniffing);
|
||||
const streamSettings = Object.keys(streamSettingsRaw).length === 0
|
||||
? streamSettingsRaw
|
||||
: fillStreamDefaults(streamSettingsRaw);
|
||||
const streamSettings =
|
||||
Object.keys(streamSettingsRaw).length === 0
|
||||
? streamSettingsRaw
|
||||
: fillStreamDefaults(streamSettingsRaw);
|
||||
return {
|
||||
protocol: raw.protocol,
|
||||
port: raw.port,
|
||||
|
||||
@@ -23,7 +23,8 @@ import { deriveSpiderX } from './spider-x';
|
||||
// directly.
|
||||
|
||||
type ForceTls = 'same' | 'tls' | 'none';
|
||||
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
const SHARE_HOSTNAME_RE =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
|
||||
// Format a host for interpolation into a URL authority. IPv6 literals are
|
||||
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
|
||||
@@ -56,7 +57,12 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
}
|
||||
if (xhttp.xPaddingObfsMode === true) {
|
||||
extra.xPaddingObfsMode = true;
|
||||
for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
|
||||
for (const k of [
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
] as const) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
||||
}
|
||||
@@ -108,7 +114,10 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
|
||||
return Object.keys(extra).length > 0 ? extra : null;
|
||||
}
|
||||
|
||||
function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
|
||||
function applyXhttpExtraToObj(
|
||||
xhttp: XHttpStreamSettings | undefined,
|
||||
obj: Record<string, unknown>,
|
||||
): void {
|
||||
if (!xhttp) return;
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
obj.x_padding_bytes = xhttp.xPaddingBytes;
|
||||
@@ -161,9 +170,11 @@ function applyExternalProxyTLSObj(
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
const sni =
|
||||
externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) obj.sni = sni;
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0)
|
||||
obj.fp = externalProxy.fingerprint;
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
@@ -171,7 +182,8 @@ function applyExternalProxyTLSObj(
|
||||
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
||||
obj.vcn = externalProxy.verifyPeerCertByName;
|
||||
}
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
|
||||
obj.ech = externalProxy.echConfigList;
|
||||
}
|
||||
|
||||
export interface GenVmessLinkInput {
|
||||
@@ -227,8 +239,8 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
if (request) {
|
||||
obj.path = request.path.join(',');
|
||||
const host =
|
||||
getHeaderValue(header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) obj.host = host;
|
||||
}
|
||||
}
|
||||
@@ -287,7 +299,10 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||
// directly. Number values get coerced via .toString() on set — same as
|
||||
// what URLSearchParams does internally so the resulting URL bytes match.
|
||||
|
||||
function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
|
||||
function applyXhttpExtraToParams(
|
||||
xhttp: XHttpStreamSettings | undefined,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (!xhttp) return;
|
||||
params.set('path', xhttp.path);
|
||||
const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
||||
@@ -300,7 +315,10 @@ function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params:
|
||||
if (extra) params.set('extra', JSON.stringify(extra));
|
||||
}
|
||||
|
||||
function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
|
||||
function applyFinalMaskToParams(
|
||||
finalmask: FinalMaskStreamSettings | undefined,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
const payload = serializeFinalMask(finalmask);
|
||||
if (payload.length > 0) params.set('fm', payload);
|
||||
}
|
||||
@@ -311,9 +329,11 @@ function applyExternalProxyTLSParams(
|
||||
security: string,
|
||||
): void {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
const sni =
|
||||
externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0)
|
||||
params.set('fp', externalProxy.fingerprint);
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) params.set('alpn', alpn);
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
@@ -321,7 +341,8 @@ function applyExternalProxyTLSParams(
|
||||
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
||||
params.set('vcn', externalProxy.verifyPeerCertByName);
|
||||
}
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
||||
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
|
||||
params.set('ech', externalProxy.echConfigList);
|
||||
}
|
||||
|
||||
export interface GenVlessLinkInput {
|
||||
@@ -344,7 +365,8 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
|
||||
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
|
||||
const n = Number(r);
|
||||
if (n > 65535) return id;
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id))
|
||||
return id;
|
||||
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
|
||||
}
|
||||
|
||||
@@ -381,8 +403,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(tcp.header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
@@ -434,16 +456,15 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
reality.settings.serverName || reality.serverNames?.[0] || reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
||||
if (spx.length > 0) params.set('spx', spx);
|
||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||
if (reality.settings.mldsa65Verify.length > 0)
|
||||
params.set('pqv', reality.settings.mldsa65Verify);
|
||||
}
|
||||
} else {
|
||||
params.set('security', 'none');
|
||||
@@ -453,15 +474,20 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
|
||||
// vlessFlowAllowed and the form's flow-field gating so panel link, share
|
||||
// link and subscription agree.
|
||||
if (flow.length > 0 && canEnableTlsFlow({
|
||||
protocol: inbound.protocol,
|
||||
settings: inbound.settings,
|
||||
streamSettings: stream,
|
||||
})) {
|
||||
if (
|
||||
flow.length > 0 &&
|
||||
canEnableTlsFlow({
|
||||
protocol: inbound.protocol,
|
||||
settings: inbound.settings,
|
||||
streamSettings: stream,
|
||||
})
|
||||
) {
|
||||
params.set('flow', flow);
|
||||
}
|
||||
|
||||
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
|
||||
const url = new URL(
|
||||
`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`,
|
||||
);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
@@ -471,7 +497,10 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
// VLESS and VMess don't call this because they have minor per-protocol
|
||||
// quirks inline (vmess maps `multi` differently into obj.type; vless sets
|
||||
// encryption=none up-front).
|
||||
function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
function writeNetworkParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (stream.network === 'tcp') {
|
||||
const tcp = stream.tcpSettings;
|
||||
if (tcp.header?.type === 'http') {
|
||||
@@ -479,8 +508,8 @@ function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, para
|
||||
if (request) {
|
||||
params.set('path', request.path.join(','));
|
||||
const host =
|
||||
getHeaderValue(tcp.header.response?.headers, 'host')
|
||||
|| getHeaderValue(request.headers, 'host');
|
||||
getHeaderValue(tcp.header.response?.headers, 'host') ||
|
||||
getHeaderValue(request.headers, 'host');
|
||||
if (host) params.set('host', host);
|
||||
params.set('headerType', 'http');
|
||||
}
|
||||
@@ -507,7 +536,10 @@ function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, para
|
||||
}
|
||||
}
|
||||
|
||||
function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
||||
function writeTlsParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
): void {
|
||||
if (stream.security !== 'tls') return;
|
||||
const tls = stream.tlsSettings;
|
||||
params.set('fp', tls.settings.fingerprint);
|
||||
@@ -524,16 +556,18 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
|
||||
|
||||
// Reality query-string writer shared by VLESS and Trojan. Preserves the
|
||||
// legacy SNI-omission quirk (see genVlessLink for the full story).
|
||||
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
|
||||
function writeRealityParams(
|
||||
stream: NonNullable<Inbound['streamSettings']>,
|
||||
params: URLSearchParams,
|
||||
clientKey: string,
|
||||
): void {
|
||||
if (stream.security !== 'reality') return;
|
||||
const reality = stream.realitySettings;
|
||||
params.set('pbk', reality.settings.publicKey);
|
||||
params.set('fp', reality.settings.fingerprint);
|
||||
|
||||
const sni =
|
||||
reality.settings.serverName ||
|
||||
reality.serverNames?.[0] ||
|
||||
reality.target?.split(':')[0];
|
||||
reality.settings.serverName || reality.serverNames?.[0] || reality.target?.split(':')[0];
|
||||
|
||||
if (sni && sni.length > 0) params.set('sni', sni);
|
||||
|
||||
@@ -591,7 +625,9 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
|
||||
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
|
||||
const url = new URL(
|
||||
`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`,
|
||||
);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
@@ -826,9 +862,8 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
||||
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
|
||||
url.username = peer.privateKey ?? '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
const pubKey =
|
||||
settings.secretKey.length > 0 ? Wireguard.generateKeypair(settings.secretKey).publicKey : '';
|
||||
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
||||
if (peer.allowedIPs.length > 0) {
|
||||
url.searchParams.set('address', peer.allowedIPs.join(','));
|
||||
@@ -852,9 +887,8 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
const peer = settings.peers[peerIndex];
|
||||
if (!peer) return '';
|
||||
|
||||
const pubKey = settings.secretKey.length > 0
|
||||
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
||||
: '';
|
||||
const pubKey =
|
||||
settings.secretKey.length > 0 ? Wireguard.generateKeypair(settings.secretKey).publicKey : '';
|
||||
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
@@ -945,12 +979,7 @@ function isUnixSocketListen(listen: string): boolean {
|
||||
|
||||
function normalizeShareHost(host: string): string {
|
||||
const h = host.trim();
|
||||
if (
|
||||
h.length === 0
|
||||
|| h.includes('://')
|
||||
|| h.startsWith('//')
|
||||
|| /[/?#@]/.test(h)
|
||||
) {
|
||||
if (h.length === 0 || h.includes('://') || h.startsWith('//') || /[/?#@]/.test(h)) {
|
||||
return '';
|
||||
}
|
||||
if (h.startsWith('[')) {
|
||||
@@ -972,7 +1001,9 @@ function normalizeShareHost(host: string): string {
|
||||
}
|
||||
|
||||
function isShareableHost(host: string): boolean {
|
||||
const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const h = normalizeShareHost(host)
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.toLowerCase();
|
||||
if (h.length === 0) return false;
|
||||
if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
|
||||
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
|
||||
@@ -1031,14 +1062,21 @@ export function resolveShareHost(
|
||||
// `node` strategy keeps the previous node-address-first behavior for
|
||||
// node-managed inbounds; other strategies let a row prefer its listen address
|
||||
// or a custom endpoint.
|
||||
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
||||
export function resolveAddr(
|
||||
inbound: Inbound,
|
||||
hostOverride: string,
|
||||
fallbackHostname: string,
|
||||
): string {
|
||||
return resolveShareHost(inbound, hostOverride, fallbackHostname);
|
||||
}
|
||||
|
||||
// A loopback browser host means the panel was reached through a tunnel (e.g.
|
||||
// SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const h = host
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.toLowerCase();
|
||||
return h === 'localhost' || h === '::1' || h.startsWith('127.');
|
||||
}
|
||||
|
||||
@@ -1056,7 +1094,16 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
|
||||
// `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.
|
||||
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; secret?: string; email?: string; subId?: string };
|
||||
type ClientShape = {
|
||||
id?: string;
|
||||
security?: VmessSecurity;
|
||||
flow?: VlessClient['flow'];
|
||||
password?: string;
|
||||
auth?: string;
|
||||
secret?: string;
|
||||
email?: string;
|
||||
subId?: string;
|
||||
};
|
||||
|
||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
||||
// keys on — subscription id first, unique email as the fallback.
|
||||
@@ -1100,18 +1147,34 @@ export interface GenLinkInput {
|
||||
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
|
||||
// don't have share URLs).
|
||||
export function genLink(input: GenLinkInput): string {
|
||||
const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
|
||||
const {
|
||||
inbound,
|
||||
address,
|
||||
port = inbound.port,
|
||||
forceTls = 'same',
|
||||
remark = '',
|
||||
client,
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
switch (inbound.protocol) {
|
||||
case 'vmess':
|
||||
return genVmessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientId: client.id ?? '',
|
||||
security: client.security,
|
||||
externalProxy,
|
||||
});
|
||||
case 'vless':
|
||||
return genVlessLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientId: client.id ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
flow: client.flow,
|
||||
@@ -1120,21 +1183,32 @@ export function genLink(input: GenLinkInput): string {
|
||||
case 'shadowsocks': {
|
||||
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
||||
return genShadowsocksLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientPassword: isMultiUser ? (client.password ?? '') : '',
|
||||
externalProxy,
|
||||
});
|
||||
}
|
||||
case 'trojan':
|
||||
return genTrojanLink({
|
||||
inbound, address, port, forceTls, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
forceTls,
|
||||
remark,
|
||||
clientPassword: client.password ?? '',
|
||||
clientKey: clientSubKey(client),
|
||||
externalProxy,
|
||||
});
|
||||
case 'hysteria':
|
||||
return genHysteriaLink({
|
||||
inbound, address, port, remark,
|
||||
inbound,
|
||||
address,
|
||||
port,
|
||||
remark,
|
||||
clientAuth: client.auth ?? '',
|
||||
externalProxy,
|
||||
});
|
||||
@@ -1163,13 +1237,7 @@ export interface GenAllLinksInput {
|
||||
// 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 = '',
|
||||
client,
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
const { inbound, remark = '', client, hostOverride = '', fallbackHostname } = input;
|
||||
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const port = inbound.port;
|
||||
@@ -1180,7 +1248,12 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
||||
const externals = inbound.streamSettings?.externalProxy;
|
||||
if (!externals || externals.length === 0) {
|
||||
const r = composeRemark('');
|
||||
return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
|
||||
return [
|
||||
{
|
||||
remark: r,
|
||||
link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }),
|
||||
},
|
||||
];
|
||||
}
|
||||
return externals.map((ep) => {
|
||||
const r = composeRemark(ep.remark);
|
||||
@@ -1212,12 +1285,7 @@ export interface GenInboundLinksInput {
|
||||
// and emits per-peer .conf blocks for wireguard. Returns '' for the
|
||||
// other clientless protocols (http, mixed, tunnel).
|
||||
export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
const {
|
||||
inbound,
|
||||
remark = '',
|
||||
hostOverride = '',
|
||||
fallbackHostname,
|
||||
} = input;
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const clients = getInboundClients(inbound);
|
||||
if (clients) {
|
||||
@@ -1229,7 +1297,13 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
|
||||
return links.join('\r\n');
|
||||
}
|
||||
if (inbound.protocol === 'shadowsocks') {
|
||||
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
||||
return genShadowsocksLink({
|
||||
inbound,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
forceTls: 'same',
|
||||
remark,
|
||||
});
|
||||
}
|
||||
if (inbound.protocol === 'wireguard') {
|
||||
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
||||
@@ -1269,13 +1343,15 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardLink({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.map((p, i) =>
|
||||
genWireguardLink({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}),
|
||||
)
|
||||
.join('\r\n');
|
||||
}
|
||||
|
||||
@@ -1288,13 +1364,15 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardConfig({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}))
|
||||
.map((p, i) =>
|
||||
genWireguardConfig({
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
peerIndex: i,
|
||||
}),
|
||||
)
|
||||
.join('\r\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +68,7 @@ export interface InboundTagInput {
|
||||
export function composeInboundTag(input: InboundTagInput): string {
|
||||
const bits = inboundTransports(input.protocol, input.streamSettings, input.settings);
|
||||
return (
|
||||
nodeTagPrefix(input.nodeId)
|
||||
+ baseInboundTag(input.port ?? 0)
|
||||
+ '-'
|
||||
+ transportTagSuffix(bits)
|
||||
nodeTagPrefix(input.nodeId) + baseInboundTag(input.port ?? 0) + '-' + transportTagSuffix(bits)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unkno
|
||||
const tls = createTlsSettingsWithDefaultCert();
|
||||
tls.alpn = ['h3'];
|
||||
|
||||
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
|
||||
? { ...(tls.settings as Record<string, unknown>) }
|
||||
: {};
|
||||
const settings =
|
||||
tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
|
||||
? { ...(tls.settings as Record<string, unknown>) }
|
||||
: {};
|
||||
settings.fingerprint = '';
|
||||
tls.settings = settings;
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ export function parseLinkParts(link: string): LinkParts | null {
|
||||
security = json.tls ?? '';
|
||||
remark = typeof json.ps === 'string' ? json.ps : '';
|
||||
port = json.port != null ? String(json.port) : '';
|
||||
} catch { /* unparseable payload, fall back to protocol only */ }
|
||||
} catch {
|
||||
/* unparseable payload, fall back to protocol only */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
@@ -90,8 +92,14 @@ export function parseLinkParts(link: string): LinkParts | null {
|
||||
the URL authority, so fall back to it when there is no authority port. */
|
||||
port = url.port || (url.searchParams.get('port') ?? '');
|
||||
const hash = url.hash.replace(/^#/, '');
|
||||
try { remark = decodeURIComponent(hash); } catch { remark = hash; }
|
||||
} catch { /* not URL-shaped, fall back to protocol only */ }
|
||||
try {
|
||||
remark = decodeURIComponent(hash);
|
||||
} catch {
|
||||
remark = hash;
|
||||
}
|
||||
} catch {
|
||||
/* not URL-shaped, fall back to protocol only */
|
||||
}
|
||||
if (scheme === 'tg') security = 'FakeTLS';
|
||||
}
|
||||
if (security === 'none') security = '';
|
||||
@@ -113,10 +121,18 @@ export function linkMetaText(parts: LinkParts): string {
|
||||
export function LinkTags({ parts }: { parts: LinkParts }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Tag color={PROTOCOL_COLORS[parts.protocol]} style={TAG_STYLE}>{parts.protocol}</Tag>
|
||||
{parts.network && <Tag color={TRANSPORT_COLOR} style={TAG_STYLE}>{parts.network}</Tag>}
|
||||
<Tag color={PROTOCOL_COLORS[parts.protocol]} style={TAG_STYLE}>
|
||||
{parts.protocol}
|
||||
</Tag>
|
||||
{parts.network && (
|
||||
<Tag color={TRANSPORT_COLOR} style={TAG_STYLE}>
|
||||
{parts.network}
|
||||
</Tag>
|
||||
)}
|
||||
{parts.security && (
|
||||
<Tag color={SECURITY_COLORS[parts.security]} style={TAG_STYLE}>{parts.security}</Tag>
|
||||
<Tag color={SECURITY_COLORS[parts.security]} style={TAG_STYLE}>
|
||||
{parts.security}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -47,11 +47,13 @@ export function createDefaultDNSOutboundSettings(): DNSOutboundSettings {
|
||||
|
||||
export function createDefaultVmessOutboundSettings(): VmessOutboundSettings {
|
||||
return {
|
||||
vnext: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
users: [{ id: '', security: 'auto' }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: '',
|
||||
port: 443,
|
||||
users: [{ id: '', security: 'auto' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,12 +80,14 @@ export function createDefaultTrojanOutboundSettings(): TrojanOutboundSettings {
|
||||
// initial state instead of an empty Select.
|
||||
export function createDefaultShadowsocksOutboundSettings(): ShadowsocksOutboundSettings {
|
||||
return {
|
||||
servers: [{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: '',
|
||||
port: 443,
|
||||
password: '',
|
||||
method: '2022-blake3-aes-128-gcm',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,11 +115,13 @@ export function createDefaultWireguardOutboundSettings(
|
||||
mtu: 1420,
|
||||
secretKey,
|
||||
address: [],
|
||||
peers: [{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
}],
|
||||
peers: [
|
||||
{
|
||||
publicKey: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
},
|
||||
],
|
||||
noKernelTun: false,
|
||||
};
|
||||
}
|
||||
@@ -149,18 +155,31 @@ export type AnyOutboundSettings =
|
||||
export function createDefaultOutboundSettings(protocol: string): AnyOutboundSettings | null {
|
||||
void RandomUtil;
|
||||
switch (protocol) {
|
||||
case 'freedom': return createDefaultFreedomOutboundSettings();
|
||||
case 'blackhole': return createDefaultBlackholeOutboundSettings();
|
||||
case 'dns': return createDefaultDNSOutboundSettings();
|
||||
case 'vmess': return createDefaultVmessOutboundSettings();
|
||||
case 'vless': return createDefaultVlessOutboundSettings();
|
||||
case 'trojan': return createDefaultTrojanOutboundSettings();
|
||||
case 'shadowsocks': return createDefaultShadowsocksOutboundSettings();
|
||||
case 'socks': return createDefaultSocksOutboundSettings();
|
||||
case 'http': return createDefaultHttpOutboundSettings();
|
||||
case 'wireguard': return createDefaultWireguardOutboundSettings();
|
||||
case 'hysteria': return createDefaultHysteriaOutboundSettings();
|
||||
case 'loopback': return createDefaultLoopbackOutboundSettings();
|
||||
default: return null;
|
||||
case 'freedom':
|
||||
return createDefaultFreedomOutboundSettings();
|
||||
case 'blackhole':
|
||||
return createDefaultBlackholeOutboundSettings();
|
||||
case 'dns':
|
||||
return createDefaultDNSOutboundSettings();
|
||||
case 'vmess':
|
||||
return createDefaultVmessOutboundSettings();
|
||||
case 'vless':
|
||||
return createDefaultVlessOutboundSettings();
|
||||
case 'trojan':
|
||||
return createDefaultTrojanOutboundSettings();
|
||||
case 'shadowsocks':
|
||||
return createDefaultShadowsocksOutboundSettings();
|
||||
case 'socks':
|
||||
return createDefaultSocksOutboundSettings();
|
||||
case 'http':
|
||||
return createDefaultHttpOutboundSettings();
|
||||
case 'wireguard':
|
||||
return createDefaultWireguardOutboundSettings();
|
||||
case 'hysteria':
|
||||
return createDefaultHysteriaOutboundSettings();
|
||||
case 'loopback':
|
||||
return createDefaultLoopbackOutboundSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@ function asPort(value: unknown, fallback: number): number {
|
||||
function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
|
||||
const s = asString(value);
|
||||
if (!s) return '';
|
||||
return OutboundDomainStrategySchema.options.find(
|
||||
(v) => v.toLowerCase() === s.toLowerCase(),
|
||||
) ?? '';
|
||||
return (
|
||||
OutboundDomainStrategySchema.options.find((v) => v.toLowerCase() === s.toLowerCase()) ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
|
||||
@@ -131,14 +131,12 @@ function vlessFromWire(raw: Raw): VlessOutboundFormSettings {
|
||||
}
|
||||
const reverse = asObject(raw.reverse);
|
||||
const reverseTag = asString(reverse.tag);
|
||||
const reverseSniffing = reverseTag
|
||||
? sniffingFromWire(reverse.sniffing)
|
||||
: SNIFFING_DEFAULT;
|
||||
const reverseSniffing = reverseTag ? sniffingFromWire(reverse.sniffing) : SNIFFING_DEFAULT;
|
||||
const savedSeed = asArray(raw.testseed);
|
||||
const testseed = savedSeed.length === 4
|
||||
&& savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||
? (savedSeed as number[])
|
||||
: [900, 500, 900, 256];
|
||||
const testseed =
|
||||
savedSeed.length === 4 && savedSeed.every((n) => Number.isInteger(n) && (n as number) > 0)
|
||||
? (savedSeed as number[])
|
||||
: [900, 500, 900, 256];
|
||||
return {
|
||||
address,
|
||||
port,
|
||||
@@ -167,7 +165,10 @@ function shadowsocksFromWire(raw: Raw): ShadowsocksOutboundFormSettings {
|
||||
address: asString(s.address),
|
||||
port: asPort(s.port, 443),
|
||||
password: asString(s.password),
|
||||
method: asString(s.method, '2022-blake3-aes-128-gcm') as ShadowsocksOutboundFormSettings['method'],
|
||||
method: asString(
|
||||
s.method,
|
||||
'2022-blake3-aes-128-gcm',
|
||||
) as ShadowsocksOutboundFormSettings['method'],
|
||||
uot: asBool(s.uot),
|
||||
UoTVersion: asNumber(s.UoTVersion, 1),
|
||||
};
|
||||
@@ -213,9 +214,7 @@ function httpFromWire(raw: Raw): HttpOutboundFormSettings {
|
||||
|
||||
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
|
||||
const secretKey = asString(raw.secretKey);
|
||||
const pubKey = secretKey.length > 0
|
||||
? Wireguard.generateKeypair(secretKey).publicKey
|
||||
: '';
|
||||
const pubKey = secretKey.length > 0 ? Wireguard.generateKeypair(secretKey).publicKey : '';
|
||||
const addressArr = asArray(raw.address).map((x) =>
|
||||
typeof x === 'number' ? String(x) : asString(x),
|
||||
);
|
||||
@@ -262,10 +261,13 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
const noises = asArray(raw.noises).map((n) => {
|
||||
const nn = asObject(n);
|
||||
return {
|
||||
type: (asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type']),
|
||||
type: asString(nn.type, 'rand') as FreedomOutboundFormSettings['noises'][number]['type'],
|
||||
packet: asString(nn.packet, '10-20'),
|
||||
delay: asString(nn.delay, '10-16'),
|
||||
applyTo: (asString(nn.applyTo, 'ip') as FreedomOutboundFormSettings['noises'][number]['applyTo']),
|
||||
applyTo: asString(
|
||||
nn.applyTo,
|
||||
'ip',
|
||||
) as FreedomOutboundFormSettings['noises'][number]['applyTo'],
|
||||
};
|
||||
});
|
||||
const finalRulesRaw = asArray(raw.finalRules);
|
||||
@@ -275,7 +277,9 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
? rr.network.map((x) => asString(x)).join(',')
|
||||
: asString(rr.network);
|
||||
return {
|
||||
action: (asString(rr.action, 'block') === 'allow' ? 'allow' : 'block') as FreedomFinalRuleForm['action'],
|
||||
action: (asString(rr.action, 'block') === 'allow'
|
||||
? 'allow'
|
||||
: 'block') as FreedomFinalRuleForm['action'],
|
||||
network,
|
||||
port: asString(rr.port),
|
||||
ip: asArray(rr.ip).map((x) => asString(x)),
|
||||
@@ -293,9 +297,8 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
// legacy behavior: when the wire omits fragment, leave all four fields
|
||||
// empty so the modal's "Fragment" Switch starts off. When present,
|
||||
// surface whatever the wire holds verbatim.
|
||||
const wireHasFragment = raw.fragment != null
|
||||
&& typeof raw.fragment === 'object'
|
||||
&& Object.keys(fragment).length > 0;
|
||||
const wireHasFragment =
|
||||
raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
|
||||
return {
|
||||
domainStrategy: targetStrategyFromWire(
|
||||
asString(raw.targetStrategy) || asString(raw.domainStrategy),
|
||||
@@ -304,7 +307,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
userLevel: asNumber(raw.userLevel, 0),
|
||||
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
||||
const n = asNumber(raw.proxyProtocol, 0);
|
||||
return (n === 1 || n === 2) ? n : 0;
|
||||
return n === 1 || n === 2 ? n : 0;
|
||||
})(),
|
||||
fragment: wireHasFragment
|
||||
? {
|
||||
@@ -337,10 +340,13 @@ function dnsRuleFromWire(raw: unknown): DnsRuleForm {
|
||||
? r.domain.map((x) => asString(x)).join(',')
|
||||
: asString(r.domain);
|
||||
const action = asString(r.action, 'direct');
|
||||
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action)
|
||||
? action
|
||||
: 'direct';
|
||||
return { action: validAction as DnsRuleForm['action'], qType, domain, rCode: asNumber(r.rCode, 0) };
|
||||
const validAction = ['direct', 'drop', 'return', 'hijack'].includes(action) ? action : 'direct';
|
||||
return {
|
||||
action: validAction as DnsRuleForm['action'],
|
||||
qType,
|
||||
domain,
|
||||
rCode: asNumber(r.rCode, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
||||
@@ -348,7 +354,7 @@ function dnsFromWire(raw: Raw): DnsOutboundFormSettings {
|
||||
return {
|
||||
rewriteNetwork: ((): DnsOutboundFormSettings['rewriteNetwork'] => {
|
||||
const s = asString(raw.rewriteNetwork ?? raw.network);
|
||||
return (s === 'udp' || s === 'tcp') ? s : '';
|
||||
return s === 'udp' || s === 'tcp' ? s : '';
|
||||
})(),
|
||||
rewriteAddress: asString(raw.rewriteAddress ?? raw.address),
|
||||
rewritePort: asPort(raw.rewritePort ?? raw.port, 53),
|
||||
@@ -415,28 +421,52 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
const sendThrough = asString(raw.sendThrough);
|
||||
const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
|
||||
const mux = muxFromWire(raw.mux);
|
||||
const hasStream = raw.streamSettings
|
||||
&& typeof raw.streamSettings === 'object'
|
||||
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
||||
const streamSettings = hasStream
|
||||
? hydrateStreamForm(raw.streamSettings as Raw)
|
||||
: undefined;
|
||||
const hasStream =
|
||||
raw.streamSettings &&
|
||||
typeof raw.streamSettings === 'object' &&
|
||||
Object.keys(raw.streamSettings as Raw).length > 0;
|
||||
const streamSettings = hasStream ? hydrateStreamForm(raw.streamSettings as Raw) : undefined;
|
||||
|
||||
let typed: OutboundFormSettings;
|
||||
switch (protocol) {
|
||||
case 'vmess': typed = { protocol: 'vmess', settings: vmessFromWire(settings) }; break;
|
||||
case 'vless': typed = { protocol: 'vless', settings: vlessFromWire(settings) }; break;
|
||||
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
|
||||
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
|
||||
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
|
||||
case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break;
|
||||
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
|
||||
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
|
||||
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
|
||||
case 'blackhole': typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) }; break;
|
||||
case 'dns': typed = { protocol: 'dns', settings: dnsFromWire(settings) }; break;
|
||||
case 'loopback': typed = { protocol: 'loopback', settings: loopbackFromWire(settings) }; break;
|
||||
default: typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
case 'vmess':
|
||||
typed = { protocol: 'vmess', settings: vmessFromWire(settings) };
|
||||
break;
|
||||
case 'vless':
|
||||
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
break;
|
||||
case 'trojan':
|
||||
typed = { protocol: 'trojan', settings: trojanFromWire(settings) };
|
||||
break;
|
||||
case 'shadowsocks':
|
||||
typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) };
|
||||
break;
|
||||
case 'socks':
|
||||
typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) };
|
||||
break;
|
||||
case 'http':
|
||||
typed = { protocol: 'http', settings: httpFromWire(settings) };
|
||||
break;
|
||||
case 'wireguard':
|
||||
typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
|
||||
break;
|
||||
case 'hysteria':
|
||||
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
|
||||
break;
|
||||
case 'freedom':
|
||||
typed = { protocol: 'freedom', settings: freedomFromWire(settings) };
|
||||
break;
|
||||
case 'blackhole':
|
||||
typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
|
||||
break;
|
||||
case 'dns':
|
||||
typed = { protocol: 'dns', settings: dnsFromWire(settings) };
|
||||
break;
|
||||
case 'loopback':
|
||||
typed = { protocol: 'loopback', settings: loopbackFromWire(settings) };
|
||||
break;
|
||||
default:
|
||||
typed = { protocol: 'vless', settings: vlessFromWire(settings) };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -453,11 +483,13 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
|
||||
function vmessToWire(s: VmessOutboundFormSettings) {
|
||||
return {
|
||||
vnext: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: [{ id: s.id, security: s.security }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: [{ id: s.id, security: s.security }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -503,24 +535,28 @@ function trojanToWire(s: TrojanOutboundFormSettings) {
|
||||
|
||||
function shadowsocksToWire(s: ShadowsocksOutboundFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
password: s.password,
|
||||
method: s.method,
|
||||
uot: s.uot,
|
||||
UoTVersion: s.UoTVersion,
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
password: s.password,
|
||||
method: s.method,
|
||||
uot: s.uot,
|
||||
UoTVersion: s.UoTVersion,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function simpleAuthToWire(s: SimpleAuthFormSettings) {
|
||||
return {
|
||||
servers: [{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
||||
}],
|
||||
servers: [
|
||||
{
|
||||
address: s.address,
|
||||
port: s.port,
|
||||
users: s.user ? [{ user: s.user, pass: s.pass }] : [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,10 +572,18 @@ function wireguardToWire(s: WireguardOutboundFormSettings) {
|
||||
return {
|
||||
mtu: s.mtu || undefined,
|
||||
secretKey: s.secretKey,
|
||||
address: s.address ? s.address.split(',').map((x) => x.trim()).filter(Boolean) : [],
|
||||
address: s.address
|
||||
? s.address
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
domainStrategy: s.domainStrategy || undefined,
|
||||
reserved: s.reserved
|
||||
? s.reserved.split(',').map((x) => Number(x.trim())).filter((n) => Number.isFinite(n))
|
||||
? s.reserved
|
||||
.split(',')
|
||||
.map((x) => Number(x.trim()))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
: undefined,
|
||||
peers: s.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
@@ -574,15 +618,16 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
||||
proxyProtocol: s.proxyProtocol || undefined,
|
||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
||||
finalRules: s.finalRules && s.finalRules.length > 0
|
||||
? s.finalRules.map((r) => ({
|
||||
action: r.action,
|
||||
network: r.network || undefined,
|
||||
port: r.port || undefined,
|
||||
ip: r.ip.length > 0 ? r.ip : undefined,
|
||||
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
finalRules:
|
||||
s.finalRules && s.finalRules.length > 0
|
||||
? s.finalRules.map((r) => ({
|
||||
action: r.action,
|
||||
network: r.network || undefined,
|
||||
port: r.port || undefined,
|
||||
ip: r.ip.length > 0 ? r.ip : undefined,
|
||||
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -591,15 +636,16 @@ function blackholeToWire(s: { type: '' | 'none' | 'http' }) {
|
||||
}
|
||||
|
||||
function dnsRuleToWire(r: DnsRuleForm) {
|
||||
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action)
|
||||
? r.action
|
||||
: 'direct';
|
||||
const action = ['direct', 'drop', 'return', 'hijack'].includes(r.action) ? r.action : 'direct';
|
||||
const result: Raw = { action };
|
||||
const qType = r.qType.trim();
|
||||
if (qType) {
|
||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
||||
}
|
||||
const domains = r.domain.split(',').map((d) => d.trim()).filter(Boolean);
|
||||
const domains = r.domain
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
if (domains.length > 0) result.domain = domains;
|
||||
if (r.rCode > 0) result.rCode = r.rCode;
|
||||
return result;
|
||||
@@ -653,13 +699,13 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||
|
||||
function muxAllowed(values: OutboundFormValues): boolean {
|
||||
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
||||
const flow = values.protocol === 'vless'
|
||||
? (values.settings as VlessOutboundFormSettings).flow
|
||||
: '';
|
||||
const flow =
|
||||
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
|
||||
if (flow) return false;
|
||||
const network = values.streamSettings && 'network' in values.streamSettings
|
||||
? values.streamSettings.network
|
||||
: undefined;
|
||||
const network =
|
||||
values.streamSettings && 'network' in values.streamSettings
|
||||
? values.streamSettings.network
|
||||
: undefined;
|
||||
if (network === 'xhttp') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -669,18 +715,42 @@ export type WireOutboundPayload = Raw;
|
||||
export function formValuesToWirePayload(values: OutboundFormValues): WireOutboundPayload {
|
||||
let settings: Raw;
|
||||
switch (values.protocol) {
|
||||
case 'vmess': settings = vmessToWire(values.settings); break;
|
||||
case 'vless': settings = vlessToWire(values.settings); break;
|
||||
case 'trojan': settings = trojanToWire(values.settings); break;
|
||||
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
|
||||
case 'socks': settings = simpleAuthToWire(values.settings); break;
|
||||
case 'http': settings = httpToWire(values.settings); break;
|
||||
case 'wireguard': settings = wireguardToWire(values.settings); break;
|
||||
case 'hysteria': settings = hysteriaToWire(values.settings); break;
|
||||
case 'freedom': settings = freedomToWire(values.settings); break;
|
||||
case 'blackhole': settings = blackholeToWire(values.settings); break;
|
||||
case 'dns': settings = dnsToWire(values.settings); break;
|
||||
case 'loopback': settings = loopbackToWire(values.settings); break;
|
||||
case 'vmess':
|
||||
settings = vmessToWire(values.settings);
|
||||
break;
|
||||
case 'vless':
|
||||
settings = vlessToWire(values.settings);
|
||||
break;
|
||||
case 'trojan':
|
||||
settings = trojanToWire(values.settings);
|
||||
break;
|
||||
case 'shadowsocks':
|
||||
settings = shadowsocksToWire(values.settings);
|
||||
break;
|
||||
case 'socks':
|
||||
settings = simpleAuthToWire(values.settings);
|
||||
break;
|
||||
case 'http':
|
||||
settings = httpToWire(values.settings);
|
||||
break;
|
||||
case 'wireguard':
|
||||
settings = wireguardToWire(values.settings);
|
||||
break;
|
||||
case 'hysteria':
|
||||
settings = hysteriaToWire(values.settings);
|
||||
break;
|
||||
case 'freedom':
|
||||
settings = freedomToWire(values.settings);
|
||||
break;
|
||||
case 'blackhole':
|
||||
settings = blackholeToWire(values.settings);
|
||||
break;
|
||||
case 'dns':
|
||||
settings = dnsToWire(values.settings);
|
||||
break;
|
||||
case 'loopback':
|
||||
settings = loopbackToWire(values.settings);
|
||||
break;
|
||||
}
|
||||
|
||||
const result: Raw = {
|
||||
|
||||
@@ -23,11 +23,23 @@ type Raw = Record<string, unknown>;
|
||||
// the same set of advanced fields when present. Keep order ~stable to
|
||||
// match the schema's authoring order so diffs read naturally.
|
||||
const XHTTP_STRING_KEYS = [
|
||||
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
|
||||
'xPaddingMethod', 'sessionIDPlacement', 'sessionIDKey', 'sessionIDTable',
|
||||
'sessionIDLength', 'seqPlacement', 'seqKey', 'uplinkDataPlacement',
|
||||
'uplinkDataKey', 'scMaxEachPostBytes', 'scMinPostsIntervalMs',
|
||||
'scStreamUpServerSecs', 'uplinkHTTPMethod',
|
||||
'xPaddingBytes',
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
'sessionIDPlacement',
|
||||
'sessionIDKey',
|
||||
'sessionIDTable',
|
||||
'sessionIDLength',
|
||||
'seqPlacement',
|
||||
'seqKey',
|
||||
'uplinkDataPlacement',
|
||||
'uplinkDataKey',
|
||||
'scMaxEachPostBytes',
|
||||
'scMinPostsIntervalMs',
|
||||
'scStreamUpServerSecs',
|
||||
'uplinkHTTPMethod',
|
||||
] as const;
|
||||
// Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
|
||||
// Map them onto the renamed keys so old links still import. Mirrors the
|
||||
@@ -37,11 +49,11 @@ const XHTTP_LEGACY_ALIASES: Record<string, string> = {
|
||||
sessionKey: 'sessionIDKey',
|
||||
};
|
||||
const XHTTP_NUMBER_KEYS = [
|
||||
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
|
||||
] as const;
|
||||
const XHTTP_BOOL_KEYS = [
|
||||
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
|
||||
'scMaxBufferedPosts',
|
||||
'serverMaxHeaderBytes',
|
||||
'uplinkChunkSize',
|
||||
] as const;
|
||||
const XHTTP_BOOL_KEYS = ['xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader'] as const;
|
||||
// Nested objects the inbound link bundles into the `extra` JSON blob
|
||||
// (and vmess JSON carries inline). The outbound form adapter expands
|
||||
// xmux into the XMUX sub-form (enableXmux) on load.
|
||||
@@ -127,8 +139,12 @@ function buildStream(network: string, security: string): Raw {
|
||||
break;
|
||||
case 'kcp':
|
||||
stream.kcpSettings = {
|
||||
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
|
||||
cwndMultiplier: 1, maxSendingWindow: 2097152,
|
||||
mtu: 1350,
|
||||
tti: 20,
|
||||
uplinkCapacity: 5,
|
||||
downlinkCapacity: 20,
|
||||
cwndMultiplier: 1,
|
||||
maxSendingWindow: 2097152,
|
||||
};
|
||||
break;
|
||||
case 'ws':
|
||||
@@ -142,7 +158,10 @@ function buildStream(network: string, security: string): Raw {
|
||||
break;
|
||||
case 'xhttp':
|
||||
stream.xhttpSettings = {
|
||||
path: '/', host: '', mode: 'auto', headers: {},
|
||||
path: '/',
|
||||
host: '',
|
||||
mode: 'auto',
|
||||
headers: {},
|
||||
xPaddingBytes: '100-1000',
|
||||
};
|
||||
break;
|
||||
@@ -151,13 +170,21 @@ function buildStream(network: string, security: string): Raw {
|
||||
}
|
||||
if (security === 'tls') {
|
||||
stream.tlsSettings = {
|
||||
serverName: '', alpn: [], fingerprint: '',
|
||||
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
|
||||
serverName: '',
|
||||
alpn: [],
|
||||
fingerprint: '',
|
||||
echConfigList: '',
|
||||
verifyPeerCertByName: '',
|
||||
pinnedPeerCertSha256: '',
|
||||
};
|
||||
} else if (security === 'reality') {
|
||||
stream.realitySettings = {
|
||||
publicKey: '', fingerprint: 'chrome', serverName: '',
|
||||
shortId: '', spiderX: '', mldsa65Verify: '',
|
||||
publicKey: '',
|
||||
fingerprint: 'chrome',
|
||||
serverName: '',
|
||||
shortId: '',
|
||||
spiderX: '',
|
||||
mldsa65Verify: '',
|
||||
};
|
||||
}
|
||||
return stream;
|
||||
@@ -241,12 +268,17 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
|
||||
if (!password) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
|
||||
const existing = udp.find((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander') as Raw | undefined;
|
||||
const existing = udp.find(
|
||||
(m) => m && typeof m === 'object' && (m as Raw).type === 'salamander',
|
||||
) as Raw | undefined;
|
||||
if (existing) {
|
||||
const settings = (existing.settings && typeof existing.settings === 'object'
|
||||
? existing.settings
|
||||
: (existing.settings = {})) as Raw;
|
||||
if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password;
|
||||
const settings = (
|
||||
existing.settings && typeof existing.settings === 'object'
|
||||
? existing.settings
|
||||
: (existing.settings = {})
|
||||
) as Raw;
|
||||
if (typeof settings.password !== 'string' || settings.password.length === 0)
|
||||
settings.password = password;
|
||||
return;
|
||||
}
|
||||
finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
|
||||
@@ -259,9 +291,11 @@ function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
|
||||
const ports = firstParam(params, 'mport');
|
||||
if (!ports) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const quicParams = (finalmask.quicParams && typeof finalmask.quicParams === 'object'
|
||||
? finalmask.quicParams
|
||||
: (finalmask.quicParams = {})) as Raw;
|
||||
const quicParams = (
|
||||
finalmask.quicParams && typeof finalmask.quicParams === 'object'
|
||||
? finalmask.quicParams
|
||||
: (finalmask.quicParams = {})
|
||||
) as Raw;
|
||||
const existingHop = quicParams.udpHop as Raw | undefined;
|
||||
if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
|
||||
quicParams.udpHop = { ports, interval: '5-10' };
|
||||
@@ -371,8 +405,9 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
(stream.tcpSettings as Raw).header = {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1', method: 'GET',
|
||||
path: (json.path as string ?? '/').split(',').filter(Boolean),
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ((json.path as string) ?? '/').split(',').filter(Boolean),
|
||||
headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
|
||||
},
|
||||
};
|
||||
@@ -407,11 +442,13 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
protocol: 'vmess',
|
||||
tag: typeof json.ps === 'string' ? json.ps : '',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: userSecurity }],
|
||||
}],
|
||||
vnext: [
|
||||
{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: userSecurity }],
|
||||
},
|
||||
],
|
||||
},
|
||||
streamSettings: stream,
|
||||
};
|
||||
@@ -493,7 +530,11 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
const hashIndex = link.indexOf('#');
|
||||
const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
|
||||
if (hashIndex >= 0) {
|
||||
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
|
||||
try {
|
||||
remark = decodeURIComponent(link.slice(hashIndex + 1));
|
||||
} catch {
|
||||
remark = '';
|
||||
}
|
||||
}
|
||||
const queryIndex = linkNoHash.indexOf('?');
|
||||
const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
|
||||
@@ -503,10 +544,17 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
if (rawUserInfo.includes(':')) {
|
||||
// SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
|
||||
// (a literal ':' can't appear in a base64/base64url string).
|
||||
try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; }
|
||||
try {
|
||||
userInfo = decodeURIComponent(rawUserInfo);
|
||||
} catch {
|
||||
userInfo = rawUserInfo;
|
||||
}
|
||||
} else {
|
||||
try { userInfo = Base64.decode(rawUserInfo); }
|
||||
catch { userInfo = rawUserInfo; }
|
||||
try {
|
||||
userInfo = Base64.decode(rawUserInfo);
|
||||
} catch {
|
||||
userInfo = rawUserInfo;
|
||||
}
|
||||
}
|
||||
const hostPort = core.slice(atIndex + 1);
|
||||
const colon = hostPort.lastIndexOf(':');
|
||||
@@ -515,8 +563,11 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||
port = Number(hostPort.slice(colon + 1)) || 443;
|
||||
} else {
|
||||
let decoded: string;
|
||||
try { decoded = Base64.decode(core.slice('ss://'.length)); }
|
||||
catch { return null; }
|
||||
try {
|
||||
decoded = Base64.decode(core.slice('ss://'.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const at = decoded.indexOf('@');
|
||||
if (at < 0) return null;
|
||||
userInfo = decoded.slice(0, at);
|
||||
@@ -554,7 +605,9 @@ export function parseHysteria2Link(link: string): Raw | null {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
hysteriaSettings: {
|
||||
version: 2, auth, udpIdleTimeout: 60,
|
||||
version: 2,
|
||||
auth,
|
||||
udpIdleTimeout: 60,
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: params.get('sni') ?? '',
|
||||
@@ -599,11 +652,17 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
const endpoint = host ? (port ? `${host}:${port}` : host) : '';
|
||||
|
||||
const addressRaw = firstParam(params, 'address', 'ip') ?? '';
|
||||
const address = addressRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const address = addressRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
|
||||
const allowedIPs = allowedRaw
|
||||
? allowedRaw.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
? allowedRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: ['0.0.0.0/0', '::/0'];
|
||||
|
||||
const peer: Raw = {
|
||||
@@ -613,7 +672,12 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
};
|
||||
const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
|
||||
if (psk) peer.preSharedKey = psk;
|
||||
const keepAliveRaw = firstParam(params, 'keepalive', 'persistentkeepalive', 'persistent_keepalive');
|
||||
const keepAliveRaw = firstParam(
|
||||
params,
|
||||
'keepalive',
|
||||
'persistentkeepalive',
|
||||
'persistent_keepalive',
|
||||
);
|
||||
if (keepAliveRaw !== null) {
|
||||
const k = Number(keepAliveRaw);
|
||||
if (Number.isFinite(k)) peer.keepAlive = k;
|
||||
@@ -627,7 +691,8 @@ export function parseWireguardLink(link: string): Raw | null {
|
||||
}
|
||||
const reservedRaw = firstParam(params, 'reserved');
|
||||
if (reservedRaw) {
|
||||
const reserved = reservedRaw.split(',')
|
||||
const reserved = reservedRaw
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (reserved.length > 0) settings.reserved = reserved;
|
||||
@@ -646,11 +711,11 @@ export function parseOutboundLink(link: string): Raw | null {
|
||||
const trimmed = link.trim();
|
||||
if (!trimmed) return null;
|
||||
return (
|
||||
parseVmessLink(trimmed)
|
||||
?? parseVlessLink(trimmed)
|
||||
?? parseTrojanLink(trimmed)
|
||||
?? parseShadowsocksLink(trimmed)
|
||||
?? parseHysteria2Link(trimmed)
|
||||
?? parseWireguardLink(trimmed)
|
||||
parseVmessLink(trimmed) ??
|
||||
parseVlessLink(trimmed) ??
|
||||
parseTrojanLink(trimmed) ??
|
||||
parseShadowsocksLink(trimmed) ??
|
||||
parseHysteria2Link(trimmed) ??
|
||||
parseWireguardLink(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,15 @@ const TLS_ELIGIBLE_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks'];
|
||||
const TLS_NETWORKS = ['tcp', 'ws', 'http', 'grpc', 'httpupgrade', 'xhttp'];
|
||||
const REALITY_ELIGIBLE_PROTOCOLS = ['vless', 'trojan'];
|
||||
const REALITY_NETWORKS = ['tcp', 'http', 'grpc', 'xhttp'];
|
||||
const STREAM_PROTOCOLS = ['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria', 'wireguard', 'tunnel'];
|
||||
const STREAM_PROTOCOLS = [
|
||||
'vmess',
|
||||
'vless',
|
||||
'trojan',
|
||||
'shadowsocks',
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'tunnel',
|
||||
];
|
||||
const VISION_FLOW = 'xtls-rprx-vision';
|
||||
const SS_2022_PREFIX = '2022';
|
||||
const SS_BLAKE3_CHACHA20 = '2022-blake3-chacha20-poly1305';
|
||||
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
WsStreamSettingsSchema,
|
||||
XHttpStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/stream';
|
||||
import {
|
||||
RealityStreamSettingsSchema,
|
||||
TlsStreamSettingsSchema,
|
||||
} from '@/schemas/protocols/security';
|
||||
import { RealityStreamSettingsSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
|
||||
|
||||
const NETWORK_KEY_MAP = {
|
||||
tcp: 'tcpSettings',
|
||||
@@ -33,22 +30,33 @@ function parseOrDefault(schema: SchemaWithParse, value: unknown): unknown {
|
||||
|
||||
function networkSchemaFor(network: string): SchemaWithParse | null {
|
||||
switch (network) {
|
||||
case 'tcp': return TcpStreamSettingsSchema;
|
||||
case 'kcp': return KcpStreamSettingsSchema;
|
||||
case 'ws': return WsStreamSettingsSchema;
|
||||
case 'grpc': return GrpcStreamSettingsSchema;
|
||||
case 'httpupgrade': return HttpUpgradeStreamSettingsSchema;
|
||||
case 'xhttp': return XHttpStreamSettingsSchema;
|
||||
case 'hysteria': return HysteriaStreamSettingsSchema;
|
||||
default: return null;
|
||||
case 'tcp':
|
||||
return TcpStreamSettingsSchema;
|
||||
case 'kcp':
|
||||
return KcpStreamSettingsSchema;
|
||||
case 'ws':
|
||||
return WsStreamSettingsSchema;
|
||||
case 'grpc':
|
||||
return GrpcStreamSettingsSchema;
|
||||
case 'httpupgrade':
|
||||
return HttpUpgradeStreamSettingsSchema;
|
||||
case 'xhttp':
|
||||
return XHttpStreamSettingsSchema;
|
||||
case 'hysteria':
|
||||
return HysteriaStreamSettingsSchema;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function securitySchemaFor(security: string): { key: string; schema: SchemaWithParse } | null {
|
||||
switch (security) {
|
||||
case 'tls': return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
|
||||
case 'reality': return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
|
||||
default: return null;
|
||||
case 'tls':
|
||||
return { key: 'tlsSettings', schema: TlsStreamSettingsSchema };
|
||||
case 'reality':
|
||||
return { key: 'realitySettings', schema: RealityStreamSettingsSchema };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,7 @@ export function validateRealityMaxClientVer(max: string, min: string): string |
|
||||
if (!maxParts || !minParts) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (maxParts[i] !== minParts[i]) {
|
||||
return maxParts[i] < minParts[i]
|
||||
? 'pages.inbounds.form.maxClientVerBelowMin'
|
||||
: undefined;
|
||||
return maxParts[i] < minParts[i] ? 'pages.inbounds.form.maxClientVerBelowMin' : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -255,12 +253,7 @@ export function normalizeXhttpForWire(
|
||||
|
||||
if (out.xPaddingObfsMode !== true) {
|
||||
delete out.xPaddingObfsMode;
|
||||
dropEmptyStrings(out, [
|
||||
'xPaddingKey',
|
||||
'xPaddingHeader',
|
||||
'xPaddingPlacement',
|
||||
'xPaddingMethod',
|
||||
]);
|
||||
dropEmptyStrings(out, ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod']);
|
||||
}
|
||||
|
||||
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
|
||||
@@ -297,13 +290,7 @@ export function normalizeSockoptForWire(
|
||||
'mark',
|
||||
]);
|
||||
|
||||
dropFalseFlags(out, [
|
||||
'acceptProxyProtocol',
|
||||
'tcpFastOpen',
|
||||
'tcpMptcp',
|
||||
'penetrate',
|
||||
'V6Only',
|
||||
]);
|
||||
dropFalseFlags(out, ['acceptProxyProtocol', 'tcpFastOpen', 'tcpMptcp', 'penetrate', 'V6Only']);
|
||||
|
||||
if (out.tproxy === 'off') delete out.tproxy;
|
||||
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
|
||||
|
||||
Reference in New Issue
Block a user