Skip to content

Protocol crate foundations

Source files: 47 · checked against Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/error.rs
  • Etemenanki/protocols/src/macros.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/core/harness.rs
  • Etemenanki/protocols/src/http/core.rs
  • Etemenanki/protocols/src/ss_legacy/core.rs
  • Etemenanki/protocols/src/ss_2022/core.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/tun/udp.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/helpers/parse.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/sniff/collector.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/protocols/src/mux/frame.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/trojan/protocol.rs
  • Etemenanki/protocols/src/trojan/users.rs
  • Etemenanki/protocols/src/vless/protocol.rs
  • Etemenanki/protocols/src/vmess/protocol.rs
  • Etemenanki/protocols/src/vmess/aead.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/ss_legacy/protocol.rs
  • Etemenanki/protocols/src/ss_2022/protocol.rs
  • Etemenanki/protocols/src/tun/inbound.rs
  • Etemenanki/protocols/tests/pipeline.rs
  • Etemenanki/protocols/tests/pipeline/core.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/protocols/tests/unit/helpers/address.rs
  • Etemenanki/protocols/tests/unit/helpers/address_family.rs
  • Etemenanki/app/Cargo.toml
  • Etemenanki/app/src/flow.rs
  • Etemenanki/app/src/router.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/concepts/src/core.rs
  • katana/Cargo.toml
  • katana/src/outbound/mod.rs

etemenanki-protocols holds every proxy protocol and transport. Every server core in it is assembled from the same small set of parts: a Flow<T> that says where a connection goes, one error type for untrusted framing, a timer whose meaning depends on the phase, a buffer for sniffed bytes, a relay tail that tracks half-closes, and a handful of parsing, address and crypto helpers.

This page covers those parts one at a time, then shows how a protocol module is laid out and what adding one involves. It assumes you know the sans-I/O contract of ProxyCoreDecode (events in, effects out), which is described on Server core. The task that drives a core is described on Server runtime.

The foundation modules own:

  • The flow a server core hands to its connector. It holds the destination, user, sniffed domain and client address (protocols/src/flow.rs).
  • Error classification for untrusted input. It is bridged to std::io::Error, because the concepts boundary is fixed to io::Error (protocols/src/error.rs).
  • The pieces every server core composes: FlowKey/SubKey, Phase/Timing, SniffPrefix, Passthrough, and the smallest complete core, PassthroughCore (protocols/src/core/mod.rs).
  • A test driver that needs no sockets, CoreHarness (protocols/src/core/harness.rs).
  • Shared wire helpers: the binary address codec and text authorities (helpers/address.rs), panic-free slicing (helpers/parse.rs), small crypto primitives (helpers/crypto.rs) and the address-family policy of outbounds (helpers/address_family.rs).

They own no protocol’s wire format, no socket and no clock. A core composes these parts; it does not inherit them. It keeps a Timing and calls it at the top of every byte event, keeps a SniffPrefix while it collects a flow’s first bytes, and keeps a Passthrough for the half-close bookkeeping of its one stream outbound.

protocols/src/lib.rs declares the modules below. Two of them are behind Cargo features that are off by default.

Module Gate Contents
core none The shared core pieces on this page, and CoreHarness.
error, flow, helpers, macros none Shared types and helpers on this page.
sniff none The TLS SNI and HTTP Host sniffers, SNIFF_LIMIT, SNIFF_TIMEOUT. See Sniffing.
dns none The resolver outbounds dial through. See DNS.
transports none TCP, TLS, WebSocket and gRPC, accept and connect sides. See TCP and TLS transports.
mux none The mux.cool server demultiplexer that Trojan, VLESS and VMess share. See mux.cool and XUDP.
socks none SOCKS4, 4a and 5: a dedicated inbound driver (SocksInbound) instead of a core, plus the SOCKS5 client codec and UDP link. A UDP ASSOCIATE hears only the client its control connection came from, or, over a Unix socket, the exact source its request named (ExpectedSender). See SOCKS.
http none HttpCore and the HttpConnect client codec.
trojan, vless, vmess none Server cores and client codecs.
ss_legacy, ss_2022 none Shadowsocks AEAD and Shadowsocks 2022 cores and codecs.
wireguard none Outbound only: WgConnector over a userspace netstack.
hysteria feature = "hysteria" Hysteria 2 client and server. Pulls in quinn, h3, rustls and blake2.
tun all(feature = "tun", unix) The TUN inbound. Pulls in ipstack, tun-rs and, on Linux, rtnetlink.

The third feature, vendored-openssl, forwards to openssl/vendored and gates no module.

The features are off by default so that a downstream does not pick up a second TLS stack or an interface-management stack on a version bump without asking for it. The consumers opt in explicitly:

Consumer Features enabled
etemenanki-app (app/Cargo.toml) hysteria, tun
katana (Cargo.toml) vendored-openssl, hysteria

protocols/src/lib.rs opens with a crate-wide deny:

#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]

The same four lints are allowed under cfg(test), because tests work on known-good inputs. The workspace gate runs cargo clippy --workspace --all-targets --all-features -- -D warnings. Production code in this crate therefore does not index a slice, unwrap, or use unchecked +, - or *. The only exceptions are a few functions that opt out of arithmetic_side_effects with a local #[allow(..., reason = "...")] that states why the arithmetic cannot overflow: the Instant deadline helpers in socks/server.rs, transports/ws/stream.rs and transports/grpc/liveness.rs, and the varint codecs of gRPC and Hysteria 2. The helpers in Parsing without panics exist because of this rule.

pub struct Flow<T> {
pub destination: Destination,
pub user: NetworkUser<T>,
pub sniffed: Option<SniffedBehavior>,
pub source: Option<IpAddr>,
}
impl<T> Flow<T> {
pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self;
pub fn toward(&self, destination: Destination) -> Self;
}

Flow<T> is the ProxyCoreDecode::Target of every server core in the crate. It is the value a core pushes in Effect::Open, and the value the connector routes and dials. The client codecs use Target = Destination instead. On the client side the target is the upstream proxy server that the runtime dials, and the flow’s own destination is encoded inside the codec.

