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")
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.
Fifth and sixth link generators. genHysteriaLink builds the v1/v2
share URL (scheme picked from settings.version), copying TLS knobs into
the query, surfacing the salamander obfs password from
finalmask.udp[type=salamander] when present, and writing the broader
finalmask payload under `fm` like the other links.
Legacy parity note: the old genHysteriaLink read
stream.tls.settings.allowInsecure, which isn't a field on
TlsStreamSettings.Settings — the guard always evaluated false and the
`insecure` param never made it into the URL. We omit it here to stay
byte-stable.
genWireguardLink and genWireguardConfig take a typed
WireguardInboundSettings + peer index and:
- link: wireguard://<peerPriv>@host:port?publickey=&address=&mtu=#remark
- config: the .conf text WireGuard clients consume directly
Both derive the server pubKey from settings.secretKey via
Wireguard.generateKeypair at call time — Zod stores only secretKey on
the wire (pubKey is computed). The Wireguard utility is pure JS (X25519
over Float64Array), so it runs fine under node + the window polyfill we
added with the vmess extraction.
Two new full-inbound fixtures (hysteria-v1-tls, wireguard-server) plus
matching parity tests bring the suite to 78 tests across 8 files;
typecheck + lint clean.
Hysteria2 (protocol literal) parity stays deferred — the legacy
class has no HYSTERIA2 dispatch case, so it can't round-trip a
hysteria2 fixture without a protocol remap. Same trick the shadow
harness uses; revisit in the orchestrator commit.
Third and fourth link generators. genTrojanLink mirrors genVlessLink's
shape (URLSearchParams + network/security branches + remark hash) minus
the encryption/flow VLESS-isms. genShadowsocksLink shares the same query
construction but base64-encodes the userinfo portion as method:password
or method:settingsPw:clientPw depending on whether SS-2022 is in
single-user or multi-user mode.
Three reusable helpers move out of the per-protocol functions:
- writeNetworkParams: the per-network switch that all param-style
links share (tcp http header / kcp mtu+tti / ws path+host /
grpc serviceName+authority / httpupgrade / xhttp extras)
- writeTlsParams: fingerprint/alpn/ech/sni
- writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission
legacy parity quirk noted in the genVlessLink commit)
genVmessLink stays with its inline switch — it builds a JSON obj instead
of URLSearchParams and has per-network quirks (kcp emits mtu+tti at
the obj root, grpc maps multiMode to obj.type='multi') that don't
factor cleanly through the shared writer.
Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022)
plus matching parity tests bring the suite to 74 tests across 8 files;
typecheck + lint clean.
Second link generator. genVlessLink builds the
vless://<uuid>@<host>:<port>?<query>#<remark> share URL from a typed
Inbound + client args, dispatching on streamSettings.network for the
network-specific knobs and on streamSettings.security for the
TLS/Reality knobs. Three param-style helpers move alongside the obj-
style ones already in this file:
- applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and
the JSON extra blob into URLSearchParams
- applyFinalMaskToParams — writes the fm payload when shareable
- applyExternalProxyTLSParams — overrides sni/fp/alpn when an external
proxy entry is supplied and security is tls
A vless-tcp-reality fixture lands alongside the existing vless-ws-tls
one, so the parity test now exercises both security branches.
Discovered a latent legacy bug while writing parity: the old class
stored realitySettings.serverNames as a comma-joined string and gated
SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true
for strings — so SNI was never written into Reality share URLs.
Existing clients rely on the omission (they pull SNI from
realitySettings.target instead). We preserve the omission here to keep
this extraction byte-stable; an inline comment marks the spot for a
separate intentional fix.
Suite: 70 tests across 8 files; typecheck + lint clean.
Step 3d's last scaffolding piece before link generators. Three new
stream-extras schemas land alongside the network/security DUs:
- finalmask: TcpMask[] + UdpMask[] + QuicParams. Mask `settings` stays
record<string, unknown> for now — there are 13 UDP mask types and 3
TCP mask types with distinct per-type setting shapes, and modeling
them all as DUs would dwarf the rest of stream/ without buying
anything the shadow harness doesn't already catch. Tightened in
Step 6.
- sockopt: 17 socket-tuning knobs (TCP keepalive, TFO, mark, tproxy,
mptcp, dialer proxy, IPv6-only, congestion). `interfaceName` field
matches the panel class naming; serializers rename to `interface` on
the wire.
- external-proxy: rows ship per inbound describing edge fronts (CDN
mirrors). Used by link generators to fan out share URLs.
schemas/api/inbound.ts composes the top-level wire shape with
intersection-of-DUs:
StreamSettingsSchema = NetworkSettingsSchema
.and(SecuritySettingsSchema)
.and(StreamExtrasSchema)
InboundSchema = InboundCoreSchema.and(InboundSettingsSchema)
A fixture (vless-ws-tls.json) exercises the full shape — protocol DU,
network DU, security DU, and TLS cert file branch in one round trip.
The snapshot pins the canonical parsed form so the upcoming link
extractor consumes typed input with no class hierarchy underneath.
Suite: 65 tests across 7 files; typecheck + lint clean. Zod 4
intersection-of-DUs works.
Round out Step 3d's settings factory set. Ten plain-object factories
(vless / vmess / trojan / shadowsocks / hysteria / hysteria2 / http /
mixed / tunnel / wireguard) replace the legacy
`new Inbound.<X>Settings(protocol)` constructors. Each returns a Zod-
parsable wire shape with schema defaults applied — no class instance.
Forms (Step 4) and InboundsPage clone (Step 5) call these factories
directly once the swap lands.
Three factories take a seed for random fields:
- shadowsocks: method-dependent password length via
RandomUtil.randomShadowsocksPassword(method)
- hysteria: explicit `version` override (defaults to 2, matching
the legacy panel constructor — v1 is opt-in)
- wireguard: secretKey from Wireguard.generateKeypair().privateKey
Tests double-verify each factory the same way as the client factories:
snapshot the shape, then Zod parse round-trip to confirm no missing
defaults or stray fields.
Suite: 59 tests across 6 files; typecheck + lint clean. Outbound
factories and the toShareLink extraction follow next.
Next Step 3d slice. Five plain-object factories — Vless, Vmess, Trojan,
Shadowsocks, Hysteria — replace the legacy
`new Inbound.<Protocol>Settings.<Protocol>(...)` constructor chain and the
ClientBase XrayCommonClass machinery. Each factory takes an optional
seed; missing random fields (id, password, auth, email, subId) fall
through to RandomUtil at call time. Forms can hand-pick a UUID; tests
pass deterministic seeds so the suite never touches window.crypto.
Tests double-verify each factory: a snapshot locks the exact shape, and
the matching Zod ClientSchema.parse(out) must equal `out` — no missing
defaults, no stray fields, type-narrowed end-to-end.
Discovered: VmessClientSchema and VlessClientSchema enforce z.uuid()
format, so the test seeds use real-shape UUIDs.
Suite: 49 tests across 6 files; typecheck + lint clean. Outbound and
inbound-settings factories follow in subsequent turns alongside the
toShareLink extraction.
Round out Step 3b. Four more inbound fixtures complete the protocol set
(http with two accounts, mixed with socks-style auth, tunnel with a port
map, hysteria v1). Two parallel test files cover the other DUs:
stream.test.ts walks tcp/ws/grpc fixtures through NetworkSettingsSchema,
and security.test.ts walks none/tls/reality through SecuritySettingsSchema.
Snapshot count is now 16 across three test files. The reality fixture
locks in the array form of serverNames/shortIds (the panel class stores
them comma-joined internally but they ship as arrays on the wire). The
TLS fixture pins the file-vs-inline cert DU on the file branch.
Stream coverage for httpupgrade/xhttp/kcp and security mixed-with-stream
combos follow in the next turn, alongside the shadow harness.
Stand up Phase 3 safety net before the models/ rewrite. The harness loads
JSON fixtures via Vite's import.meta.glob, parses each through
InboundSettingsSchema (the tagged-wrapper DU), and snapshots the canonical
parsed shape. Snapshots stay byte-stable across the upcoming class-to-
pure-function extraction, catching any normalization drift.
Six representative inbound fixtures cover the high-traffic protocols:
vless, vmess, trojan, shadowsocks (2022-blake3 multi-user), wireguard,
hysteria2. Stream and security branches plus the remaining protocols
(http, mixed, tunnel, hysteria) follow in subsequent turns.
Uses /// <reference types="vite/client" /> instead of @types/node so we
avoid pulling in another type package; import.meta.glob is enough to walk
the fixtures directory at compile time.
Adds vitest 4.1.7 as the only new dev dependency. test/test:watch scripts
land in package.json; a standalone vitest.config.ts keeps the production
vite.config.js (which reads from sqlite via DatabaseSync) out of the test
runner.