Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.
Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
"hysteria2":` branches across client.go, inbound.go, outbound.go,
xray.go, port_conflict.go, xray/api.go, subService.go,
subJsonService.go, subClashService.go
- Stale #4081 comments
Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version
Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.
Same per-inbound batching strategy as BulkDelete. The previous code
called Update once per email, which itself looped through each inbound
the client belonged to — reparsing the same settings JSON, calling
RemoveUser+AddUser on xray, and running SyncInbound for every single
email. For 200 emails in one inbound that's 200 JSON read/write cycles
and 400 xray runtime calls.
The new BulkAdjust groups emails by inbound and per inbound:
- locks once, reads settings JSON once
- mutates expiryTime/totalGB in place for every target client
- writes the inbound and runs SyncInbound once
ClientTraffic rows are updated with a single per-email query at the end
(values differ per client so they can't be folded into one statement).
For local-node inbounds the xray runtime calls are skipped entirely.
The AddUser payload only contains email/id/security/flow/auth/password/
cipher — none of which change in an adjust — so RemoveUser+AddUser was
a no-op that briefly flapped active users. Limit enforcement is driven
by the panel's traffic loop reading ClientTraffic, not by xray-core.
For remote-node inbounds rt.UpdateUser is preserved so the remote panel
receives the new totals/expiry.
Skip+report semantics match BulkDelete: any per-email error leaves that
email's record/traffic untouched and is returned in Skipped[].
Replace the panel-side fan-out (Promise.all of single /add and /del
calls) that raced on the shared inbound config and capped throughput at
roughly one round-trip per client. New endpoints batch the work on the
server:
- POST /panel/api/clients/bulkDel { emails, keepTraffic }
- POST /panel/api/clients/bulkCreate [ {client, inboundIds}, ... ]
BulkDelete groups emails by inbound and performs a single
read-modify-write per inbound (one JSON parse, one marshal, one Save)
instead of N. Per-row DB cleanups (ClientInbound, ClientTraffic,
InboundClientIps, ClientRecord) are batched with WHERE...IN queries.
Per-email failures are reported via Skipped[] and processing continues.
BulkCreate iterates payloads sequentially through the same Create path
single-add uses, so heterogeneous batches (different inboundIds, plans)
remain valid in one round-trip.
Frontend bulkDelete/bulkCreate hooks parse the new response shape
({ deleted|created, skipped[] }) and the bulk-add modal now posts a
single request instead of fanning out emails.
useClients.removeMany was firing all DELETEs in parallel via Promise.all.
The 3x-ui backend mutates a single config JSON per request (read /
modify / write), so 20 concurrent deletes raced on the same file: every
request reported success, but only the last writer's copy stuck — about
half the selected clients reappeared after the toast. Replace the
parallel fan-out with a sequential for-of loop so each delete sees the
committed state of the previous one. The trade-off is total latency
(20 * ~250ms = ~5s) which is the correct behavior until the backend
grows a proper /bulkDel endpoint.
Also rename the Alert `message` prop to `title` in
ClientBulkAdjustModal to clear the AntD v6 deprecation warning.
Adds JSON fixtures under golden/fixtures/{dns,dns-server,balancer,rule}
plus three vitest files that parse them through the new schemas and
snapshot the result.
dns/: minimal (servers as strings) + full (every top-level field plus
hosts with geosite/domain/full prefixes and 5 mixed string/object
servers covering fakedns, localhost, https://, tcp://, quic+local://).
dns-server/: full (every DnsServerObject field) + legacy-expectips
(asserts the z.preprocess that migrates the legacy `expectIPs` key
into the canonical `expectedIPs`).
balancer/: random-minimal (default strategy by omission), roundrobin,
leastping, leastload-full (covers all StrategySettings fields and both
regexp=true|false costs).
rule/: minimal, full (exercises every RuleObject field including
localPort, localIP, process aliases like `self/`, all four protocol
enum values, ip negation `!geoip:`, attrs with regexp value, and the
WebhookObject with deduplication+headers), balancer-routed (uses
balancerTag instead of outboundTag), port-number (port as a number to
prove the union(number,string) accepts both).
Adds first-class Zod schemas for the xray-core DNS block and routing
sub-objects (Balancer, Rule) matching the documented shape at
https://xtls.github.io/config/dns.html and
https://xtls.github.io/config/routing.html, then wires the
DnsServerModal and BalancerFormModal up to those schemas.
schemas/dns.ts (new):
- DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem)
- DnsHostsSchema record(string -> string | string[])
- DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess
to migrate legacy `expectIPs` -> `expectedIPs` alias)
- DnsServerEntrySchema = string | DnsServerObject (xray accepts both)
- DnsObjectSchema with all documented fields and defaults
schemas/routing.ts (new):
- RuleProtocolSchema enum (http/tls/quic/bittorrent)
- RuleWebhookSchema (url/deduplication/headers)
- RuleObjectSchema covering every documented field (domain/ip/port/
sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/
inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/
webhook) with type=literal('field').default('field')
- BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad)
- BalancerCostObjectSchema {regexp,match,value}
- BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs)
- BalancerStrategySchema + BalancerObjectSchema
schemas/xray.ts:
- routing.rules: was loose 3-field object, now z.array(RuleObjectSchema)
- routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema)
- dns: was 2-field loose, now full DnsObjectSchema
- BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum)
instead of z.string(); fallbackTag defaults to ''; settings? added
for leastLoad
DnsServerModal (full Pattern A rewrite):
- useState/DnsForm interface -> Form.useForm<DnsServerForm>()
- manual domain/expectedIP/unexpectedIP list -> Form.List
- antdRule on address/port/timeoutMs for inline validation
- preserves legacy collapse-to-bare-string behavior on submit
BalancerFormModal:
- Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/
Baselines/Costs) wired to BalancerStrategySettingsSchema
- Strategy options derived from schema enum
- Cost rows with regexp/literal switch + match + value
- required prop on Tag and Selector for red asterisk visual
BalancersTab:
- BalancerRecord interface -> type alias to BalancerObject
- onConfirm now propagates strategy.settings to wire when leastLoad
- Removes useMemo wrapping `columns` array. The memo had deps
[t, isMobile] (with an eslint-disable) so the column render
functions kept their original closure over `openEdit`. Once a
balancer was created and the user clicked the edit button, the
stale openEdit fired with empty `rows`, so rows[idx] was undefined
and the modal opened blank. Columns are cheap to rebuild each
render, so dropping the memo is the right fix.
DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types.
translations (en-US, fa-IR): add the previously-missing
pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired
keys so antdRule surfaces a real message instead of the raw i18n key.
uplinkHTTPMethod was wrapped Form.Item -> Form.Item(shouldUpdate) ->
Select, which broke AntD's value/onChange injection (AntD only clones
the immediate child). Restructured so shouldUpdate is the outer wrapper
and Form.Item(name) directly wraps the Select.
Also drop empty-string fields from xhttpSettings in the wire payload —
fields like uplinkHTTPMethod, sessionPlacement, seqPlacement,
xPaddingKey default to '' meaning "use server default", so they
shouldn't appear in JSON as "field": "".
Adds placeholder text to the 3 xhttp Selects so the form reflects the
current value after JSON paste.
The QUIC Params and UDP Hop toggles previously persisted as separate
boolean flags (enableQuicParams / hasUdpHop) which weren't part of the
xray wire format and weren't restored when a config was pasted into the
modal. Use data presence as the single source of truth: the switch is
on iff the corresponding sub-object exists. Switching off clears it
back to undefined.
Move the network/security schema-default filler out of inbound-from-db.ts
into stream-defaults.ts so other consumers can reuse it without dragging
in the DBInbound-specific code path.
The previous B21 patch only blurred on user-initiated tab clicks via
onTabChange. Two other paths still set activeKey while a JSON-tab
input retained focus:
- importLink: after a successful share-link parse, setActiveKey('1')
switched to the form tab while the user's focus was still on the
Input.Search they just pressed Enter in. Chrome logged the same
"Blocked aria-hidden" warning because the panel they were leaving
became aria-hidden synchronously, with their input still focused.
- onTabChange entering the JSON tab: also did a bare setActiveKey
with no blur, so going from a focused form input INTO the JSON
tab could trip the warning in reverse.
Fix: centralized switchTab(key) that blurs document.activeElement
sync before calling setActiveKey. Every internal tab transition
(importLink, onTabChange both directions) now routes through it.
The single setActiveKey('1') in the open-modal useEffect is left as
a plain setter because there's no focused input at modal-open time.
Two issues surfaced on Outbound save:
1. Crash: `Cannot read properties of undefined (reading 'enabled')` at
formValuesToWirePayload. The modal hides the Mux switch entirely
for non-stream protocols (dns/freedom/blackhole/loopback) and for
stream protocols when isMuxAllowed gates it out (xhttp, vless+flow).
With the field never registered, validateFields() returns no `mux`
key — `values.mux.enabled` then dereferences undefined.
Fix: optional chain `values.mux?.enabled` so missing mux skips the
mux clause silently. Documented why mux can be absent.
2. Chrome a11y warning: "Blocked aria-hidden on an element because its
descendant retained focus" — when the user has an input focused
inside one Tab panel and switches to another tab, AntD marks the
outgoing panel aria-hidden while focus is still inside. The browser
warns, but the focused control is now invisible to AT users.
Fix: blur the active element before setActiveKey in onTabChange.
User-reported vless share link with full xhttp + reality + finalmask
config failed to round-trip on outbound import. The inbound link
generator emits three payloads the outbound parser was ignoring:
1. `extra=<json>` — bundles advanced xhttp knobs (xPaddingBytes,
scMaxEachPostBytes, scMinPostsIntervalMs, padding-obfs keys,
etc.). applyXhttpStringFromParams now JSON.parses this and
merges the fields into xhttpSettings via the same JSON-branch
logic used by vmess.
2. `x_padding_bytes=<range>` — snake_case alias the inbound emits
alongside the camelCase form. Now applied before camelCase so
explicit `xPaddingBytes` URL params still win.
3. `fm=<json>` — full finalmask object including quicParams.udpHop
and tcp/udp mask arrays. New applyFinalMaskParam attaches the
decoded object to streamSettings.finalmask. Wired into both
parseVlessLink and parseTrojanLink.
Tests:
- Real B20 link parses with xhttp + reality + finalmask all populated
- Precedence: camelCase URL > extra JSON > snake_case alias > default
- Malformed extra JSON falls through without crashing the parser
300/300 pass.
User report: "streamSettings.finalmask.quicParams.udpHop.interval:
Invalid input: expected string, received number".
Three-part fix:
- FinalMaskForm: Hop Interval input changed from InputNumber to
Input with "e.g. 5-10" placeholder. xray-core spec says interval
is a range string like '5-10' (seconds between min-max hops),
not a single number.
- FinalMaskForm: defaultQuicParams() seeds interval: '5-10' instead
of the broken `interval: 5`.
- QuicUdpHopSchema: preprocess coerces number → string for legacy
DB rows that were written by the now-fixed buggy UI. Stops the
load-time validation crash on existing inbounds.
Tests still 296/296.
Smoke-testing the new inboundFromDb helper surfaced two regressions
that the strict lib/xray link generators expose when fed raw DB
streamSettings without per-network sub-keys.
1. genVlessLink / genTrojanLink crash on `stream.tcpSettings.header`
when streamSettings lacks `tcpSettings` (true for slim list rows
and for handcrafted minimal-JSON inbounds). The legacy
Inbound.fromJson chain populated TcpStreamSettings via its own
constructor; the new helper now does the same by parsing the raw
<network>Settings sub-object through the matching Zod schema and
merging schema defaults onto whatever the DB stored.
2. genVlessLink writes `encryption=undefined` into the share URL
when settings lacks the `encryption: 'none'` literal that vless
wire JSON normally carries. Fixed by running raw settings through
InboundSettingsSchema.safeParse() to populate per-protocol
defaults (encryption, decryption, fallbacks, etc.) the same way
the legacy class fromJson chain did.
Same pattern applied to security branch (tls/realitySettings).
Tests: src/test/inbound-from-db.test.ts covers
- JSON-string / object / empty settings coercion
- genInboundLinks vless (TCP/none, with encryption=none)
- genWireguardConfigs + genWireguardLinks peer fanout
- genAllLinks trojan with TLS sub-defaults applied
- protocol-capability helpers with raw shapes
- getInboundClients across vless/SS-single/non-client protocols
296/296 pass.
Delete models/inbound.ts (3,359 lines) and outbound.ts (2,405).
The Inbound/Outbound classes and ~50 sub-classes are replaced by
Zod-typed data + pure functions in lib/xray/*.
Consumer migration off dbInbound.toInbound():
- useInbounds: isSSMultiUser({protocol, settings}) directly
- QrCodeModal: genWireguardConfigs/Links/AllLinks from lib/xray
- InboundList: derives tags from streamSettings raw fields
- InboundsPage: clone via raw JSON, fallback projection via
schema-shape stream object, exports via genInboundLinks
- InboundInfoModal: builds an InboundInfo facade locally from
raw streamSettings (host/path/serverName/serviceName per
network), canEnableTlsFlow + isSS2022 from lib/xray
New helper: lib/xray/inbound-from-db.ts exposes
inboundFromDb(raw) converting a raw DBInbound row into a
schema-typed Inbound for the link-generation orchestrators.
DBInbound trimmed: drops toInbound, isMultiUser, hasLink,
genInboundLinks, _cachedInbound. Imports Protocols from
@/schemas/primitives now that ./inbound is gone.
Bundled Phase 2 fixes:
- Outbound modal: Form.useWatch with preserve: true so the
stream block doesn't gate itself out when network is unmounted
- Inbound form adapter: pruneEmpty preserves empty objects;
per-protocol client field projection via Zod safeParse;
sniffing collapse to {enabled:false}
- useClients invalidateAll also invalidates inbounds.root()
- IndexPage Config modal top/maxHeight polish
Tests: 283/283 pass. typecheck/lint clean.
Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was
modeled as a separate top-level protocol when it's really just hysteria
v2. The xray transports/hysteria.html docs also pin the hysteria stream
to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the
previous schema carried legacy congestion/up/down/udphop/window knobs
that aren't part of the wire contract.
Hysteria2 removal:
- Drop 'hysteria2' from ProtocolSchema enum and Protocols const
- Drop hysteria2 branches from inbound/outbound discriminated unions
- Drop createDefaultHysteria2InboundSettings / OutboundSettings
- Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts
- Drop hysteria2 case in getInboundClients / genLink (fell through to
the hysteria handler anyway)
- Update client form modals' MULTI_CLIENT_PROTOCOLS sets
- Remove hysteria2-basic fixture + snapshot entries (14 capability
cases, 1 protocols fixture, 1 inbound-defaults factory)
- Keep parseHysteria2Link() outbound parser since hysteria2:// is the
share-link URI prefix for hysteria v2
Hysteria stream alignment with xtls docs:
- HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/
masquerade per transports/hysteria.html
- Masquerade type adds '' (default 404 page) and defaults to it
- Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/
Keep alive/Disable Path MTU controls and the receive-window note
- newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed
shape; outbound-link-parser emits the trimmed shape too
- InboundFormModal Masquerade Select gains the default option
New TUN inbound schema:
- Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/
userLevel/autoSystemRoutingTable/autoOutboundsInterface
- Wire into ProtocolSchema enum, InboundSettingsSchema discriminated
union, createDefaultInboundSettings dispatcher
Other Phase 2 smoke fixes folded in:
- Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire
shape is Record<string,string> and the List was producing arrays
- Hysteria onValuesChange seeds full TLS schema defaults + one
empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN
were undefined before)
- HTTP/Mixed accounts Add button auto-fills user/pass with
RandomUtil.randomLowerAndNum
- Hysteria security tab gates the 'none' radio out — TLS only
- Hysteria stream tab drops the inbound Auth password field (xray
inbound auth is per-user via 'users', not stream-level)
- Reality onSecurityChange auto-randomizes target/serverNames/
shortIds and fetches an X25519 keypair
- Tag and DB-side fields (up/down/total/expiryTime/
lastTrafficResetTime/clientStats/security) gain hidden Form.Items
so validateFields keeps them in the wire payload (rc-component
form strips unregistered fields)
- WireGuard inbound auto-seeds one peer with generated keypair,
allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy
- WireGuard peer rows separated by Divider with the Peer N title
and a small inline remove button (titlePlacement="center")
AntD Tabs with the `items` API lazy-mounts inactive tab panes by
default. The Form.Items inside an unvisited tab never register, so:
- Form.useWatch on a parent path (e.g. 'sniffing') returns a partial
view containing only registered children. Until the user clicked the
Sniffing tab, Advanced > Sniffing JSON showed `{sniffing: {}}`
instead of the full default object set by setFieldsValue.
- After visiting the Sniffing tab once, the `sniffing.enabled` Form.Item
registered, so useWatch suddenly returned `{enabled: false}` — still
partial, because the rest of the sniffing children only register when
their Form.Items mount in conditional sub-sections.
Setting `forceRender: true` on every tab item forces all tab panes to
mount at modal open. Every Form.Item registers immediately; the watch
result reflects the full form value seeded by buildAddModeValues. This
also likely resolves the earlier "Invalid discriminator value" error
on submit, which surfaced when streamSettings had an unregistered
security field whose Form.Item hadn't mounted yet.
XHTTP showed blank Selects for Session Placement / Sequence Placement /
Padding Method / Uplink HTTP Method (and several other knobs). Those
fields have a literal "" (empty string) value in the schema, which the
Select renders as "Default (path)" / "Default (repeat-x)" / etc.
The form field was `undefined`, not `""`, so the Select showed blank
instead of the labelled default option.
newStreamSlice in InboundFormModal hand-rolled per-network seed
objects with only a handful of fields. Replaced with
{Tcp,Kcp,Ws,Grpc,HttpUpgrade,XHttp}StreamSettingsSchema.parse({}) so
every default declared in the schema populates the form on network
switch. Same change in buildAddModeValues for the initial TCP state.
QUIC Params (FinalMaskForm) had the same shape on a smaller scale —
defaultQuicParams() only seeded congestion + debug + udpHop. The
schema's other fields are .optional() (no Zod default) so a schema
parse won't help. Hard-coded the xray-core / hysteria recommended
values (maxIdleTimeout 30, keepAlivePeriod 10, brutalUp/Down 0,
maxIncomingStreams 1024, four window sizes) so the InputNumber
controls render with usable starting values instead of blank.
B15 — Inbound TCP HTTP camouflage exposed Host / Path / Method / Version
/ request-headers inputs. Per Xray docs
(https://xtls.github.io/config/transports/raw.html#httpheaderobject),
the `request` object is honored only by outbound proxies; the inbound
listener reads `response`. Those inputs were writing dead data the
server ignored. Removed them from the inbound modal; only Response
{version, status, reason, headers} remain. The toggle still seeds an
empty request object so the wire shape stays valid against the schema.
B16 — KCP Uplink / Downlink inputs bound to non-existent form fields
`upCap` / `downCap`, while the schema (and wire) use `uplinkCapacity` /
`downlinkCapacity`. Renamed the Form.Items to the schema names so
defaults populate and saves persist. Also corrected newStreamSlice('kcp')
to seed the four KCP defaults (uplinkCapacity / downlinkCapacity /
cwndMultiplier / maxSendingWindow) — the missing two were why
"CWND Multiplier" and "Max Sending Window" still showed empty after
switching to KCP.
B13 — FinalMaskForm used absolute paths like
['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names
inside Form.List render props. AntD's Form.List prefixes Form.Item
names with the list's own name, so the actual storage path became
['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask',
'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show
the 'fragment' default after add(), and the sub-form for the picked
type never rendered (Fragment/Sudoku/HeaderCustom).
Rewrote FinalMaskForm to use RELATIVE names inside every Form.List
context (TCP/UDP outer list + nested clients/servers/noise inner
lists). Added a `listPath` prop on the items so the shouldUpdate
guard and the side-effect setFieldValue calls (resetting `settings`
when type changes) can still address the absolute path; the
displayed Form.Items use the relative form (`[fieldName, 'type']`).
Replaced top-level Form.useWatch on nested paths with
<Form.Item shouldUpdate> blocks reading via getFieldValue, same
pattern as the earlier B5 fix — Form.useWatch on paths inside
Form.List doesn't re-fire reliably in AntD 6.4.3.
B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the
new XSettings blob as `{}` so every field showed as empty. The
legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored
those defaults in onNetworkChange and seeded the initial
tcpSettings.header in buildAddModeValues so even the default TCP
state shows the HTTP-camouflage Switch in the correct off state
instead of an undefined header object.
The file InboundFormModal.css existed but was never imported, so every
class in it had no effect — including:
- .vless-auth-state — the "Selected: <auth>" caption next to the X25519/
ML-KEM/Clear button row stayed inline next to Clear instead of
display:block beneath the row
- .advanced-shell / .advanced-panel — the Advanced tab's header / panel
framing was missing
- .advanced-editor-meta — the per-section help text under each Advanced
sub-tab had no spacing
- .wg-peer — wireguard peer rows had no top margin
Add a side-effect import of the CSS file at the top of the modal. No
other change needed; the legacy modal must have either imported it or
had a global import that the new modal didn't inherit.
B10 — FinalMaskForm TCP Mask: after adding a mask and picking a Type
(Fragment/Header Custom/Sudoku), the type-specific sub-forms didn't
render. TcpMaskItem read `type` via Form.useWatch on a path inside
Form.List, which doesn't re-fire reliably in AntD 6.4.3 — same root
cause as the earlier B1/B2/B5 reactivity issues. Replaced with a
<Form.Item shouldUpdate> wrapper that reads `type` via getFieldValue
inside the render prop.
B11 — Advanced sub-tabs (settings / streamSettings / sniffing) showed
just the inner value (e.g. `{clients:[],decryption:"none",...}`), but
the legacy modal wrapped each slice with its key envelope (e.g.
`{settings:{...}}`) so the JSON matches the wire shape's slice and
round-trips cleanly from copy-pasted inbound configs. Added a
`wrapKey` prop to AdvancedSliceEditor that wraps/unwraps the value
on render/write; the three sub-tabs now pass settings / streamSettings
/ sniffing as their wrapKey.
A run of resets dropped the per-bug commits 1401d833 / 5b1ae450 /
5bce0dc5 / 4007eec7. Re-landing all fixes against the same files in one
commit to avoid another rebase-style drop.
B1 — Transmission Select / External Proxy + Sockopt switches didn't
react after click. AntD 6.4.3 Form.useWatch on nested paths doesn't
re-fire reliably after `setFieldValue('streamSettings', cleaned)` on
the parent. Bound Transmission via `name={['streamSettings', 'network']}`
and wrapped the two switches in `<Form.Item shouldUpdate>` blocks that
read state via getFieldValue.
B2 — Security regressed from `Radio.Group buttonStyle="solid"` to a
Select dropdown, and disable state didn't refresh because tlsAllowed/
realityAllowed were derived at the top of the component. Restored
Radio.Button group and moved canEnableTls/canEnableReality evaluation
inside the shouldUpdate render prop.
B3 — Advanced tab "All" sub-tab was missing. Added it as the first
item with a new AdvancedAllEditor that round-trips top-level fields +
the three nested slices on edit.
B4 — Advanced tab title/subtitle and per-section help text were gone.
Wrapped the Tabs in the existing `.advanced-shell` / `.advanced-panel`
structure and restored the `.advanced-editor-meta` help under each
sub-tab using existing i18n keys.
B5 — TLS / Reality sub-forms didn't render when selecting tls or
reality on the Security tab. The `{security === 'tls' && ...}` and
`{security === 'reality' && ...}` conditionals used a stale top-level
useWatch value. Wrapped both in <Form.Item shouldUpdate> blocks that
read `security` via getFieldValue.
B6 — Advanced JSON editors stale after Stream/Sniffing changes. The
editors seeded text via lazy useState and AntD Tabs renders all panes
upfront, so the Advanced tab was already mounted with stale data.
Both AdvancedSliceEditor and AdvancedAllEditor now subscribe via
Form.useWatch and re-sync the text buffer when the watched JSON
differs from a lastEmitRef (the serialization at the moment of our
own last accepted write). User typing doesn't trigger re-sync because
setFieldValue updates lastEmitRef too. (A prior attempt added
`destroyOnHidden` to the outer Tabs but broke conditional tab items
when the unmounted Form.Item for `protocol` lost its value —
abandoned in favor of useWatch reactivity.)
B7 — HeaderMapEditor + button did nothing. addRow() appended a blank
{name:'', value:''} row, but commit() filtered it via rowsToMap before
reaching the form, so AntD saw no change and didn't re-render. The
editor now keeps a local rows state so blank rows survive during
editing; only filled rows are emitted to onChange.
B9 — Sniffing destOverride defaults (HTTP/TLS/QUIC/FAKEDNS) were not
pre-checked on a fresh Add Inbound. buildAddModeValues() seeded
sniffing: {} which left destOverride undefined. Now seeds with
SniffingSchema.parse({}) so the Zod defaults populate.
Mainline migration goal — replace class-based xray models with Zod
schemas as the single source of truth + drive all forms through
AntD `Form.useForm` + `antdRule(schema.shape.X)` — is complete.
Remaining items are incremental polish.
Rewrite FinalMaskForm.tsx from a class-coupled component (mutated
stream.finalmask.tcp[] via .addTcpMask/.delTcpMask methods, notified
parent via onChange callback) into a Pattern A sub-form: takes a
NamePath base, a FormInstance, and the surrounding network/protocol,
then composes Form.List + Form.Item at absolute paths under that base.
All array structures use nested Form.List — tcp/udp mask arrays, the
clients/servers groups in header-custom (Form.List of Form.List of
ItemEditor), and the noise list. Type Selects use onChange to reset
the settings sub-object via form.setFieldValue, mirroring the legacy
changeMaskType behavior. The kcp.mtu side effect on xdns type change
is preserved.
Wired into both InboundFormModal and OutboundFormModal stream tabs,
placed after the sockopt section. The component is the first Pattern A
consumer of nested Form.List inside another Form.List, so it stands
as the reference for future nested-array sub-forms.
Extract the XHTTP key-mapping into typed string/number/bool key arrays
applied by both the URL query-param branch and the vmess JSON branch.
The parser now covers xPaddingObfsMode + xPaddingKey/Header/Placement/
Method, sessionKey/seqKey/uplinkData{Placement,Key}, noSSEHeader,
scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes, and
uplinkHTTPMethod alongside the previous five XHTTP fields. Two new
round-trip tests cover the padding-obfs surface on both link forms.
Pick up xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs,
uplinkChunkSize, and noGRPCHeader from both vmess:// JSON and the URL
query-param parsers (vless/trojan). The advanced xmux/padding-obfs/
reality-shortId knobs still wait on a follow-up; this slice unblocks
the common case where a phone-issued xhttp link carries non-default
padding or post sizes.
Add method/version inputs, request header map, and full response
sub-section (version/status/reason/headers) to OutboundFormModal so the
outbound side can configure the same HTTP-1.1 obfuscation knobs the
inbound side already exposed.
Add the four remaining SockoptStreamSettings fields that were
edit-via-JSON-only after the initial outbound modal rewrite:
- TCP keep-alive idle (s) — tcpKeepAliveIdle, time before sending
the first probe on an idle TCP connection.
- TCP max segment — tcpMaxSeg, override the default MSS.
- TCP window clamp — tcpWindowClamp, cap the TCP receive window.
- Trusted X-Forwarded-For — trustedXForwardedFor, list of trusted
proxy hostnames/CIDRs whose XFF headers Xray will honor.
The outbound sockopt section now exposes all 17 SockoptStreamSettings
fields from the schema. The InboundFormModal's sockopt section has
its own field list (closer to the legacy class) and is unchanged.
Restore the inbound side of Hysteria stream configuration that was
previously hidden — the legacy modal exposed these knobs but the
Pattern A rewrite gated them out.
schemas/protocols/stream/hysteria.ts:
- HysteriaMasqueradeSchema covers the inbound-only masquerade wire
shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost,
insecure, content, headers, statusCode. The three masquerade types
cover the spectrum: reverse-proxy upstream, serve static files, or
return a fixed string body.
- HysteriaStreamSettingsSchema gains 3 inbound-side optional fields:
protocol, udpIdleTimeout, masquerade. Outbound side is untouched
(the legacy class accepted both wire shapes via the same struct).
InboundFormModal.tsx:
- New hysteria stream sub-form section in streamTab, gated by
protocol === HYSTERIA. Fields: version (disabled, locked to 2),
auth, udpIdleTimeout, masquerade Switch + nested type-Select with
three conditional sub-blocks (proxy URL+rewriteHost+insecure,
file dir, string statusCode+body+headers).
- onValuesChange cascade: switching TO hysteria seeds streamSettings
with the hysteria branch (forcing network='hysteria' + TLS); switching
AWAY from hysteria snaps back to TCP so the standard network
selector has a valid starting point.
masquerade headers use the HeaderMapEditor v1 component.
The legacy outbound modal could import a vmess://, vless://, trojan://,
ss://, or hysteria2:// share link via a Convert button on the JSON
tab. Restore that UX with a focused pure-function parser.
lib/xray/outbound-link-parser.ts:
- parseVmessLink: base64 JSON, maps net/tls + per-network params onto
the discriminated stream branch.
- parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow
query params, dispatches transport via buildStream + applies
security params via applySecurityParams.
- parseTrojanLink: same URL pattern, defaults security to tls.
- parseShadowsocksLink: both modern (base64 userinfo@host:port) and
legacy (base64 of whole thing) ss:// formats.
- parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes,
uses the hysteria stream branch with version=2 + TLS h3.
- parseOutboundLink dispatcher returns the first non-null parser
result, or null when no scheme matches.
test/outbound-link-parser.test.ts:
- 13 cases covering happy paths for each protocol family plus malformed
input, ss:// dual-format handling, hy2:// alias.
OutboundFormModal.tsx:
- Import button on the JSON tab Input.Search; on success, parsed
payload flows through rawOutboundToFormValues, the form is reset,
and we switch back to the Basic tab.
- Tag is preserved when the parsed link does not carry one.
Out of scope: advanced fields the legacy parser handled (xmux, padding
obfs, reality short IDs, finalmask from fm= param). Power users can
finish the import in the form after the basics land.
Complete the TCP HTTP camouflage UI on the inbound side.
Already there from the previous symmetric host/path commit:
- Request host (string[] via comma-string)
- Request path (string[] via comma-string)
This commit adds:
- Request headers (V2 map: name -> string[]) via HeaderMapEditor.
- Response version (defaults to '1.1' when camouflage toggles on).
- Response status (defaults to '200').
- Response reason (defaults to 'OK').
- Response headers (V2 map) via HeaderMapEditor.
The HTTP camouflage Switch seeds both request and response sub-objects
on toggle-on so xray-core sees a valid TcpHeader.http shape from the
first save. Without the response seed, partial fills would emit a
schema-incomplete response block that xray-core might reject.
Replace the 'edit via JSON' deferred-features hint with the full XHTTP
sub-form matching the legacy modal's XhttpFields helper.
schemas/protocols/stream/xhttp.ts:
- New XHttpXmuxSchema: 6 connection-multiplexing knobs
(maxConcurrency, maxConnections, cMaxReuseTimes, hMaxRequestTimes,
hMaxReusableSecs, hKeepAlivePeriod).
- XHttpStreamSettingsSchema gains 5 outbound-only fields and one
UI-only toggle: scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader,
xmux, enableXmux.
outbound-form-adapter.ts:
- New stripUiOnlyStreamFields() drops xhttpSettings.enableXmux on the
way to wire so the panel never embeds the UI toggle into the saved
config. xray-core ignores unknown fields anyway, but the panel reads
back its own emitted JSON, so a clean wire shape matters.
OutboundFormModal.tsx:
- Headers editor (HeaderMapEditor v1) for xhttpSettings.headers.
- Padding obfs Switch + 4 conditional fields (key/header/placement/
method) when on.
- Uplink HTTP method Select with GET disabled outside packet-up.
- Session placement + session key (key shown when placement != path).
- Sequence placement + sequence key (same pattern).
- packet-up mode: scMinPostsIntervalMs, scMaxEachPostBytes, uplink
data placement + key + chunk size (key/chunk-size shown when
placement != body).
- stream-up / stream-one mode: noGRPCHeader Switch.
- XMUX Switch + 6 nested fields when on.
Two small UX wins on the InboundFormModal Fallbacks card:
- Per-row Move up / Move down buttons (ArrowUp/Down icons) that swap
adjacent indices. Order survives reloads via sortOrder (rebuilt from
index on save). First row's Up button + last row's Down button are
disabled.
- 'Add all' button next to 'Add fallback' that one-shot inserts a
fresh row for every eligible inbound (every option in
fallbackChildOptions) not already wired up. Disabled when every
eligible inbound is already covered. Convenient for operators
running catch-all routing across every host on the panel.
Add the 7th branch to NetworkSettingsSchema for Hysteria transport.
schemas/protocols/stream/hysteria.ts:
- HysteriaStreamSettingsSchema covers the full wire shape: version=2,
auth, congestion (''|'brutal'), up/down bandwidth strings, optional
udphop sub-object for port-hopping, receive-window tuning fields,
maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery.
schemas/protocols/stream/index.ts:
- NetworkSchema gains 'hysteria'.
- NetworkSettingsSchema gains the 7th branch
{ network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }.
OutboundFormModal.tsx:
- NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria
protocols; when protocol === 'hysteria', a 7th option is appended
(matches the legacy [...NETWORKS, 'hysteria'] gate).
- newStreamSlice handles the 'hysteria' case with sensible defaults
matching the legacy HysteriaStreamSettings constructor.
- New sub-form when network === 'hysteria': 8 common fields (auth,
congestion, up, down, udphop Switch + 3 nested fields when on,
maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery).
- Receive-window tuning fields are still edit-via-JSON (rarely
touched + would clutter the form).
Add a single reusable header-map editor that handles the two wire
shapes Xray uses:
- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
Each header can repeat (RFC 7230 §3.2.2).
Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.
Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers
XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.
OutboundFormModal:
- Sockopt section gains 5 common-but-rarely-tweaked knobs:
acceptProxyProtocol, tproxy (off/redirect/tproxy), tcpcongestion
(bbr/cubic/reno), V6Only, tcpUserTimeout. The remaining sockopt
fields (tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp,
trustedXForwardedFor) are still edit-via-JSON; they are deeply
tunable and not commonly touched.
InboundFormModal:
- TCP HTTP camouflage gains host + path inputs symmetric to the
outbound side. Switch ON seeds request with sensible defaults
(version 1.1, method GET, path ['/'], empty headers). The two
inputs use the same normalize/getValueProps comma-string ↔
string[] dance the outbound side uses, so the wire shape stays
identical to what xray-core expects.
Three small wins from the post-atomic-swap deferred list:
- VLESS Vision testpre + testseed: shown only when flow ===
'xtls-rprx-vision' (mirrors the legacy canEnableVisionSeed gate).
testseed binds to a Select mode='tags' with a normalize() that
coerces strings to positive integers and drops invalid entries.
- TCP HTTP camouflage host + path: when the TCP HTTP camouflage
Switch is on, surface two inputs that read/write directly into
streamSettings.tcpSettings.header.request.headers.Host and .path.
Both fields are string[] on the wire; normalize + getValueProps
translate to/from comma-joined strings in the UI (one entry per
host or path the user wants camouflaged).
- Wireguard pubKey auto-derive: Form.useWatch on settings.secretKey
+ useEffect that runs Wireguard.generateKeypair(secret).publicKey
on every change and writes the result into the disabled pubKey
display field. Matches the legacy modal's per-keystroke derive.
Step 7 of the Zod migration: lock the migration's gains in place via
lint + CI enforcement.
- eslint.config.js: `@typescript-eslint/no-explicit-any` set to error.
Verified locally — zero violations in src/, with the only file-level
disables being src/models/inbound.ts and src/models/outbound.ts
(kept for DBInbound's toInbound() consumer; their migration is out
of spec scope).
- .github/workflows/ci.yml: add Typecheck and Test steps to the
frontend job, between Lint and Build. PRs now have to pass
tsc --noEmit and the full vitest suite (285 tests + 172 snapshots)
before build runs.
Migration scoreboard (vs the spec):
Step 1 primitives + barrels done
Step 2 protocol leaf + DUs done
Step 3 pure-fn extraction done
Step 4 form modals -> Pattern A done (Inbound + Outbound)
Step 5 delete models/ files DEFERRED (DBInbound still uses
Inbound; spec marks DBInbound
migration out of scope)
Step 6 tighten .loose() / unknown DEFERRED (invasive, separate PR)
Step 7 lint + CI enforcement done (this commit)
Production code paths now have no direct dependency on the legacy
Inbound or Outbound classes.
With the inbound/outbound modal rewrites complete, the cross-check
against the legacy Inbound class has served its purpose. The new
pure-function / Zod-schema paths are the source of truth for production
code; the parity assertions were the migration safety net.
Convert the three parity test files to snapshot-based regression tests:
- headers.test.ts: toHeaders + toV2Headers run against snapshots
captured at the close of the migration (when both new and legacy
were verified byte-equal).
- protocol-capabilities.test.ts: 140 cases (10 fixtures × 14 stream
shapes) snapshot the predicate-result tuple. Was: parity vs legacy
Inbound.canEnableX() class methods.
- inbound-link.test.ts: per-protocol genXxxLink + genInboundLinks
orchestrator output is snapshotted. Was: byte-equality vs legacy
Inbound.genXxxLink() methods.
Also delete shadow.test.ts — its purpose was a dual-parse drift
detector (Inbound.Settings.fromJson vs InboundSettingsSchema.parse).
inbound-full.test.ts already snapshots the Zod parse output, which
covers the same ground without the legacy dependency.
models/inbound.ts and models/outbound.ts stay in the tree for now —
DBInbound still consumes Inbound via its toInbound() method, and
DBInbound migration is out of scope per the migration spec
('Do NOT migrate Status, DBInbound, or AllSetting...'). No
production page imports from @/models/inbound or @/models/outbound
directly anymore.
- Sockopts: Switch toggles streamSettings.sockopt between undefined and
a populated default object (17 fields with sane bbr/UseIP defaults).
Only the 8 most-used fields are rendered (dialer proxy, domain
strategy, keep alive interval, TFO, MPTCP, penetrate, mark, interface).
The remaining sockopt knobs (acceptProxyProtocol, tcpUserTimeout,
tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only,
trustedXForwardedFor, tproxy) are still in the wire payload — edit
them via the JSON tab.
- Mux: gated by isMuxAllowed(protocol, flow, network) — VMess/VLESS/
Trojan/SS/HTTP/SOCKS, no flow set, no xhttp transport. Sub-fields
(concurrency / xudpConcurrency / xudpProxyUDP443) only render when
enabled is true.
- Sockopt section visible only when streamAllowed AND network is set —
non-stream protocols (freedom/blackhole/dns/loopback) still edit
sockopt via the JSON tab.
- onSecurityChange cascade: swaps tlsSettings/realitySettings sub-key
matching the DU branch, seeding the new sub-form with empty/default
fields so the UI does not reference undefined values.
- Flow Select rendered when canEnableTlsFlow is true (VLESS + TCP +
TLS/Reality). Moved from the basic VLESS section so it only appears
in the relevant security context — matches the legacy modal UX.
- Security Radio (none / TLS / Reality) gated by canEnableTls and
canEnableReality pure-function predicates from
lib/xray/protocol-capabilities.
- TLS sub-form: 6 outbound-specific fields (SNI/uTLS/ALPN/ECH/
verifyPeerCertByName/pinnedPeerCertSha256) matching the legacy
TlsStreamSettings flat shape (no certificates list — outbound is
client-side).
- Reality sub-form: 6 fields (SNI/uTLS/shortId/spiderX/publicKey/
mldsa65Verify). publicKey + mldsa65Verify get TextAreas to handle
the long base64 strings.
Wire the stream sub-form into the Pattern A modal:
- newStreamSlice(network) helper bootstraps the per-network DU branch
with Xray defaults (mtu=1350, tti=20, uplinkCapacity=5, etc.).
- streamSettings is seeded once when the protocol supports streams
but the form has no slice yet (new outbound + protocol switch).
- onNetworkChange swaps the sub-key and preserves security when the
new network still supports it, else snaps back to 'none'.
- Per-network sub-forms wired:
TCP: HTTP camouflage Switch (sets header.type = 'http' / 'none')
KCP: 6 numeric tuning fields
WS: host + path + heartbeat
gRPC: service name + authority + multi-mode switch
HTTPUpgrade: host + path
XHTTP: host + path + mode + padding bytes (advanced fields via JSON)
Security radio, TLS/Reality sub-forms, sockopt, and mux still pending.
- DNS: rewriteNetwork (udp/tcp Select) + rewriteAddress + rewritePort +
userLevel + rules Form.List (action/qtype/domain).
- Freedom: domainStrategy + redirect + Fragment Switch with conditional
4-field sub-block (legacy 'enable Fragment' UX preserved — Switch sets
all four fields to populated defaults, off-state empties them all out
so the adapter strips them on submit) + Noises Form.List (rand/base64/
str/hex types, packet/delay/applyTo per row) + Final Rules Form.List
with conditional block-delay sub-field.
- VLESS reverse-sniffing slice: rendered only when reverseTag is set
(matches the legacy modal's nested conditional). All six fields wired
to the form state with appropriate widgets (Switch / Select multi /
Select tags).
- SOCKS / HTTP: user + pass at settings root.
- Hysteria: read-only version=2 (the actual transport knobs live on
stream.hysteria, added with the stream tab).
- Loopback: inboundTag.
- Blackhole: response type Select with empty/none/http options.
- Wireguard: address (csv) + secretKey (with regenerate icon) + derived
pubKey + domain strategy + MTU + workers + no-kernel-tun + reserved
(csv) + peers Form.List with nested allowedIPs sub-list.
Wireguard regenerate icon uses Wireguard.generateKeypair() and writes
both keys to the form via setFieldValue — preserves the legacy UX of
the SyncOutlined inline-icon next to the privateKey label.
- Shared connect-target sub-block (address + port) for the six protocols
whose form schema carries them flat at settings root.
- VMess: id + security Select (USERS_SECURITY).
- VLESS: id + encryption + flow + reverseTag (reverse-sniffing slice and
Vision testpre/testseed come in a later commit).
- Trojan: password.
- Shadowsocks: password + method Select (SSMethodSchema) + UoT switch +
UoT version.
onValuesChange cascade: when the user picks a different protocol, the
adapter re-seeds the settings sub-object to the new protocol's defaults
so leftover fields from the previous protocol do not bleed through.
Sibling .new.tsx file with the Modal shell, Tabs (Basic/JSON), Form.useForm
hydration via rawOutboundToFormValues, and the submit pipeline that calls
formValuesToWirePayload before onConfirm. Tag uniqueness check is wired in.
Protocol-specific sub-forms, stream, security, sockopt, and mux sections
are deferred to subsequent commits — accessible via the JSON tab in the
meantime. The InboundsPage continues to render the legacy modal until the
atomic swap at the end.
Also: rawOutboundToFormValues now returns streamSettings as undefined
when the wire payload omits it, so Form.useForm doesn't receive a value
that does not match the NetworkSettings discriminated union.
Lay the groundwork for OutboundFormModal's Pattern A rewrite:
- schemas/forms/outbound-form.ts: discriminated-union form values across
all 12 outbound protocols, with flat per-protocol settings shapes that
match the legacy class fields (vmess vnext / trojan-ss-socks-http
servers / wireguard csv address-reserved all flattened).
- lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts
wire-shape outbound JSON to typed form values; formValuesToWirePayload
re-nests on submit. Replaces the Outbound.fromJson/toJson dependency
the modal currently has on the legacy class hierarchy.
- test/outbound-form-adapter.test.ts: 15 round-trip cases covering each
protocol's wire quirks (vmess vnext flatten, vless reverse-wrap,
wireguard csv↔array, blackhole response wrap, DNS rule normalization,
mux gating).