Field Set by Meaning
destination The core, from the request network, remote (IP or domain) and port. For a UDP flow it is the first packet’s destination. Later packets carry their own address in Effect::SendTo.
user The core, from its validator NetworkUser<T>: an authorization plus user_data: Arc<T>. The app uses T = () (app/src/flow.rs → Flow). A downstream such as katana carries its own per-user payload.
sniffed The core, after sniffing Option<SniffedBehavior>, a protocol and a domain. Flow::new leaves it None.
source The inbound The client’s address, when the inbound knows it.

The app’s router (app/src/router.rs → route_target) turns a flow into a route target. sniffed becomes the target’s sniffed domain, so a domain or geosite rule can match a flow addressed by IP. flow.source takes precedence over the listener’s address (FlowContext::source), because a QUIC inbound serves many peers from one socket and only the flow knows which peer it belongs to.

toward(destination) builds a sub-flow. The sub-flow shares the carrier’s user (the Arc is cloned) and source, and resets sniffed to None, because a sniffed domain describes the carrier’s first bytes, not the new destination. The callers are:

  • mux/demux.rs → Demux: one sub-flow per mux.cool New frame. When the inbound sniffs and the sub-flow’s target is an IP, the demux then sniffs the payload of that New frame.
  • trojan/core.rs → TrojanCore: a UDP association opens its outbound toward the first packet’s destination.
  • app/src/outbound/udp_fanout.rs → FanOutLink: routes each datagram of an association as the association’s flow toward that datagram’s target.

Clone is written by hand instead of derived, so cloning a Flow<T> does not require T: Clone. The payload only ever sits behind an Arc. NetworkUser<T> in the concepts crate does the same, for katana’s per-user traffic counter, which must stay a single shared instance.

