Plugin Architecture
Infrarust processes every player connection through a layered pipeline. Each layer operates at a different level of abstraction, from raw TCP bytes to high-level game events. Plugins hook into whichever layer matches what they need to do.
Native plugins are Rust crates compiled into the proxy binary. They run in-process with full access to every proxy service, including the transport-level byte stream. There is no sandbox: a native plugin is trusted code that the operator chose to compile in, and a misbehaving one can crash or stall the proxy. WASM plugins use a separate capability-gated model and do not reach the lowest layers described here. This page covers the native layers.
The pipeline
A connection flows through four layers in order:
TCP connection accepted
│
▼
┌───────────────────┐
│ TransportFilter │ Raw TCP bytes, before Minecraft framing.
│ (Layer 1) │ Can reject connections at the TCP level.
└───────┬───────────┘
│
▼
┌───────────────────┐
│ CodecFilter │ Framed Minecraft packets (RawPacket).
│ (Layer 2) │ Synchronous, per-connection instances.
└───────┬───────────┘
│
▼
┌───────────────────┐
│ EventBus │ High-level events (login, chat, kicks).
│ (Layer 3) │ Async handlers, priority-ordered.
└───────┬───────────┘
│
▼
┌───────────────────┐
│ LimboHandler │ Session-level control. Holds players
│ (Layer 4) │ in proxy-hosted worlds.
└───────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Each layer is independent. A plugin can hook into one layer, multiple layers, or all four depending on its needs. A rate-limiter plugin might only use TransportFilter. An auth plugin uses EventBus for login events and LimboHandler to hold unauthed players. A packet inspector uses CodecFilter.
Layer 1: TransportFilter
Transport filters operate on raw TCP bytes before the proxy applies Minecraft packet framing. They see every connection, including passthrough-mode connections where the proxy does not decode packets at all.
The trait is defined in crates/infrarust-api/src/filter/transport.rs:
pub trait TransportFilter: Send + Sync {
fn metadata(&self) -> FilterMetadata;
fn on_accept<'a>(&'a self, ctx: &'a mut TransportContext)
-> BoxFuture<'a, FilterVerdict>;
fn on_client_data<'a>(
&'a self,
ctx: &'a mut TransportContext,
data: &'a mut BytesMut,
) -> BoxFuture<'a, FilterVerdict>;
fn on_server_data<'a>(
&'a self,
ctx: &'a mut TransportContext,
data: &'a mut BytesMut,
) -> BoxFuture<'a, FilterVerdict>;
fn on_close(&self, _ctx: &TransportContext) {}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
on_accept fires when a TCP connection arrives. Return FilterVerdict::Reject to close it immediately (IP bans, rate limiting). on_client_data and on_server_data fire on each chunk of bytes flowing in either direction, letting you inspect or modify the raw stream.
TransportContext carries connection metadata: remote address, local address, real IP (if behind PROXY protocol), connection time, byte counters, and a type-erased Extensions map for sharing state between filters.
pub enum FilterVerdict {
Continue, // No changes, pass data through.
Modified, // Data was modified in the BytesMut buffer.
Reject, // Drop the connection.
}2
3
4
5
Transport filters are shared instances (Send + Sync). The proxy calls them for every connection. Methods are async because this layer is not on the per-packet hot path.
WARNING
Transport filters are not available to WASM plugins. Direct byte access is restricted to native plugins.
Registering a transport filter
Register transport filters through PluginContext::transport_filters() during on_enable:
fn on_enable<'a>(
&'a self,
ctx: &'a dyn PluginContext,
) -> BoxFuture<'a, Result<(), PluginError>> {
Box::pin(async move {
if let Some(registry) = ctx.transport_filters() {
registry.register(Box::new(MyTransportFilter));
}
Ok(())
})
}2
3
4
5
6
7
8
9
10
11
Layer 2: CodecFilter
Codec filters operate on framed Minecraft packets (RawPacket). They run inline in the proxy's packet-forwarding loop for every single packet, so they must be fast (under 1 microsecond per call).
This layer uses a factory pattern. You register a CodecFilterFactory once globally, and the proxy creates per-connection CodecFilterInstance objects from it. Each instance holds mutable state for one connection and one side (client-side or server-side).
The factory trait (crates/infrarust-api/src/filter/codec.rs):
pub trait CodecFilterFactory: Send + Sync {
fn metadata(&self) -> FilterMetadata;
fn create(&self, ctx: &CodecSessionInit) -> Box<dyn CodecFilterInstance>;
}2
3
4
The per-connection instance trait:
pub trait CodecFilterInstance: Send {
fn filter(
&mut self,
packet: &mut RawPacket,
output: &mut FrameOutput,
) -> CodecVerdict;
fn on_state_change(&mut self, _new_state: ConnectionState) {}
fn on_compression_change(&mut self, _threshold: i32) {}
fn on_encryption_enabled(&mut self) {}
fn on_close(&mut self) {}
}2
3
4
5
6
7
8
9
10
11
12
The filter method is the hot path. It receives the packet and a FrameOutput for injecting extra packets. The instance learns about protocol state through the on_state_change, on_compression_change, and on_encryption_enabled callbacks rather than a per-call context, and the protocol version and side were fixed at creation time by CodecSessionInit:
pub enum CodecVerdict {
Pass, // Let the packet through (possibly modified in place).
Drop, // Discard the packet.
Replace, // Replace with packets injected via FrameOutput.
Error(CodecFilterError),
}2
3
4
5
6
FrameOutput lets you inject packets before or after the current one:
output.inject_before(RawPacket::new(0x01, data));
output.inject_after(RawPacket::new(0x02, data));2
The factory's create method receives a CodecSessionInit struct with the client's protocol version, connection ID, remote address, and which ConnectionSide (client or server) this instance will handle. The proxy calls create twice per session: once for the client-side, once for the server-side.
CodecFilterInstance is Send but not Sync. Each instance lives in a single tokio task. All methods are synchronous (no async) because they run on the packet hot path.
Registering a codec filter
if let Some(registry) = ctx.codec_filters() {
registry.register(Box::new(MyCodecFilterFactory));
}2
3
Layer 3: EventBus
The event bus is the primary hook point for most plugins. It fires typed events at key moments in the player lifecycle. Handlers subscribe with a priority and receive a mutable reference to the event, allowing inspection and modification.
Events fall into categories:
| Category | Events |
|---|---|
| Lifecycle | PreLoginEvent, PostLoginEvent, DisconnectEvent, OnlineAuthFailed, PermissionsSetupEvent |
| Connection | PlayerChooseInitialServerEvent, ServerPreConnectEvent, ServerConnectedEvent, ServerSwitchEvent, KickedFromServerEvent |
| Chat | ChatMessageEvent |
| Proxy | ProxyPingEvent, ProxyInitializeEvent, ProxyShutdownEvent, ConfigReloadEvent, ServerStateChangeEvent |
| Packet (Tier 3) | RawPacketEvent |
The events page documents each event's fields and result type.
Subscribing to events
ctx.event_bus().subscribe::<PostLoginEvent, _>(
EventPriority::NORMAL,
|event| {
tracing::info!("Player {} joined", event.profile.username);
},
);2
3
4
5
6
Async handlers work the same way:
ctx.event_bus().subscribe_async::<PreLoginEvent, _>(
EventPriority::EARLY,
|event| {
Box::pin(async move {
// async work here
})
},
);2
3
4
5
6
7
8
Resulted events
Some events implement ResultedEvent, which means handlers can change the outcome. The proxy reads the final result after all handlers have run.
For example, PreLoginEvent supports these results:
pub enum PreLoginResult {
Allowed, // Default. Proceed normally.
Denied { reason: Component }, // Kick the player.
ForceOffline, // Skip Mojang authentication.
ForceOnline, // Force Mojang authentication.
}2
3
4
5
6
ServerPreConnectEvent lets you redirect players to another backend, send them to a limbo handler chain, or deny the connection entirely. Its result enum also has a VirtualBackend arm, but routing to a virtual backend is not wired into the proxy yet (see below).
ChatMessageEvent lets you allow, deny, or modify messages.
Priority ordering
Listeners run in priority order from lowest value (FIRST = 0) to highest value (LAST = 255). Each listener sees modifications made by previous listeners.
EventPriority::FIRST // 0 runs first
EventPriority::EARLY // 64 before normal
EventPriority::NORMAL // 128 default
EventPriority::LATE // 192 after normal
EventPriority::LAST // 255 runs last2
3
4
5
Use EventPriority::custom(u8) for values between the named constants.
Packet-level events
For plugins that need to see individual packets without writing a CodecFilter, the event bus supports packet subscriptions filtered by packet ID, connection state, and direction:
ctx.event_bus().subscribe_packet_typed(
PacketFilter {
packet_id: 0x03,
state: ConnectionState::Play,
direction: PacketDirection::Serverbound,
},
EventPriority::NORMAL,
|event: &mut RawPacketEvent| {
event.drop_packet(); // Silently discard
},
);2
3
4
5
6
7
8
9
10
11
The proxy skips event dispatch for packets that have no listeners registered, so unused packet subscriptions have zero overhead.
Layer 4: LimboHandler
Limbo handlers give a plugin full control over a player's session without requiring raw protocol knowledge. The proxy hosts the player in a void world and manages the Minecraft protocol (JoinGame, KeepAlive, chunks). The handler receives high-level callbacks for chat, commands, and player entry.
The trait is defined in crates/infrarust-api/src/limbo/handler.rs:
pub trait LimboHandler: Send + Sync {
fn name(&self) -> &str;
fn on_player_enter<'a>(
&'a self,
session: &'a dyn LimboSession,
) -> BoxFuture<'a, HandlerResult>;
fn on_command<'a>(
&'a self,
_session: &'a dyn LimboSession,
_command: &'a str,
_args: &'a [&'a str],
) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
fn on_chat<'a>(
&'a self,
_session: &'a dyn LimboSession,
_message: &'a str,
) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
fn on_disconnect(&self, _player_id: PlayerId) -> BoxFuture<'_, ()> {
Box::pin(async {})
}
fn on_session_end(&self, _player_id: PlayerId, _reason: SessionEndReason)
-> BoxFuture<'_, ()> {
Box::pin(async {})
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
on_disconnect fires only when the client drops. on_session_end fires on every terminal outcome (released, kicked, redirected, timed out, or shutdown, via the SessionEndReason enum), so it is the right place to tear down retained state uniformly. The per-session cancellation token from LimboSession::cancellation_token() is cancelled around the same time.
on_player_enter determines what happens when the player arrives. The handler returns a HandlerResult:
pub enum HandlerResult {
Accept, // Continue to the next handler or the real server.
Deny(Component), // Kick the player.
Hold, // Keep the player in limbo until complete() is called.
Redirect(ServerId), // Send to a specific server.
SendToLimbo(Vec<String>), // Chain into another set of limbo handlers.
HoldWithTimeout { // Like Hold, but auto-complete after the deadline.
after: Duration,
on_timeout: Box<HandlerResult>,
},
}2
3
4
5
6
7
8
9
10
11
When a handler returns Hold, the player stays in the void world. The handler uses the LimboSession to communicate: send chat messages, display titles, show action bar text. When it's done (player authenticated, server finished booting), it calls session.complete(result) to release the player. HoldWithTimeout is the same, except the engine owns a timer and applies on_timeout if complete() is not called within after. The deadline holds even if the handler's own task dies, and on_timeout must be a terminal result (Accept, Deny, Redirect, or SendToLimbo); a nested hold there is treated as Accept.
Limbo handlers are chained. Each server configuration lists which limbo handlers run and in what order. A player passes through them sequentially.
Registering a limbo handler
ctx.register_limbo_handler(Box::new(MyLimboHandler));The handler's name() return value must match the name used in server configuration files.
Filter ordering
Both TransportFilter and CodecFilter use FilterMetadata for ordering within their chains:
pub struct FilterMetadata {
pub id: String,
pub priority: FilterPriority,
pub after: Vec<String>,
pub before: Vec<String>,
}2
3
4
5
6
FilterPriority controls the base execution order:
pub enum FilterPriority {
First = 0, // Security filters.
Early = 1,
Normal = 2, // Default.
Late = 3,
Last = 4, // Logging filters.
}2
3
4
5
6
7
The after and before fields express explicit dependencies between filters by ID. If filter A lists filter B in its after field, A is guaranteed to run after B regardless of priority.
Plugin tiers
The layers map to three plugin complexity tiers:
| Tier | Capability | Key traits |
|---|---|---|
| 1 | Event listeners, commands, services | Plugin, EventBus |
| 2 | Limbo handlers (proxy manages protocol) | LimboHandler, LimboSession |
| 3 | Codec and transport filters, full packet control | CodecFilterFactory, TransportFilter |
Most plugins only need Tier 1. The auth plugin uses Tier 1 plus Tier 2 (events for login flow, limbo for the login screen). Packet-rewriting plugins use Tier 3.
Virtual backends (planned)
A virtual backend is a proxy-hosted "server" that speaks raw Minecraft packets directly to the client. Unlike a limbo handler, where the proxy manages the protocol for you, a virtual backend handles everything itself: JoinGame, chunks, KeepAlive responses. The VirtualBackendHandler trait and the ServerPreConnectResult::VirtualBackend arm both exist in infrarust_api:
pub trait VirtualBackendHandler: Send + Sync {
fn name(&self) -> &str;
fn on_session_start(&self, session: &dyn VirtualBackendSession)
-> BoxFuture<'_, ()>;
fn on_packet_received(&self, session: &dyn VirtualBackendSession,
packet: &RawPacket) -> BoxFuture<'_, ()>;
fn on_session_end(&self, player_id: PlayerId) -> BoxFuture<'_, ()>;
}2
3
4
5
6
7
8
The proxy core does not act on the VirtualBackend result yet. Returning it from an event handler is a no-op on the current release, so treat virtual backends as planned, not available. Use a limbo handler for proxy-hosted screens today.
Planned
Virtual backend routing is not implemented in the proxy on 2.0.0-beta.1. The trait is published so the API can stabilize ahead of the runtime support.
Choosing the right layer
| You want to... | Use |
|---|---|
| Block IPs, rate-limit connections | TransportFilter |
| Inspect or rewrite raw TCP bytes | TransportFilter |
| Modify, drop, or inject Minecraft packets | CodecFilter |
| React to player login, disconnect, chat | EventBus |
| Redirect players between servers | EventBus (ServerPreConnectEvent) |
| Customize the server list ping | EventBus (ProxyPingEvent) |
| Hold a player in a waiting room | LimboHandler |
| Build a proxy-hosted screen or minigame | LimboHandler (VirtualBackendHandler is planned) |