pub enum ProtocolError {
Truncated(&'static str),
Overflow(&'static str),
Malformed(&'static str),
Unauthenticated(&'static str),
Crypto(&'static str),
Unsupported(&'static str),
Io(#[from] io::Error),
Other(#[from] anyhow::Error),
}
impl From<ProtocolError> for io::Error { /* … */ }
pub type Result<T, E = ProtocolError> = std::result::Result<T, E>;

Every public service in the crate still returns io::Error, because that is the concepts boundary. ProtocolError classifies failures inside parsers, and the From impl lets any function that returns io::Result propagate it with ?. The &'static str payload names the field or region; it never carries input bytes.

Variant Display io::ErrorKind after conversion
Truncated truncated input: … UnexpectedEof
Overflow integer overflow: … InvalidData
Malformed malformed: … InvalidData
Unsupported unsupported: … InvalidData
Unauthenticated authentication failed: … PermissionDenied
Crypto cryptographic operation failed: … Other
Other the wrapped error Other
Io the wrapped error the wrapped error, returned unchanged

At this revision, production code constructs only Truncated, Overflow and Malformed. They come from the slice helpers and from the parsers of SOCKS, mux.cool, VMess, the two Shadowsocks families, Hysteria 2 and DNS. Unauthenticated, Crypto and Unsupported are declared but not raised. The server cores build an authentication failure directly as an io::Error. TrojanCore, VlessCore, VMessCore and ShadowsocksCore use kind PermissionDenied (for example trojan: invalid user), and their core tests assert on that kind. Ss2022Core reports an unknown identity as InvalidData (shadowsocks-2022: unknown identity), and HttpCore answers a failed login with a 407 response instead of an error.

pub enum FlowKey {
Direct,
Sub(SubKey),
}
pub struct SubKey {
pub id: u16,
pub generation: u32,
}

Both derive Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord and Hash. That covers the bounds ProxyCoreDecode::Key asks for (Copy + Ord + Send + Sync + 'static). A core whose connection carries either one flow or a mux.cool carrier (TrojanCore, VlessCore, VMessCore) uses FlowKey as its key. FlowKey::Direct is the connection’s own flow, and FlowKey::Sub is one mux sub-flow.

The generation exists because of a runtime rule: Effect::Open on a key that is still live fails the connection with RuntimeError::DuplicateKey (open reuses a live outbound key). A mux.cool peer may reuse a session id right after ending it, while the old outbound is still being torn down. Demux therefore increments its generation: u32 counter with wrapping_add(1) on every accepted New frame and mints SubKey { id, generation }, so the reused id becomes a different key. Demux::session_id accepts an outbound event only if the stored SubKey for that id matches the whole key. Downlink bytes that arrive for a retired generation therefore produce no frames.

The server cores in the crate and their keys:

Core Module Key TransportAddr BUF_SIZE Uses Timing
PassthroughCore core Single () 8 KiB yes
HttpCore http Single () MAX_HEAD (64 KiB) yes
TrojanCore trojan FlowKey () 16 KiB yes
VlessCore vless FlowKey () 16 KiB yes
VMessCore vmess FlowKey () 32 KiB yes
ShadowsocksCore ss_legacy Single () 20 KiB yes
Ss2022Core ss_2022 Single () 32 KiB yes
Hy2StreamCore hysteria::server Single () 8 KiB yes
Hy2UdpCore hysteria::server u32 (session id) () 16 KiB no, it runs its own sweep
TunUdpCore tun Single SocketAddr 8 KiB yes
pub enum Phase { Handshake, Sniff, Relay, Closing }
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
pub const RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
pub enum Expired { Handshake, Sniff, Idle }
pub struct Timing { phase: Phase, armed: bool }
impl Timing {
pub fn new() -> Self;
pub fn phase(&self) -> Phase;
pub fn is_established(&self) -> bool;
pub fn touch<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>);
pub fn enter<C: ProxyCoreDecode>(&mut self, phase: Phase, fx: &mut Effects<'_, C>);
pub fn expired<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) -> Expired;
}
pub fn handshake_timed_out() -> io::Error;

The runtime has exactly one deadline timer, driven by Effect::SetDeadline(Option<Duration>), so Timing lets the phase decide what the deadline means. Every arm pushes Effect::SetDeadline(Some(after)), and arming again replaces the previous deadline. Timing is Copy and Default; Default is new().

Phase enter(phase) arms touch expired returns What the core does
Handshake HANDSHAKE_TIMEOUT (10 s) Arms HANDSHAKE_TIMEOUT only if nothing is armed Expired::Handshake Returns Err(handshake_timed_out())
Sniff SNIFF_TIMEOUT (300 ms) Nothing Expired::Sniff Opens the flow with what it collected
Relay RELAY_IDLE_TIMEOUT (300 s) Re-arms RELAY_IDLE_TIMEOUT Expired::Idle, after pushing Effect::Finish and moving to Closing Returns Ok(0)
Closing RELAY_IDLE_TIMEOUT Re-arms RELAY_IDLE_TIMEOUT Same as Relay Returns Ok(0)

expired also clears armed. is_established() is true in Relay and Closing. A core enters Relay once the request is parsed and nothing more is needed from the client: for a single TCP flow that is right when it pushes Open, before the dial completes; a UDP association or a mux.cool carrier enters it before any Open. It is false during Sniff. Every core that keeps a Timing forwards it as its own is_established(); Hy2UdpCore has no handshake and always returns true. The application reads it to tell a client that never finished its request from one that is being served.

What arms and re-arms the deadline:

  • The handshake deadline is armed once, by the first touch. Timing::new starts in Handshake with nothing armed, and later touch calls in Handshake do nothing. The limit therefore counts from the client’s first bytes, and a slow trickle of bytes does not extend it. handshake_timed_out() is an io::Error of kind TimedOut with the text client did not complete its request in time.
  • The sniff window is fixed. enter(Phase::Sniff) arms SNIFF_TIMEOUT and touch leaves it alone, so bytes that arrive during sniffing do not extend it.
  • The idle deadline is re-armed on every byte event. Cores call touch at the top of Event::Transport and Event::Outbound, and of Event::Datagram when they carry UDP. TunUdpCore, whose transport is a datagram socket, calls it on Event::TransportDatagram and Event::Datagram. EOF, connect and error events do not call it. This is what makes RELAY_IDLE_TIMEOUT an idle limit and not a lifetime.
  • Cores enter only Sniff and Relay. Closing is reached only through an idle expiry.

A runtime delivers no event before the client speaks, so Timing alone cannot catch a client that connects and sends nothing. The drivers above the core cover that case with the same constant:

Driver Watchdog Error on expiry
app/src/serve.rs → drive While the core is not established, wraps each runtime.next() in tokio::time::timeout(HANDSHAKE_TIMEOUT, …) inbound handshake timed out after 10s
tun/inbound.rs → serve_stream Same loop over a PassthroughCore runtime tun: the client never spoke
socks/server.rs → SocksInbound Wraps the whole SOCKS handshake in HANDSHAKE_TIMEOUT; the relay then uses its own RELAY_IDLE_TIMEOUT sleep handshake_timed_out()

drive takes an Established bound, which the established! macro in app/src/serve.rs implements for HttpCore, TrojanCore, VlessCore, VMessCore, ShadowsocksCore and Ss2022Core. See Serving.

stateDiagram-v2
  [*] --> Handshake: Timing new, unarmed
  Handshake --> Handshake: first touch arms HANDSHAKE_TIMEOUT
  Handshake --> Sniff: enter Sniff, IP destination and sniffing on
  Handshake --> Relay: enter Relay
  Sniff --> Relay: Found, Exhausted, EOF or Expired Sniff
  Relay --> Relay: touch re-arms RELAY_IDLE_TIMEOUT
  Relay --> Closing: Expired Idle pushes Finish
  Handshake --> [*]: Expired Handshake, core returns an error
  Closing --> [*]
pub struct SniffPrefix { collector: Collector }
impl SniffPrefix {
pub fn new() -> Self;
pub fn push(&mut self, plain: &[u8]) -> (usize, Verdict);
pub fn held(&self) -> &[u8];
pub fn found(&self) -> Option<&SniffedBehavior>;
pub fn result(&self) -> Option<SniffedBehavior>;
pub fn clear(&mut self);
pub fn is_empty(&self) -> bool;
}

SniffPrefix wraps sniff::collector::Collector, a bounded accumulator of a flow’s first plaintext bytes with no clock of its own. push takes at most Collector::remaining() bytes. The budget is SNIFF_LIMIT (4 KiB) across however many events the bytes arrive in. push returns how many bytes it took and a Verdict:

Verdict Meaning What the core does
Found A sniffer recognised a TLS SNI or an HTTP Host. Opens the flow now.
More Nothing recognised yet, and budget is left. Consumes the returned count and waits.
Exhausted The budget is spent without a match. Opens the flow without a sniffed domain.

The caller consumes exactly the returned count. Once the budget is spent, push takes 0 bytes and keeps returning Exhausted.

The bytes are copied into the collector, so they stay in the core’s held buffer (ProxyCoreDecode::held returns prefix.held()) until the flow opens. The core then pushes Effect::Open with flow.sniffed = prefix.result(), followed by Effect::ForwardHeld { range: 0..held.len() } when it holds any bytes, and calls clear() at the top of the next byte event. That is safe because of the runtime’s held-buffer rule: while a held forward is queued, the runtime delivers no byte event, so at the start of a byte event every earlier held range has been applied. result() clones the sniffed value and leaves the bytes in place for that forward.

worth_sniffing(&destination) in sniff/mod.rs is true only for an IP destination. A flow that already names a domain is routed by that name, so cores skip the sniff phase for it and do not spend up to SNIFF_TIMEOUT on it.

pub struct Passthrough<K> { key: K, outbound_eof: bool, transport_eof: bool }
impl<K: Copy> Passthrough<K> {
pub fn new(key: K) -> Self;
pub fn key(&self) -> K;
pub fn is_done(&self) -> bool;
pub fn on_transport<C: ProxyCoreDecode<Key = K>>(&self, data: &[u8], fx: &mut Effects<'_, C>) -> usize;
pub fn on_outbound<C: ProxyCoreDecode<Key = K>>(&self, data: &[u8], fx: &mut Effects<'_, C>) -> io::Result<usize>;
pub fn on_outbound_eof<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>);
pub fn on_transport_eof<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>);
pub fn on_outbound_gone<C: ProxyCoreDecode<Key = K>>(&mut self, fx: &mut Effects<'_, C>);
}
pub fn staging_full() -> io::Error;

Passthrough is the relay tail of a protocol with one stream outbound. Most protocols frame or encrypt their transport side, so such a core decodes its own transport bytes and uses only the half-close bookkeeping. on_transport and on_outbound are the verbatim forms that a plaintext relay uses: PassthroughCore, Trojan and VLESS after the header, the HTTP CONNECT tunnel, and a Hysteria 2 proxy stream.

Method State change Effects
on_transport(data) none Forward { key, range: 0..data.len() }; returns data.len()
on_outbound(data) none Stages data toward the transport; returns data.len(), or Err(staging_full())
on_outbound_eof outbound_eof = true ShutdownTransport, then Finish if the transport already ended
on_transport_eof transport_eof = true Shutdown { key }, then Finish if the outbound already ended
on_outbound_gone both flags set ShutdownTransport, Finish

is_done() is true once both flags are set. on_outbound_gone handles a failed connect or an outbound error: nothing more can move, so the core finishes, and bytes already staged toward the transport are still written. staging_full() is io::Error::other("staging room below the core's declared reserve"). Reaching it means the core staged more than its STAGING_RESERVE promised. That is a bug in the core, not a peer error, because the runtime polls an outbound only when staging has room for the reserve plus the payload.

Passthrough is Copy, and the byte methods take &self. Cores copy it out of their state enum (let relay = *relay;) before they call them. This keeps the borrow of self.state separate from the self.prefix.clear() call that follows.

pub struct PassthroughCore<T> {
pending: Option<Flow<T>>,
sniff: bool,
prefix: SniffPrefix,
relay: Passthrough<Single>,
timing: Timing,
}
impl<T> PassthroughCore<T> {
pub const BUF_SIZE: usize = 8 * 1024;
pub fn new(flow: Flow<T>) -> Self;
pub fn sniffing(flow: Flow<T>) -> Self;
pub fn is_established(&self) -> bool;
}
impl<T: Send + Sync + 'static> ProxyCoreDecode for PassthroughCore<T> {
type Key = Single;
type Target = Flow<T>;
type Error = io::Error;
type TransportAddr = ();
const STAGING_RESERVE: usize = 0;
fn handle(&mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>) -> Result<usize, io::Error>;
fn held(&self) -> &[u8];
}

The smallest complete core. It serves a flow whose destination is known before the first byte and relays it verbatim in both directions. The TUN inbound uses it for each TCP connection, because the IP stack has already told it where the connection goes. sniffing(flow) turns the sniff phase on only when worth_sniffing says the destination is an IP. PassthroughCore is also the reference for how the parts fit together:

Event Handling
Transport while pending and sniffing Enters Sniff on the first one, calls prefix.push, and opens when the verdict is not More. Returns the count taken.
Transport otherwise Opens if not open yet, then touch, prefix.clear(), relay.on_transport.
Outbound touch, prefix.clear(), relay.on_outbound.
TransportEof Opens if still pending, with whatever was sniffed, then relay.on_transport_eof.
OutboundEof relay.on_outbound_eof.
ConnectFailed, OutboundError Drops the pending flow, then relay.on_outbound_gone.
Deadline Expired::Handshake returns Err(handshake_timed_out()), Expired::Sniff opens, and Expired::Idle returns Ok(0).
Connected, datagram events Ignored.

Opening pushes Effect::Open { key: Single, target: flow }, then Effect::ForwardHeld over the held prefix when it is not empty, and then enters Relay. A non-sniffing core opens on its first Transport event, so the effects of that event are, in order, Open, SetDeadline(RELAY_IDLE_TIMEOUT) from enter, SetDeadline(RELAY_IDLE_TIMEOUT) from touch, and Forward.

pub const HARNESS_STAGING: usize = 64 * 1024;
pub struct CoreHarness<C: ProxyCoreDecode> {
pub core: C,
effects: EffectList<C>,
staging: WriteBuffer<HARNESS_STAGING>,
packets: Option<PacketList<C::TransportAddr>>,
}
impl<C: ProxyCoreDecode> CoreHarness<C> {
pub fn new(core: C) -> Self;
pub fn over_datagrams(core: C) -> Self;
pub fn event(&mut self, event: Event<'_, C>) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn transport(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn feed(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn outbound(&mut self, key: C::Key, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn staged(&mut self) -> Vec<u8>;
pub fn staged_packets(&mut self) -> Vec<(Vec<u8>, C::TransportAddr)>;
pub fn held(&self, range: std::ops::Range<usize>) -> Vec<u8>;
}

A stand-in for the runtime that a test drives by hand. The unit tests of the cores use it. It does no I/O and keeps no clock, so a test delivers Event::Deadline itself.

  • event delivers one event and returns the consumed count and the effects pushed during that call.
  • transport delivers one Event::Transport. feed calls it again on the unconsumed tail while the core makes progress, the way the runtime does, and stops when the core consumes 0 bytes. It shifts the ranges of Forward and SendTo so they are absolute in the slice you passed. ForwardHeld and SendToHeld ranges index the held buffer and are left alone; resolve them with held(range).
  • outbound delivers Event::Outbound for one key.
  • staged takes everything staged toward the transport so far. Staged bytes accumulate in the harness’s HARNESS_STAGING (64 KiB) buffer until a test takes them.
  • over_datagrams builds a harness for a datagram transport, so Effects::stage_to works. staged_packets returns each staged packet with its peer, in order.

The harness does not enforce the runtime’s scheduling rules: the staging reserve check, the held-buffer pin, FrameTooLarge, DuplicateKey and the other RuntimeError checks. The pipeline tests cover those by running each core under the real runtime.

A typical single-stream core keeps Timing and SniffPrefix as fields and moves through a state enum that holds a Passthrough once it relays. TrojanCore is a good one to read. For a CONNECT to an IP with sniffing on, the exchange looks like this:

sequenceDiagram
  participant R as Runtime
  participant C as Core
  participant P as SniffPrefix
  R->>C: Event Transport with the request header
  C->>R: SetDeadline HANDSHAKE_TIMEOUT from touch
  C->>C: parse header, look up user, Flow new
  C->>R: SetDeadline SNIFF_TIMEOUT from enter Sniff
  R->>C: Event Transport with the first payload
  C->>P: push payload
  P-->>C: taken count and Verdict Found
  C->>R: Open with the sniffed flow
  C->>R: ForwardHeld over the held prefix
  C->>R: SetDeadline RELAY_IDLE_TIMEOUT from enter Relay
  R->>R: dial, then apply the held forward
  R->>C: Event Outbound with reply bytes
  C->>R: SetDeadline RELAY_IDLE_TIMEOUT from touch
  C->>C: prefix clear
  C->>R: stage the reply toward the transport

If the sniff deadline fires first, Event::Deadline yields Expired::Sniff and the core opens with sniffed = None and the bytes it holds. If the destination names a domain, or sniffing is off, the core opens right after the header. A UDP association and a mux.cool carrier skip Sniff and enter Relay directly.

pub struct AddressCodec {
pub ipv4: u8,
pub domain: u8,
pub ipv6: u8,
pub port_first: bool,
}
impl AddressCodec {
pub const SOCKS: Self;
pub const VMESS: Self;
pub const MAX_LEN: usize = 1 + 1 + 255 + 2;
pub fn encoded_len(remote: &Remote) -> usize;
pub fn write_slice(&self, out: &mut [u8], remote: &Remote, port: u16) -> Option<usize>;
pub async fn read<R: AsyncRead + Unpin>(&self, r: &mut R) -> io::Result<(Remote, u16)>;
pub async fn read_destination<R: AsyncRead + Unpin>(&self, r: &mut R, network: DialNetwork) -> io::Result<Destination>;
pub fn read_slice(&self, data: &[u8]) -> io::Result<(Remote, u16, usize)>;
pub fn write_buf(&self, out: &mut BytesMut, remote: &Remote, port: u16);
pub async fn write<W: AsyncWrite + Unpin>(&self, w: &mut W, remote: &Remote, port: u16) -> io::Result<()>;
}

A port of Xray’s AddressParser. An address is a type byte, the address bytes and a big-endian port. The protocols differ only in the three type values and in whether the port comes first, so a codec is just those four fields.

AddressCodec::SOCKS, port last:

Field Size Meaning
type 1 0x01 IPv4, 0x03 domain, 0x04 IPv6
address 4, 16, or 1 + n IPv4 octets; IPv6 octets; or a length byte n followed by n domain bytes
port 2 Big-endian

AddressCodec::VMESS, port first:

Field Size Meaning
port 2 Big-endian
type 1 0x01 IPv4, 0x02 domain, 0x03 IPv6
address 4, 16, or 1 + n As above
Layout Used by
SOCKS SOCKS5 (socks/protocol.rs), Trojan (trojan/protocol.rs), Shadowsocks (ss_legacy/protocol.rs), Shadowsocks 2022 (ss_2022/protocol.rs)
VMESS VLESS (vless/protocol.rs), mux.cool frames (mux/frame.rs), VMess (vmess/protocol.rs)

Every module except VMess declares its layout once as pub const ADDR: AddressCodec = …;. VMess names AddressCodec::VMESS at its two call sites.

encoded_len counts the type byte and the port: 7 bytes for IPv4, 19 bytes for IPv6, and n + 4 bytes for a domain of n bytes. MAX_LEN is 259, the size with a 255-byte domain. Protocols use it to size fixed header buffers, for example the Shadowsocks client’s STAGING_RESERVE.

The slice and async forms share these decoding rules:

  • An unknown type byte is InvalidData with unknown address type: <n>.
  • A domain must be non-empty UTF-8 made only of ASCII letters, digits, -, . and _. Anything else is InvalidData (empty domain name, non-utf8 domain, invalid domain name: …).
  • A domain that starts with a digit or [ and parses as an IP (brackets stripped) becomes Remote::IpAddr, the way Xray’s maybeIPPrefix does.
  • In read_slice, a short input is a Truncated error, which reaches the caller as UnexpectedEof, so a caller can wrap it in need_more. The returned count is the number of bytes consumed, port included. The async read returns the UnexpectedEof of the underlying read_exact instead.

write_slice writes into a fixed slice and returns None when the slice is too short or the domain is longer than 255 bytes. Cores use this form to seal into staging room. write_buf appends to a BytesMut and is used where the output grows; unlike write_slice, it does not check the 255-byte domain limit, so the caller must.

pub fn format_authority(dest: &Destination) -> String;
pub fn parse_authority(raw: &str, default_port: u16) -> io::Result<Destination>;

These serve the protocols that carry the target as text: the HTTP proxy server, for a CONNECT target and for the host of a plain proxied request (http/core.rs); the HTTP client’s CONNECT line (http/protocol.rs → build_connect_request); and Hysteria 2’s TCP requests and UDP messages (hysteria/connector.rs, hysteria/server/inbound.rs, hysteria/server/datagrams.rs).

  • format_authority brackets IPv6, as in [2001:db8::1]:443.
  • parse_authority trims the input and accepts host, host:port, [v6] and [v6]:port. It uses default_port when the port is absent, leaves domains unresolved, and returns a TCP Destination.
  • parse_authority rejects an empty host (empty authority host), an unbracketed IPv6 (ambiguous authority (bracket IPv6 literals)), an invalid port (invalid port), an unclosed bracket (malformed IPv6 authority) and trailing data after a bracketed literal (trailing data after IPv6 authority). All of these are InvalidData.

helpers/address_family.rs decides which of a destination’s resolved IPs an outbound may use. It answers two independent questions:

pub enum AddressFamilyStrategy { Auto, Ipv4Only, Ipv6Only, PreferIpv4, PreferIpv6 }
pub struct FamilySupport { ipv4: bool, ipv6: bool }
pub async fn resolve_candidates(
context: &str,
dest: &Destination,
strategy: AddressFamilyStrategy,
support: FamilySupport,
resolver: &Resolver,
) -> io::Result<Vec<IpAddr>>;
pub fn select_candidate_ips(
resolved: Vec<IpAddr>,
strategy: AddressFamilyStrategy,
support: FamilySupport,
) -> Vec<IpAddr>;
pub async fn destination_to_socketaddrs(
dest: &Destination,
strategy: AddressFamilyStrategy,
resolver: &Resolver,
) -> io::Result<Vec<SocketAddr>>;
  • Policy (AddressFamilyStrategy, default Auto) is what the operator asked for. Its FromStr is lenient: it trims, lowercases and maps - to _, and accepts aliases such as ipv4, v4, 4, ipv4only, prefer_v6 and v6_prefer. The empty string is Auto. Anything else is AddressFamilyStrategyParseError (unknown address family strategy).
  • Capability (FamilySupport) is which families the outbound can source traffic from. A dialer that leaves source selection to the kernel passes FamilySupport::both(), and the kernel fails fast with ENETUNREACH for an unusable family. WireGuard runs a userspace netstack with no routing table, so it derives support from its tunnel addresses with FamilySupport::from_addrs.

select_candidate_ips keeps every resolved address that passes both checks, in resolver order. The Prefer* strategies then apply a stable sort, so the other family stays as a fallback instead of being dropped. Keeping every candidate, not only the first, is what lets a dialer move on when the leading address is unreachable. A domain that resolves to nothing is NotFound with <context>: destination did not resolve. When nothing survives the filter, no_candidate_error returns AddrNotAvailable with <context>: no usable <strategy> destination address for <host>:<port>. It appends (local address supports …) whenever the caller’s FamilySupport is not both families, so a kernel-routed dialer never gets the clause.

transports/connect.rs, hysteria/connection.rs, app/src/outbound/freedom.rs and app/src/balancer.rs go through destination_to_socketaddrs, whose context is dial. wireguard/connector.rs calls resolve_candidates with its own FamilySupport. See Dialers.

pub fn take<'a, I>(data: &'a [u8], index: I, what: &'static str) -> Result<&'a [u8], ProtocolError>
where
I: SliceIndex<[u8], Output = [u8]>;
pub fn take_array<const N: usize>(data: &[u8], at: usize) -> Result<[u8; N], ProtocolError>;
pub fn need_more<T>(result: std::io::Result<T>) -> std::io::Result<Option<T>>;

These three functions in helpers/parse.rs replace slice[a..b] and slice[a..b].try_into().unwrap(), which the crate-level lints reject:

  • take borrows a sub-slice for any range form (..b, a.., a..b, ..) and maps an out-of-range access to Truncated(what).
  • take_array::<N> copies N bytes from offset at. It computes the end offset with checked_add (overflow is Overflow("field offset")) and bounds-checks the range (short input is Truncated("fixed-size field")). Use it for reads such as u16::from_be_bytes(take_array::<2>(data, at)?).
  • need_more is for slice parsers that receive a growing buffer and are called again when more bytes arrive. It turns an UnexpectedEof error into Ok(None) and leaves every other error an error.

The resulting shape for a header parser is fn parse(buf: &[u8]) -> io::Result<Option<(Header, usize)>>:

Return Meaning for the core
Ok(None) Not enough bytes yet. Consume 0 and wait for the next Event::Transport.
Ok(Some((header, used))) A whole header. Consume used.
Err(e) The peer is not speaking this protocol. Fail the connection.

trojan/protocol.rs → parse_request_header shows the idiom:

let hash = match need_more(take_array::<HASH_LEN>(buf, 0).map_err(io::Error::from))? {
Some(hash) => hash,
None => return Ok(None),
};
let Some(crlf) = need_more(take_array::<2>(buf, HASH_LEN).map_err(io::Error::from))? else {
return Ok(None);
};
if crlf != CRLF {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"trojan: not trojan protocol (missing CRLF after hash)",
));
}

Keep need_more to the results of slice parsing. An UnexpectedEof from a real read means the peer closed, and need_more would turn it into a wait. Compute lengths read from the wire with checked_add or saturating_add, and check fixed fields (versions, reserved bytes, separators, commands) explicitly: a wrong separator must be an error, not a wait.

byte_newtype! (protocols/src/macros.rs, exported at the crate root) declares a Clone + Copy newtype over [u8; N] with a pub const fn as_bytes(&self) -> &[u8; N]. vmess/aead.rs uses it for GcmKey (16 bytes), GcmNonce (12 bytes) and ConnNonce (8 bytes), so values with different roles do not mix at a call site. Role-named constructors keep the pairs of the same size apart.

pub fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec<u8>;
pub fn hkdf_sha1_ss_subkey(master_key: &[u8], salt: &[u8], out: &mut [u8]);
pub fn increment_le(nonce: &mut [u8]);
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool;
Function What it computes Callers
evp_bytes_to_key OpenSSL EVP_BytesToKey with MD5 and no salt: D_i = MD5(D_(i-1) ‖ password), concatenated and truncated to key_len. This is how Shadowsocks derives the master key from a password. ss_legacy/users.rs; the Shadowsocks outbounds of the app (app/src/outbound/mod.rs) and of katana (src/outbound/mod.rs)
hkdf_sha1_ss_subkey HKDF-SHA1 with the salt, the master key as input key material and the info string ss-subkey, filling out. This is the per-session Shadowsocks AEAD subkey. ss_legacy/aead.rs
increment_le Adds one to a little-endian counter in place, carrying across bytes and wrapping at the top. The chunk nonces in ss_legacy/aead.rs and ss_2022/crypto.rs
ct_eq Constant-time equality through subtle::ConstantTimeEq. Slices of different length compare unequal. trojan/users.rs → Validator::get; the response-salt checks in ss_2022/protocol.rs and ss_2022/codec.rs; hysteria/server/authenticator.rs

hkdf_sha1_ss_subkey cannot fail for Shadowsocks subkey sizes of 16 or 32 bytes: HKDF-SHA1 fails only above 255 × 20 bytes of output. On that unreachable path it zero-fills out instead of panicking, because of the crate lints.

Invariant Mechanism Pinned by
Production code in the crate does not panic on input Crate-level deny of unwrap_used, expect_used, indexing_slicing and arithmetic_side_effects; take, take_array and checked arithmetic cargo clippy --workspace --all-targets --all-features -- -D warnings
A short slice waits, a malformed one fails Truncated maps to UnexpectedEof, and need_more maps only that kind to None request_header_is_parsed_from_a_slice_once_whole in protocols/tests/unit/trojan/protocol.rs and in protocols/tests/unit/vless/protocol.rs
The handshake deadline is armed once and counts from the first bytes Timing.armed; touch in Handshake timing_arms_handshake_once_then_idle_per_byte_event in protocols/tests/unit/core/mod.rs
The relay deadline is an idle limit touch re-arms RELAY_IDLE_TIMEOUT on every byte event Same test
An idle expiry finishes the connection Timing::expired pushes Effect::Finish and moves to Closing Same test; passthrough_core_finishes_when_the_outbound_fails_or_idles
Handshake and sniff expiry are left to the core expired pushes nothing for them timing_reports_handshake_and_sniff_expiry_to_the_core
Sniffing never holds more than SNIFF_LIMIT bytes SniffPrefix::push takes at most Collector::remaining() sniff_prefix_takes_no_more_than_its_budget
Sniffed bytes are forwarded, not lost Open then ForwardHeld over the held prefix; clear only at the next byte event sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes, a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host, a_sniffing_passthrough_core_opens_on_the_sniff_deadline
A flow addressed by domain is never held for sniffing worth_sniffing a_sniffing_passthrough_core_with_a_domain_target_opens_at_once
A relay finishes only when both halves have closed The two Passthrough flags passthrough_half_closes_each_side_and_finishes_on_the_second
A reused mux session id never collides with the outbound still going away SubKey.generation, incremented on every New stream_sessions_open_forward_and_end_with_fresh_generations in protocols/tests/unit/mux/demux.rs
Encoded addresses round-trip and encoded_len is exact AddressCodec write_slice_matches_write_buf_and_bounds_itself in protocols/tests/unit/helpers/address.rs
Prefer* keeps the other family as a fallback Stable sort in select_candidate_ips prefer_ipv4_keeps_ipv6_as_fallback in protocols/tests/unit/helpers/address_family.rs
A netstack outbound never gets an address it cannot source FamilySupport::from_addrs auto_skips_families_without_a_local_address
  • Handshake timeout. On Expired::Handshake the core returns Err(handshake_timed_out()) (TimedOut), and the runtime ends the connection. A client that sends nothing at all is ended by the driver’s watchdog instead (see the table under Phase, Timing and Expired).
  • Unknown user. Trojan, VLESS, VMess and Shadowsocks return an io::Error of kind PermissionDenied, and Shadowsocks 2022 returns InvalidData; the connection ends without a reply. The HTTP core instead stages a 407 response, then ShutdownTransport and Finish.
  • Malformed framing. Parsers return InvalidData, directly or as a converted ProtocolError, and the error ends the connection. Nothing in sniff can fail a connection: bytes it does not recognise yield no sniffed domain.
  • Outbound failure. ConnectFailed or OutboundError leads to Passthrough::on_outbound_gone, which pushes ShutdownTransport and Finish. Staged bytes are still written.
  • Staging overrun. staging_full() fails the connection. It signals a wrong STAGING_RESERVE in the core, not a hostile peer.
  • Cancellation. Cores own no tasks, timers or sockets, so dropping a core is always safe. Cancellation happens above them: the app spawns each connection under its generation’s CancellationToken (see Generations and reload), and dropping the runtime drops the core.
Constant Value Defined in
HANDSHAKE_TIMEOUT 10 s protocols/src/core/mod.rs
RELAY_IDLE_TIMEOUT 300 s protocols/src/core/mod.rs
SNIFF_TIMEOUT 300 ms protocols/src/sniff/mod.rs
SNIFF_LIMIT 4 KiB (4096 bytes) protocols/src/sniff/mod.rs
PassthroughCore::BUF_SIZE 8 KiB protocols/src/core/mod.rs
PassthroughCore::STAGING_RESERVE 0 protocols/src/core/mod.rs
HARNESS_STAGING 64 KiB protocols/src/core/harness.rs
AddressCodec::MAX_LEN 259 bytes protocols/src/helpers/address.rs
Longest domain AddressCodec can encode 255 bytes (one length byte) protocols/src/helpers/address.rs

Most modules follow the same split. Not every module has every file, and a module adds files for what is specific to it (VMess adds aead.rs, keys.rs, framing.rs and session.rs; Shadowsocks adds aead.rs or crypto.rs).

File Holds Examples
mod.rs Module docs and the public re-exports trojan/mod.rs: pub use core::TrojanCore;
protocol.rs Wire primitives: constants, the module’s ADDR codec, slice parsers returning io::Result<Option<(T, usize)>>, encoders, and async read and write forms used by tests trojan/protocol.rs → parse_request_header, encode_request_header
core.rs The server core: a state enum, Timing, SniffPrefix, a Passthrough once relaying, BUF_SIZE, STAGING_RESERVE, is_established TrojanCore, VlessCore, VMessCore, ShadowsocksCore, Ss2022Core, HttpCore
codec.rs Client codecs implementing ProxyCoreEncodeHandshake plus ProxyCoreEncode (streams) or ProxyCoreEncodeDatagram (UDP) TrojanStream, TrojanDatagram, VlessStream, SsStream, HttpConnect
config.rs Server configuration types VlessServerConfig, HttpServerConfig, SocksServerConfig
users.rs, validator.rs, accounts.rs User tables, generic over the payload T and handing out Arc<T>. The core holds them behind an Arc. Some modules keep their server config here too (TrojanServerConfig, ShadowsocksServerConfig, Ss2022ServerConfig). trojan::Validator, vless::Validator, vmess::AccountValidator

The exceptions are structural:

  • SOCKS is served by its own driver (socks/server.rs → SocksInbound), not by a core.
  • Hysteria 2 owns its QUIC endpoint and runs one runtime per proxy stream (Hy2StreamCore) and one per connection’s datagrams (Hy2UdpCore).
  • TUN owns its device and runs one runtime per TCP connection (PassthroughCore) and one per client source’s UDP (TunUdpCore).
  • WireGuard has only an outbound connector.

Unit tests live outside src/, under protocols/tests/unit/<module>/<file>.rs. They are compiled into the module they test, so they can reach private items:

#[cfg(test)]
#[path = "../../tests/unit/trojan/core.rs"]
mod tests;

Pipeline tests run a server core and a client codec against each other under the real runtime. They are modules of protocols/tests/pipeline.rs, one file per protocol under protocols/tests/pipeline/. The hysteria and tun modules are gated by the same features as the code they test.

  1. Write the wire primitives in protocols/src/<name>/protocol.rs. Parse from slices with take, take_array and checked arithmetic, return Ok(None) through need_more while bytes are missing, and check every fixed field (version, reserved bytes, lengths, command) explicitly. Reuse AddressCodec::SOCKS or AddressCodec::VMESS if the address layout matches; otherwise declare a new AddressCodec value. Put the result in pub const ADDR.

  2. Write the server core in core.rs. Implement ProxyCoreDecode with Target = Flow<T>, Error = io::Error, Key = Single (or FlowKey if it can carry mux.cool) and TransportAddr = () for a stream transport. Call timing.touch(fx) at the top of every byte event, enter(Phase::Sniff) or enter(Phase::Relay) once the request is parsed, and handle all three Expired values. Declare BUF_SIZE for the largest frame the protocol admits and a STAGING_RESERVE that covers everything one call stages beyond its payload, and expose is_established().

  3. Write the client codecs in codec.rs against ProxyCoreEncodeHandshake plus ProxyCoreEncode and, if the protocol carries UDP, ProxyCoreEncodeDatagram. See Client runtime.

  4. Add users and configuration in users.rs or validator.rs and config.rs, generic over T. Compare secrets with ct_eq and scan the whole table, as trojan::Validator::get does. Never log credentials or keys.

  5. Register the module in protocols/src/lib.rs. Put it behind a new Cargo feature if it brings a heavy dependency tree, as hysteria and tun do, and gate its pipeline test module the same way.

  6. Test it. Write unit tests in protocols/tests/unit/<name>/ that drive the core through CoreHarness, including negative tests for malformed input, truncated input and unknown users. Add a pipeline test in protocols/tests/pipeline/<name>.rs and register it in protocols/tests/pipeline.rs.

  7. Wire it into the app. Add the settings structs in app/src/config.rs, a StreamProtocol variant and its build arm in app/src/inbound/mod.rs, a drive::<{ Core::<()>::BUF_SIZE }, _, _> arm in app/src/serve.rs with the core’s name added to the established! list, and an Outbound variant in app/src/outbound/mod.rs. See Build pipeline.

  8. Run the gates: cargo fmt --all -- --check, cargo test --workspace and cargo clippy --workspace --all-targets --all-features -- -D warnings.

Test File Covers
timing_arms_handshake_once_then_idle_per_byte_event protocols/tests/unit/core/mod.rs One handshake arm, an idle re-arm per event, and an idle expiry that pushes Finish
timing_reports_handshake_and_sniff_expiry_to_the_core same Expired::Handshake and Expired::Sniff push nothing
sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes same A Host header split across two pushes; held bytes kept until clear
sniff_prefix_takes_no_more_than_its_budget same The SNIFF_LIMIT cap and Exhausted
passthrough_half_closes_each_side_and_finishes_on_the_second same Half-close order and Finish
passthrough_core_opens_on_the_first_bytes_and_relays_verbatim same The order of open, deadline and forward
passthrough_core_finishes_when_the_outbound_fails_or_idles same ConnectFailed and an idle Deadline
a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host same Sniff, Open with the domain, ForwardHeld, then clear
a_sniffing_passthrough_core_opens_on_the_sniff_deadline same Opening on Expired::Sniff without a domain
a_sniffing_passthrough_core_with_a_domain_target_opens_at_once same No sniff phase for a domain destination
passthrough_runtime_relays_a_tcp_flow_and_half_closes protocols/tests/pipeline/core.rs PassthroughCore relaying 100,000 bytes under the real runtime
write_slice_matches_write_buf_and_bounds_itself protocols/tests/unit/helpers/address.rs Both layouts round-trip; encoded_len; a short output and a 256-byte domain are rejected
authority_ipv4_with_port, authority_domain_default_port, authority_ipv6_bracketed same parse_authority
parses_address_family_strategy_aliases, auto_skips_families_without_a_local_address, auto_keeps_resolver_order_when_both_families_are_supported, ipv4_only_filters_to_ipv4, prefer_ipv4_keeps_ipv6_as_fallback, a_kernel_routed_dialer_is_limited_by_policy_alone, the_capability_clause_is_omitted_for_a_kernel_routed_dialer protocols/tests/unit/helpers/address_family.rs Aliases, filtering, fallback order and error text
request_header_is_parsed_from_a_slice_once_whole protocols/tests/unit/trojan/protocol.rs, protocols/tests/unit/vless/protocol.rs need_more returns None at every cut of a header
stream_sessions_open_forward_and_end_with_fresh_generations protocols/tests/unit/mux/demux.rs A new SubKey generation per New
evp_key_known_answer protocols/tests/unit/ss_legacy/aead.rs evp_bytes_to_key against MD5 of the password
unknown_user_is_refused, unknown_uuid_is_refused, an_unknown_password_is_refused protocols/tests/unit/trojan/core.rs, protocols/tests/unit/vless/core.rs, protocols/tests/unit/ss_legacy/core.rs PermissionDenied from the cores