feat: add plugin support

This commit is contained in:
Alexander Medvedev 2026-09-08 17:33:35 +02:00
parent 9ca11c175f
commit a100ba9549
29 changed files with 3496 additions and 57 deletions

1010
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,9 @@ license = "GPL-3.0"
pumpkin-protocol = { path = "../Pumpkin/crates/pumpkin-protocol" }
pumpkin-util = { path = "../Pumpkin/crates/pumpkin-util" }
pumpkin-command = { path = "../Pumpkin/crates/pumpkin-command" }
pumpkin-plugin-runtime = { path = "../Pumpkin/crates/pumpkin-plugin-runtime" }
wasmtime = { git = "https://github.com/bytecodealliance/wasmtime", rev = "f6b3140dbb1d9b436ef523d14740292c956b0364", default-features = false, features = ["runtime", "component-model", "component-model-async", "async", "cache", "cranelift", "gc", "gc-drc", "threads", "std"] }
wasmtime-wasi = { git = "https://github.com/bytecodealliance/wasmtime", rev = "f6b3140dbb1d9b436ef523d14740292c956b0364", default-features = false, features = ["p2", "p3"] }
tokio = { version = "1.53", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }

View File

@ -17,7 +17,9 @@ use pumpkin_protocol::java::packet_decoder::TCPNetworkDecoder;
use pumpkin_protocol::java::packet_encoder::TCPNetworkEncoder;
use pumpkin_protocol::java::server::handshake::SHandShake;
use pumpkin_protocol::java::server::login::{SLoginPluginResponse, SLoginStart};
use pumpkin_protocol::java::server::play::{SChatCommand, SCommandSuggestion};
use pumpkin_protocol::java::server::play::{
SChatCommand, SChatMessage, SCommandSuggestion, SCustomPayload,
};
use pumpkin_protocol::packet::MultiVersionJavaPacket;
use pumpkin_protocol::ser::{NetworkReadExt, NetworkReadSliceExt};
use pumpkin_protocol::{ClientPacket, ConnectionState, ServerPacket};
@ -37,6 +39,11 @@ use crate::backend::forwarding::{
use crate::command::{CommandDispatcher, CommandSender, ProxyCommandSource};
use crate::config::{BackendConfig, Config, ForwardingMode, NetworkConfig};
use crate::network;
use crate::plugin::PluginManager;
use crate::plugin::api::{
PlayerChatEvent, PlayerCommandEvent, PlayerDisconnectEvent, PlayerJoinEvent,
PluginMessageEvent, ServerConnectEvent, ServerConnectedEvent,
};
use crate::security::PacketRateLimiter;
use crate::session::{PlayerAction, PlayerSession, SessionManager};
@ -87,6 +94,7 @@ pub struct BackendBridge {
session_manager: Arc<SessionManager>,
command_dispatcher: Arc<CommandDispatcher>,
proxy_config: Arc<Config>,
plugin_manager: Arc<PluginManager>,
}
impl BackendBridge {
@ -106,6 +114,7 @@ impl BackendBridge {
session_manager: Arc<SessionManager>,
command_dispatcher: Arc<CommandDispatcher>,
proxy_config: Arc<Config>,
plugin_manager: Arc<PluginManager>,
) -> Self {
Self {
server_name,
@ -122,6 +131,7 @@ impl BackendBridge {
session_manager,
command_dispatcher,
proxy_config,
plugin_manager,
}
}
@ -413,8 +423,39 @@ impl BackendBridge {
let mut current_server_name = self.server_name.clone();
let mut connect_event = ServerConnectEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
current_server: None,
target_server: current_server_name.clone(),
cancelled: false,
cancel_reason: None,
};
self.plugin_manager
.fire_server_connect(&mut connect_event)
.await;
if connect_event.cancelled {
let reason = connect_event
.cancel_reason
.unwrap_or_else(|| "Connection cancelled by plugin".to_string());
warn!(
"Player '{}' connection to '{}' cancelled by plugin: {}",
self.player.username, connect_event.target_server, reason
);
return Err(BridgeError::VineError(reason));
}
current_server_name = connect_event.target_server;
let initial_backend_cfg = if current_server_name == self.server_name {
self.config.clone()
} else if let Some(cfg) = self.proxy_config.servers.get(&current_server_name) {
cfg.clone()
} else {
self.config.clone()
};
let (mut backend_decoder, mut backend_encoder, success_bytes) = match self
.login_backend(&current_server_name, &self.config)
.login_backend(&current_server_name, &initial_backend_cfg)
.await
{
Ok(res) => res,
@ -454,12 +495,31 @@ impl BackendBridge {
self.online_players.load(Ordering::Relaxed)
);
let connected_event = ServerConnectedEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
server_name: current_server_name.clone(),
};
self.plugin_manager
.fire_server_connected(&connected_event)
.await;
let join_event = PlayerJoinEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
client_ip: self.client_ip.to_string(),
initial_server: current_server_name.clone(),
};
self.plugin_manager.fire_player_join(&join_event).await;
let (action_tx, mut action_rx) = tokio::sync::mpsc::unbounded_channel();
self.session_manager.register(PlayerSession {
username: self.player.username.clone(),
uuid: self.player.id,
current_server: current_server_name.clone(),
action_tx: action_tx.clone(),
client_ip: self.client_ip.to_string(),
protocol_version: self.client_version.protocol_version() as u32,
});
loop {
@ -467,8 +527,8 @@ impl BackendBridge {
&self.player.username,
&mut client_decoder,
&mut client_encoder,
backend_decoder,
backend_encoder,
&mut backend_decoder,
&mut backend_encoder,
self.max_packets_per_sec,
self.client_version,
&mut action_rx,
@ -476,6 +536,9 @@ impl BackendBridge {
self.command_dispatcher.clone(),
self.proxy_config.clone(),
self.session_manager.clone(),
self.plugin_manager.clone(),
self.player.id,
current_server_name.clone(),
)
.await;
@ -518,6 +581,32 @@ impl BackendBridge {
self.player.username, server_name
);
let mut connect_event = ServerConnectEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
current_server: Some(current_server_name.clone()),
target_server: server_name.clone(),
cancelled: false,
cancel_reason: None,
};
self.plugin_manager
.fire_server_connect(&mut connect_event)
.await;
if connect_event.cancelled {
let reason = connect_event
.cancel_reason
.as_deref()
.unwrap_or("Server switch cancelled by plugin");
let msg = TextComponent::text(format!("§c{}", reason));
let chat_msg = CSystemChatMessage::new(&msg, false);
if let Ok(bytes) = chat_msg.serialize_packet(&self.client_version) {
let _ = client_encoder.write_packet(bytes).await;
let _ = client_encoder.flush().await;
}
continue;
}
let server_name = connect_event.target_server;
let target_config = match self.proxy_config.servers.get(&server_name) {
Some(cfg) => cfg.clone(),
None => {
@ -552,6 +641,14 @@ impl BackendBridge {
"Player '{}' successfully switched to '{}'",
self.player.username, current_server_name
);
let connected_event = ServerConnectedEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
server_name: current_server_name.clone(),
};
self.plugin_manager
.fire_server_connected(&connected_event)
.await;
}
Err(e) => {
warn!(
@ -695,6 +792,15 @@ impl BackendBridge {
}
}
let disconnect_event = PlayerDisconnectEvent {
uuid: self.player.id.to_string(),
username: self.player.username.clone(),
last_server: Some(current_server_name.clone()),
};
self.plugin_manager
.fire_player_disconnect(&disconnect_event)
.await;
self.session_manager.unregister(&self.player.username);
self.online_players.fetch_sub(1, Ordering::Relaxed);
info!(
@ -907,8 +1013,8 @@ impl BackendBridge {
username: &str,
client_decoder: &mut TCPNetworkDecoder<R1>,
client_encoder: &mut TCPNetworkEncoder<W1>,
mut backend_decoder: TCPNetworkDecoder<R2>,
mut backend_encoder: TCPNetworkEncoder<W2>,
backend_decoder: &mut TCPNetworkDecoder<R2>,
backend_encoder: &mut TCPNetworkEncoder<W2>,
max_packets_per_sec: u32,
client_version: JavaMinecraftVersion,
action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<PlayerAction>,
@ -916,6 +1022,9 @@ impl BackendBridge {
command_dispatcher: Arc<CommandDispatcher>,
proxy_config: Arc<Config>,
session_manager: Arc<SessionManager>,
plugin_manager: Arc<PluginManager>,
player_uuid: uuid::Uuid,
current_server_name: String,
) -> Result<TunnelExit, BridgeError>
where
R1: AsyncRead + Unpin,
@ -925,6 +1034,8 @@ impl BackendBridge {
{
let mut client_rate_limiter = PacketRateLimiter::new(max_packets_per_sec);
let chat_command_id = SChatCommand::to_id(client_version);
let chat_message_id = SChatMessage::to_id(client_version);
let custom_payload_id = SCustomPayload::to_id(client_version);
let command_suggestion_id = SCommandSuggestion::to_id(client_version);
let command_suggestions_clientbound_id = CCommandSuggestions::to_id(client_version);
let commands_packet_id = CCommands::to_id(client_version);
@ -967,8 +1078,22 @@ impl BackendBridge {
if let Ok(chat_cmd) = SChatCommand::read(&mut payload, &client_version)
{
let cmd_text = chat_cmd.command;
let first_word =
cmd_text.split_whitespace().next().unwrap_or(cmd_text);
let mut cmd_event = PlayerCommandEvent {
uuid: player_uuid.to_string(),
username: username.to_string(),
command: cmd_text.to_string(),
cancelled: false,
};
plugin_manager.fire_player_command(&mut cmd_event).await;
if cmd_event.cancelled {
continue;
}
let first_word = cmd_event
.command
.split_whitespace()
.next()
.unwrap_or(&cmd_event.command);
if command_dispatcher.has_command(first_word) {
let source = ProxyCommandSource {
sender: CommandSender::Player {
@ -978,7 +1103,43 @@ impl BackendBridge {
config: proxy_config.clone(),
session_manager: session_manager.clone(),
};
command_dispatcher.handle_command(&source, cmd_text);
command_dispatcher.handle_command(&source, &cmd_event.command);
continue;
}
}
}
if raw_packet.id == chat_message_id {
let mut payload = &raw_packet.payload[..];
if let Ok(chat_msg) = SChatMessage::read(&mut payload, &client_version)
{
let mut chat_event = PlayerChatEvent {
uuid: player_uuid.to_string(),
username: username.to_string(),
message: chat_msg.message.to_string(),
cancelled: false,
};
plugin_manager.fire_player_chat(&mut chat_event).await;
if chat_event.cancelled {
continue;
}
}
}
if raw_packet.id == custom_payload_id {
let mut payload = &raw_packet.payload[..];
if let Ok(custom_payload) =
SCustomPayload::read(&mut payload, &client_version)
{
let mut msg_event = PluginMessageEvent {
channel: custom_payload.channel.to_string(),
data: custom_payload.data.to_vec(),
player_uuid: Some(player_uuid.to_string()),
server_name: Some(current_server_name.clone()),
cancelled: false,
};
plugin_manager.fire_plugin_message(&mut msg_event).await;
if msg_event.cancelled {
continue;
}
}
@ -1032,9 +1193,8 @@ impl BackendBridge {
// 2. Root command name completion (no space, e.g. "/", "/s", "/ser", "/server")
if !clean_cmd.contains(' ') {
let clean_lower = clean_cmd.to_ascii_lowercase();
let permitted = command_dispatcher
.raw()
.get_all_permitted_commands(&source);
let permitted =
command_dispatcher.get_all_permitted_commands(&source);
let proxy_matches: Vec<(String, Option<String>)> = permitted
.into_iter()
.filter(|(cmd_name, _)| cmd_name.starts_with(&clean_lower))
@ -1171,9 +1331,11 @@ impl BackendBridge {
config: proxy_config.clone(),
session_manager: session_manager.clone(),
};
let permitted =
command_dispatcher.raw().get_all_permitted_commands(&source);
let permitted_refs: Vec<(&str, &str)> = permitted.into_iter().collect();
let permitted = command_dispatcher.get_all_permitted_commands(&source);
let permitted_refs: Vec<(&str, &str)> = permitted
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
if let Some(modified_payload) = inject_proxy_commands(
&raw_packet.payload,
@ -1225,6 +1387,25 @@ impl BackendBridge {
let _ = client_tx.send(bytes);
}
}
PlayerAction::Disconnect(reason) => {
let text = TextComponent::text(reason.clone());
let disconnect_pkt = CPlayDisconnect::new(&text);
if let Ok(bytes) = disconnect_pkt.serialize_packet(&client_version) {
let _ = client_tx.send(bytes);
}
return TunnelExit::BackendDisconnected {
reason: Some(reason),
};
}
PlayerAction::PluginMessage { channel, data } => {
let custom_payload =
pumpkin_protocol::java::client::play::CCustomPayload::new(
&channel, &data,
);
if let Ok(bytes) = custom_payload.serialize_packet(&client_version) {
let _ = client_tx.send(bytes);
}
}
}
}
TunnelExit::ClientDisconnected

View File

@ -25,6 +25,8 @@ use crate::backend::{BackendBridge, Router};
use crate::command::CommandDispatcher;
use crate::config::Config;
use crate::network::PrefixedRead;
use crate::plugin::PluginManager;
use crate::plugin::api::{PlayerLoginEvent, PlayerPreLoginEvent, ProxyPingEvent};
use crate::security::Sanitizer;
use crate::session::SessionManager;
use crate::status::StatusHandler;
@ -55,6 +57,7 @@ pub struct ClientHandler {
online_players: Arc<AtomicUsize>,
session_manager: Arc<SessionManager>,
command_dispatcher: Arc<CommandDispatcher>,
plugin_manager: Arc<PluginManager>,
}
impl ClientHandler {
@ -69,6 +72,7 @@ impl ClientHandler {
online_players: Arc<AtomicUsize>,
session_manager: Arc<SessionManager>,
command_dispatcher: Arc<CommandDispatcher>,
plugin_manager: Arc<PluginManager>,
) -> Self {
Self {
peer_addr,
@ -80,6 +84,7 @@ impl ClientHandler {
online_players,
session_manager,
command_dispatcher,
plugin_manager,
}
}
@ -161,9 +166,29 @@ impl ClientHandler {
.map_err(|e| ClientError::DecodeError(e.to_string()))?;
if raw_status.id == 0x00 {
let status_pkt = self
.status_handler
.create_status_packet(client_version, Some(virtual_host));
let initial_motd = self.status_handler.resolve_motd_text(Some(virtual_host));
let initial_online = self
.online_players
.load(std::sync::atomic::Ordering::Relaxed) as u32;
let mut ping_event = ProxyPingEvent {
client_ip: self.peer_addr.ip().to_string(),
protocol_version: client_version.protocol_version() as u32,
virtual_host: virtual_host.to_string(),
motd: initial_motd,
max_players: self.config.motd.max_players,
online_players: initial_online,
version_name: self.config.motd.version_name.clone(),
};
self.plugin_manager.fire_proxy_ping(&mut ping_event).await;
let status_pkt = self.status_handler.create_custom_status_packet(
client_version,
&ping_event.motd,
ping_event.max_players,
ping_event.online_players,
&ping_event.version_name,
Some(virtual_host),
);
let status_bytes = status_pkt
.serialize_packet(&client_version)
.map_err(|e| ClientError::EncodeError(e.to_string()))?;
@ -215,6 +240,30 @@ impl ClientHandler {
let username = login_start.name.to_string();
let mut pre_login_event = PlayerPreLoginEvent {
client_ip: client_ip.to_string(),
username: username.clone(),
protocol_version: client_version.protocol_version() as u32,
virtual_host: virtual_host.clone(),
cancelled: false,
cancel_reason: None,
};
self.plugin_manager
.fire_player_pre_login(&mut pre_login_event)
.await;
if pre_login_event.cancelled {
let reason = pre_login_event
.cancel_reason
.as_deref()
.unwrap_or("Connection cancelled by plugin");
warn!(
"[{}] Player '{}' pre-login cancelled by plugin: {}",
self.peer_addr, username, reason
);
Self::kick(&mut encoder, client_version, reason).await?;
return Ok(());
}
if self.config.security.validate_usernames && !Sanitizer::is_valid_username(&username) {
warn!(
"[{}] Rejecting invalid username '{}' (failed sanitization)",
@ -347,6 +396,29 @@ impl ClientHandler {
AuthenticatedPlayer::offline(&username)
};
let mut login_event = PlayerLoginEvent {
uuid: authenticated_player.id.to_string(),
username: authenticated_player.username.clone(),
client_ip: client_ip.to_string(),
cancelled: false,
cancel_reason: None,
};
self.plugin_manager
.fire_player_login(&mut login_event)
.await;
if login_event.cancelled {
let reason = login_event
.cancel_reason
.as_deref()
.unwrap_or("Login cancelled by plugin");
warn!(
"[{}] Player '{}' login cancelled by plugin: {}",
self.peer_addr, authenticated_player.username, reason
);
Self::kick(&mut encoder, client_version, reason).await?;
return Ok(());
}
let comp_threshold = self.config.server.compression_threshold;
if comp_threshold > 0 {
let set_comp = CSetCompression::new(VarInt(comp_threshold));
@ -416,6 +488,7 @@ impl ClientHandler {
self.session_manager.clone(),
self.command_dispatcher.clone(),
self.config.clone(),
self.plugin_manager.clone(),
);
bridge.run(decoder, encoder).await?;

View File

@ -158,12 +158,16 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: tx1,
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
});
session_manager.register(PlayerSession {
username: "Alex".to_string(),
uuid: Uuid::new_v4(),
current_server: "survival".to_string(),
action_tx: tx2,
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
});
let (action_tx, mut action_rx) = mpsc::unbounded_channel();

View File

@ -215,9 +215,11 @@ impl CommandExecutor<ProxyCommandSource> for ListCommandExecutor {
}
}
use std::sync::RwLock;
/// Central registry and dispatcher for commands using `pumpkin-command`
pub struct CommandDispatcher {
inner: RawCommandDispatcher<ProxyCommandSource>,
inner: RwLock<RawCommandDispatcher<ProxyCommandSource>>,
}
impl Default for CommandDispatcher {
@ -246,18 +248,36 @@ impl CommandDispatcher {
.executes(CommandSpecificHelpExecutor),
),
);
Self { inner: dispatcher }
Self {
inner: RwLock::new(dispatcher),
}
}
/// Registers a new command builder into the dispatcher
pub fn register(
&self,
builder: pumpkin_command::argument_builder::CommandArgumentBuilder<ProxyCommandSource>,
) {
if let Ok(mut lock) = self.inner.write() {
lock.register(builder);
}
}
/// Returns true if a command with the given name is registered
pub fn has_command(&self, name: &str) -> bool {
let clean = name.trim_start_matches('/').to_ascii_lowercase();
self.inner.has_command(&clean)
if let Ok(lock) = self.inner.read() {
lock.has_command(&clean)
} else {
false
}
}
/// Dispatches a command for execution, reporting any error messages back to the source
pub fn handle_command(&self, source: &ProxyCommandSource, input: &str) {
self.inner.handle_command(source, input);
if let Ok(lock) = self.inner.read() {
lock.handle_command(source, input);
}
}
/// Executes raw input directly, returning the execution result
@ -268,7 +288,23 @@ impl CommandDispatcher {
) -> Result<i32, CommandSyntaxError> {
let trimmed = input.trim();
let clean = trimmed.strip_prefix('/').unwrap_or(trimmed);
self.inner.execute_input(clean, source)
if let Ok(lock) = self.inner.read() {
lock.execute_input(clean, source)
} else {
Err(command_error("Command dispatcher lock poisoned"))
}
}
/// Returns all permitted commands for the given source
pub fn get_all_permitted_commands(&self, source: &ProxyCommandSource) -> Vec<(String, String)> {
if let Ok(lock) = self.inner.read() {
lock.get_all_permitted_commands(source)
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} else {
Vec::new()
}
}
/// Returns suggestions with range information for any command input.
@ -278,14 +314,16 @@ impl CommandDispatcher {
let clean = input.strip_prefix('/').unwrap_or(input);
if clean.contains(' ') {
let first_word = clean.split_whitespace().next().unwrap_or(clean);
if self.has_command(first_word) {
return self.inner.suggest_with_range(clean, source);
if self.has_command(first_word)
&& let Ok(lock) = self.inner.read()
{
return lock.suggest_with_range(clean, source);
}
return Suggestions::empty();
}
let clean_lower = clean.to_ascii_lowercase();
let permitted = self.inner.get_all_permitted_commands(source);
let permitted = self.get_all_permitted_commands(source);
let mut suggestions = Vec::new();
for (cmd_name, description) in permitted {
if cmd_name.starts_with(&clean_lower) {
@ -316,16 +354,6 @@ impl CommandDispatcher {
pub fn suggest_with_range(&self, input: &str, source: &ProxyCommandSource) -> Suggestions {
self.suggest_command(input, source)
}
/// Exposes the inner pumpkin command dispatcher
pub fn raw(&self) -> &RawCommandDispatcher<ProxyCommandSource> {
&self.inner
}
/// Exposes a mutable reference to the inner pumpkin command dispatcher
pub fn raw_mut(&mut self) -> &mut RawCommandDispatcher<ProxyCommandSource> {
&mut self.inner
}
}
#[cfg(test)]

View File

@ -205,6 +205,8 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: tx,
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
});
let source = ProxyCommandSource {
@ -333,6 +335,8 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: tx,
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
});
let source = ProxyCommandSource {

View File

@ -182,6 +182,8 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: action_tx.clone(),
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
};
session_manager.register(session);

View File

@ -60,6 +60,29 @@ pub struct Config {
pub bedrock: crate::bedrock::BedrockConfig,
#[serde(default)]
pub permissions: PermissionsConfig,
#[serde(default)]
pub plugins: PluginConfig,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_plugins_dir")]
pub plugin_dir: String,
}
fn default_plugins_dir() -> String {
"./plugins".to_string()
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
enabled: true,
plugin_dir: default_plugins_dir(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -361,6 +384,7 @@ impl Default for Config {
telemetry: TelemetryConfig::default(),
bedrock: crate::bedrock::BedrockConfig::default(),
permissions: PermissionsConfig::default(),
plugins: PluginConfig::default(),
}
}
}

View File

@ -6,6 +6,7 @@ pub mod command;
pub mod config;
pub mod network;
pub mod permissions;
pub mod plugin;
pub mod proxy;
pub mod security;
pub mod session;

151
src/plugin/api.rs Normal file
View File

@ -0,0 +1,151 @@
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::Mutex;
pub type PluginFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub authors: Vec<String>,
pub description: Option<String>,
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum EventPriority {
Lowest = 0,
Low = 1,
Normal = 2,
High = 3,
Highest = 4,
Monitor = 5,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventType {
ProxyPing,
PlayerPreLogin,
PlayerLogin,
PlayerJoin,
ServerConnect,
ServerConnected,
PlayerDisconnect,
PlayerChat,
PlayerCommand,
PluginMessage,
}
#[derive(Debug, Clone)]
pub struct ProxyPingEvent {
pub client_ip: String,
pub protocol_version: u32,
pub virtual_host: String,
pub motd: String,
pub max_players: u32,
pub online_players: u32,
pub version_name: String,
}
#[derive(Debug, Clone)]
pub struct PlayerPreLoginEvent {
pub client_ip: String,
pub username: String,
pub protocol_version: u32,
pub virtual_host: String,
pub cancelled: bool,
pub cancel_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PlayerLoginEvent {
pub uuid: String,
pub username: String,
pub client_ip: String,
pub cancelled: bool,
pub cancel_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PlayerJoinEvent {
pub uuid: String,
pub username: String,
pub client_ip: String,
pub initial_server: String,
}
#[derive(Debug, Clone)]
pub struct ServerConnectEvent {
pub uuid: String,
pub username: String,
pub current_server: Option<String>,
pub target_server: String,
pub cancelled: bool,
pub cancel_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ServerConnectedEvent {
pub uuid: String,
pub username: String,
pub server_name: String,
}
#[derive(Debug, Clone)]
pub struct PlayerDisconnectEvent {
pub uuid: String,
pub username: String,
pub last_server: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PlayerChatEvent {
pub uuid: String,
pub username: String,
pub message: String,
pub cancelled: bool,
}
#[derive(Debug, Clone)]
pub struct PlayerCommandEvent {
pub uuid: String,
pub username: String,
pub command: String,
pub cancelled: bool,
}
#[derive(Debug, Clone)]
pub struct PluginMessageEvent {
pub channel: String,
pub data: Vec<u8>,
pub player_uuid: Option<String>,
pub server_name: Option<String>,
pub cancelled: bool,
}
pub struct PluginContext {
pub metadata: PluginMetadata,
pub data_folder: PathBuf,
pub registered_commands: Mutex<Vec<u32>>,
pub registered_events: Mutex<Vec<(u32, EventType, EventPriority)>>,
}
impl PluginContext {
#[must_use]
pub fn new(metadata: PluginMetadata, data_folder: PathBuf) -> Self {
Self {
metadata,
data_folder,
registered_commands: Mutex::new(Vec::new()),
registered_events: Mutex::new(Vec::new()),
}
}
}
pub trait Plugin: Send + Sync {
fn on_load(&self, context: Arc<PluginContext>) -> PluginFuture<'_, Result<(), String>>;
fn on_unload(&self, context: Arc<PluginContext>) -> PluginFuture<'_, Result<(), String>>;
}

1
src/plugin/loader/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod wasm;

View File

@ -0,0 +1,34 @@
pub mod wasm_host;
use std::path::Path;
use std::sync::Arc;
use crate::plugin::api::PluginMetadata;
use wasm_host::state::ProxyPluginContext;
pub use wasm_host::{PluginRuntime, WasmPlugin};
pub struct WasmPluginLoader {
runtime: PluginRuntime,
}
impl WasmPluginLoader {
pub fn new() -> Result<Self, wasm_host::PluginInitError> {
let runtime = PluginRuntime::new()?;
Ok(Self { runtime })
}
pub fn can_load(&self, path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|ext| ext.eq_ignore_ascii_case("wasm"))
.unwrap_or(false)
}
pub async fn load(
&self,
path: &Path,
proxy: Option<ProxyPluginContext>,
) -> Result<(Arc<WasmPlugin>, PluginMetadata), wasm_host::PluginInitError> {
self.runtime.init_plugin(path, proxy).await
}
}

View File

@ -0,0 +1,41 @@
use std::sync::Arc;
use pumpkin_plugin_runtime::{RuntimeSpawner, SpawnError, SpawnFuture, StoreExecutor};
use wasmtime::Store;
use super::state::PluginHostState;
pub use pumpkin_plugin_runtime::LegacySyncReentry;
pub type LegacyStore = StoreExecutor<PluginHostState, LegacySyncReentry>;
pub struct TokioSpawner {
runtime: tokio::runtime::Handle,
}
impl TokioSpawner {
#[must_use]
pub const fn new(runtime: tokio::runtime::Handle) -> Self {
Self { runtime }
}
}
impl RuntimeSpawner for TokioSpawner {
fn spawn(&self, task: SpawnFuture) -> Result<(), SpawnError> {
drop(self.runtime.spawn(task));
Ok(())
}
fn spawn_blocking(&self, task: Box<dyn FnOnce() + Send + 'static>) -> Result<(), SpawnError> {
drop(self.runtime.spawn_blocking(task));
Ok(())
}
}
pub async fn start_legacy_store(
store: Store<PluginHostState>,
policy: LegacySyncReentry,
spawner: Arc<dyn RuntimeSpawner>,
) -> wasmtime::Result<LegacyStore> {
StoreExecutor::start(store, policy, spawner).await
}

View File

@ -0,0 +1,267 @@
pub mod concurrent_store;
pub mod state;
pub mod wit;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use wasmtime::component::{Component, HasSelf, Linker};
use wasmtime::{Engine, Store};
use crate::plugin::api::{Plugin, PluginContext, PluginFuture, PluginMetadata};
use concurrent_store::{LegacyStore, LegacySyncReentry, TokioSpawner};
use state::{PluginHostState, ProxyPluginContext};
#[derive(Error, Debug)]
pub enum PluginInitError {
#[error("Failed to read plugin file '{0}': {1}")]
FileReadFailed(PathBuf, std::io::Error),
#[error("Failed to compile WebAssembly component: {0}")]
ComponentCompilationFailed(wasmtime::Error),
#[error("Failed to setup linker: {0}")]
LinkerSetupFailed(wasmtime::Error),
#[error("Failed to instantiate plugin: {0}")]
InstantiationFailed(wasmtime::Error),
#[error("Failed to initialize plugin: {0}")]
InitPluginFailed(wasmtime::Error),
#[error("Failed to retrieve metadata: {0}")]
GetMetadataFailed(wasmtime::Error),
#[error("Engine creation failed: {0}")]
EngineCreationFailed(wasmtime::Error),
}
pub fn setup_linker(engine: &Engine) -> wasmtime::Result<Linker<PluginHostState>> {
let mut linker = Linker::<PluginHostState>::new(engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wit::Plugin::add_to_linker::<_, HasSelf<_>>(&mut linker, |state: &mut PluginHostState| state)?;
Ok(linker)
}
pub struct WasmPlugin {
pub instance: wit::Plugin,
pub store: LegacyStore,
pub metadata: PluginMetadata,
}
impl WasmPlugin {
pub async fn on_load(&self, context: Arc<PluginContext>) -> Result<(), String> {
let func = self.instance.func_on_load();
let context_clone = context.clone();
self.store
.call_guest(move |mut guest| {
Box::pin(async move {
let context_res =
guest.with(|mut store| store.data_mut().add_context(context_clone))?;
let (res,) = guest.call(func, (context_res,)).await?;
Ok(res)
})
})
.await
.map_err(|e| e.to_string())?
}
pub async fn on_unload(&self, context: Arc<PluginContext>) -> Result<(), String> {
let func = self.instance.func_on_unload();
let context_clone = context.clone();
self.store
.call_guest(move |mut guest| {
Box::pin(async move {
let context_res =
guest.with(|mut store| store.data_mut().add_context(context_clone))?;
let (res,) = guest.call(func, (context_res,)).await?;
Ok(res)
})
})
.await
.map_err(|e| e.to_string())?
}
pub async fn handle_event(
&self,
handler_id: u32,
event: wit::vine::plugin::event::Event,
) -> Result<wit::vine::plugin::event::Event, wasmtime::Error> {
let func = self.instance.func_handle_event();
self.store
.call_guest(move |mut guest| {
Box::pin(async move {
let (res,) = guest.call(func, (handler_id, event)).await?;
Ok(res)
})
})
.await
}
pub async fn handle_command(
&self,
command_id: u32,
sender: wit::vine::plugin::types::CommandSender,
args: Vec<String>,
) -> Result<Result<i32, String>, wasmtime::Error> {
let func = self.instance.func_handle_command();
self.store
.call_guest(move |mut guest| {
Box::pin(async move {
let (res,) = guest.call(func, (command_id, sender, args)).await?;
Ok(res)
})
})
.await
}
pub async fn handle_command_suggestion(
&self,
command_id: u32,
sender: wit::vine::plugin::types::CommandSender,
args: Vec<String>,
) -> Result<Vec<String>, wasmtime::Error> {
let func = self.instance.func_handle_command_suggestion();
self.store
.call_guest(move |mut guest| {
Box::pin(async move {
let (res,) = guest.call(func, (command_id, sender, args)).await?;
Ok(res)
})
})
.await
}
}
impl Plugin for WasmPlugin {
fn on_load(&self, context: Arc<PluginContext>) -> PluginFuture<'_, Result<(), String>> {
Box::pin(async move { Self::on_load(self, context).await })
}
fn on_unload(&self, context: Arc<PluginContext>) -> PluginFuture<'_, Result<(), String>> {
Box::pin(async move { Self::on_unload(self, context).await })
}
}
pub struct PluginRuntime {
engine: Engine,
linker: Linker<PluginHostState>,
reentry: LegacySyncReentry,
spawner: Arc<TokioSpawner>,
}
impl PluginRuntime {
pub fn new() -> Result<Self, PluginInitError> {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.wasm_component_model_async(true);
config.concurrency_support(true);
let engine = Engine::new(&config).map_err(PluginInitError::EngineCreationFailed)?;
let linker = setup_linker(&engine).map_err(PluginInitError::LinkerSetupFailed)?;
let reentry = LegacySyncReentry::new();
let spawner = Arc::new(TokioSpawner::new(tokio::runtime::Handle::current()));
Ok(Self {
engine,
linker,
reentry,
spawner,
})
}
pub async fn init_plugin(
&self,
path: &Path,
proxy: Option<ProxyPluginContext>,
) -> Result<(Arc<WasmPlugin>, PluginMetadata), PluginInitError> {
let wasm_bytes =
fs::read(path).map_err(|e| PluginInitError::FileReadFailed(path.to_path_buf(), e))?;
let component = Component::new(&self.engine, &wasm_bytes)
.map_err(PluginInitError::ComponentCompilationFailed)?;
let instance_pre = self
.linker
.instantiate_pre(&component)
.map_err(PluginInitError::InstantiationFailed)?;
let plugin_pre =
wit::PluginPre::new(instance_pre).map_err(PluginInitError::InstantiationFailed)?;
let filename = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let data_folder = PathBuf::from("plugins").join("data").join(&filename);
let _ = fs::create_dir_all(&data_folder);
let mut store = Store::new(
&self.engine,
PluginHostState::new(filename, data_folder, proxy),
);
store.limiter(|state| &mut state.limits);
let plugin = self
.reentry
.scope_bootstrap(plugin_pre.instantiate_async(&mut store))
.await
.map_err(PluginInitError::InstantiationFailed)?;
store
.run_concurrent(async |accessor| {
self.reentry
.scope_bootstrap(plugin.call_init_plugin(accessor))
.await
})
.await
.map_err(PluginInitError::InitPluginFailed)?
.map_err(PluginInitError::InitPluginFailed)?;
let raw_meta = store
.run_concurrent(async |accessor| {
self.reentry
.scope_bootstrap(plugin.call_get_metadata(accessor))
.await
})
.await
.map_err(PluginInitError::GetMetadataFailed)?
.map_err(PluginInitError::GetMetadataFailed)?;
let metadata = PluginMetadata {
name: raw_meta.name.clone(),
version: raw_meta.version,
authors: raw_meta.authors,
description: raw_meta.description,
dependencies: raw_meta.dependencies,
};
store.data_mut().plugin_name = raw_meta.name;
let legacy_store = concurrent_store::start_legacy_store(
store,
self.reentry.clone(),
Arc::clone(&self.spawner) as Arc<dyn pumpkin_plugin_runtime::RuntimeSpawner>,
)
.await
.map_err(PluginInitError::InstantiationFailed)?;
let wasm_plugin = Arc::new(WasmPlugin {
instance: plugin,
store: legacy_store,
metadata: metadata.clone(),
});
let weak = Arc::downgrade(&wasm_plugin);
wasm_plugin
.store
.call(move |accessor| {
Box::pin(async move {
accessor.with(|mut store| {
store.data_mut().plugin = Some(weak);
});
Ok(())
})
})
.await
.map_err(PluginInitError::InstantiationFailed)?;
Ok((wasm_plugin, metadata))
}
}

View File

@ -0,0 +1,438 @@
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use wasmtime::component::{Resource, ResourceTable};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use crate::command::CommandDispatcher;
use crate::config::Config;
use crate::plugin::PluginManager;
use crate::plugin::api::{EventPriority, EventType, PluginContext};
use crate::plugin::loader::wasm::wasm_host::WasmPlugin;
use crate::session::{PlayerAction, SessionManager};
use super::wit;
#[derive(Clone)]
pub struct ProxyPluginContext {
pub config: Arc<Config>,
pub session_manager: Arc<SessionManager>,
pub command_dispatcher: Arc<CommandDispatcher>,
pub plugin_manager: Arc<PluginManager>,
}
pub struct ContextResource {
pub context: Arc<PluginContext>,
}
pub struct PluginHostState {
pub wasi_ctx: WasiCtx,
pub resource_table: ResourceTable,
pub limits: wasmtime::StoreLimits,
pub proxy: Option<ProxyPluginContext>,
pub plugin_name: String,
pub data_folder: PathBuf,
pub plugin: Option<Weak<WasmPlugin>>,
}
impl Default for PluginHostState {
fn default() -> Self {
Self::new(String::new(), PathBuf::new(), None)
}
}
impl PluginHostState {
#[must_use]
pub fn new(
plugin_name: String,
data_folder: PathBuf,
proxy: Option<ProxyPluginContext>,
) -> Self {
Self {
wasi_ctx: WasiCtxBuilder::new()
.inherit_stdout()
.inherit_stderr()
.build(),
resource_table: ResourceTable::new(),
limits: wasmtime::StoreLimitsBuilder::new().build(),
proxy,
plugin_name,
data_folder,
plugin: None,
}
}
pub fn add_context(
&mut self,
context: Arc<PluginContext>,
) -> wasmtime::Result<Resource<wit::vine::plugin::context::Context>> {
let res = self.resource_table.push(ContextResource { context })?;
Ok(Resource::new_own(res.rep()))
}
pub fn get_context(
&self,
resource: &Resource<wit::vine::plugin::context::Context>,
) -> wasmtime::Result<&ContextResource> {
Ok(self
.resource_table
.get(&Resource::new_borrow(resource.rep()))?)
}
}
impl WasiView for PluginHostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi_ctx,
table: &mut self.resource_table,
}
}
}
impl wit::vine::plugin::types::Host for PluginHostState {}
impl wit::vine::plugin::metadata::Host for PluginHostState {}
impl wit::vine::plugin::event::Host for PluginHostState {}
impl wit::vine::plugin::context::Host for PluginHostState {}
impl wit::vine::plugin::logging::Host for PluginHostState {
async fn log(
&mut self,
level: wit::vine::plugin::logging::Level,
message: String,
) -> wasmtime::Result<()> {
match level {
wit::vine::plugin::logging::Level::Trace => {
tracing::trace!("[Plugin: {}] {}", self.plugin_name, message)
}
wit::vine::plugin::logging::Level::Debug => {
tracing::debug!("[Plugin: {}] {}", self.plugin_name, message)
}
wit::vine::plugin::logging::Level::Info => {
tracing::info!("[Plugin: {}] {}", self.plugin_name, message)
}
wit::vine::plugin::logging::Level::Warn => {
tracing::warn!("[Plugin: {}] {}", self.plugin_name, message)
}
wit::vine::plugin::logging::Level::Error => {
tracing::error!("[Plugin: {}] {}", self.plugin_name, message)
}
}
Ok(())
}
}
impl wit::vine::plugin::command::Host for PluginHostState {
async fn reply(
&mut self,
sender: wit::vine::plugin::types::CommandSender,
message: String,
) -> wasmtime::Result<()> {
if let Some(proxy) = &self.proxy {
match sender.sender_type {
wit::vine::plugin::types::CommandSenderType::Console => {
tracing::info!("[Console] {}", message);
}
wit::vine::plugin::types::CommandSenderType::Player => {
if let Some(uuid_str) = sender.player_uuid
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&uuid_str)
&& let Some(session) = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid)
{
let _ = session.action_tx.send(PlayerAction::Message(
pumpkin_util::text::TextComponent::text(message),
));
}
}
}
}
Ok(())
}
}
impl wit::vine::plugin::proxy::Host for PluginHostState {
async fn get_player_count(&mut self) -> wasmtime::Result<u32> {
Ok(self
.proxy
.as_ref()
.map(|p| p.session_manager.count() as u32)
.unwrap_or(0))
}
async fn get_players(&mut self) -> wasmtime::Result<Vec<wit::vine::plugin::types::PlayerInfo>> {
if let Some(proxy) = &self.proxy {
let list = proxy
.session_manager
.list_sessions()
.into_iter()
.map(|s| wit::vine::plugin::types::PlayerInfo {
uuid: s.uuid.to_string(),
username: s.username,
current_server: s.current_server,
client_ip: s.client_ip,
protocol_version: s.protocol_version,
})
.collect();
Ok(list)
} else {
Ok(Vec::new())
}
}
async fn get_player_by_name(
&mut self,
username: String,
) -> wasmtime::Result<Option<wit::vine::plugin::types::PlayerInfo>> {
if let Some(proxy) = &self.proxy {
let session = proxy.session_manager.get_session(&username);
Ok(session.map(|s| wit::vine::plugin::types::PlayerInfo {
uuid: s.uuid.to_string(),
username: s.username,
current_server: s.current_server,
client_ip: s.client_ip,
protocol_version: s.protocol_version,
}))
} else {
Ok(None)
}
}
async fn get_player_by_uuid(
&mut self,
uuid: String,
) -> wasmtime::Result<Option<wit::vine::plugin::types::PlayerInfo>> {
if let Some(proxy) = &self.proxy
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&uuid)
{
let session = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid);
return Ok(session.map(|s| wit::vine::plugin::types::PlayerInfo {
uuid: s.uuid.to_string(),
username: s.username,
current_server: s.current_server,
client_ip: s.client_ip,
protocol_version: s.protocol_version,
}));
}
Ok(None)
}
async fn get_servers(&mut self) -> wasmtime::Result<Vec<wit::vine::plugin::types::ServerInfo>> {
if let Some(proxy) = &self.proxy {
let sessions = proxy.session_manager.list_sessions();
let mut list = Vec::new();
for (name, srv) in &proxy.config.servers {
let player_count = sessions
.iter()
.filter(|s| s.current_server == *name)
.count() as u32;
list.push(wit::vine::plugin::types::ServerInfo {
name: name.clone(),
address: srv.address.clone(),
player_count,
});
}
Ok(list)
} else {
Ok(Vec::new())
}
}
async fn broadcast(&mut self, message: String) -> wasmtime::Result<()> {
if let Some(proxy) = &self.proxy {
for session in proxy.session_manager.list_sessions() {
let _ = session.action_tx.send(PlayerAction::Message(
pumpkin_util::text::TextComponent::text(message.clone()),
));
}
tracing::info!("[Broadcast] {}", message);
}
Ok(())
}
async fn send_message(
&mut self,
player_uuid: String,
message: String,
) -> wasmtime::Result<bool> {
if let Some(proxy) = &self.proxy
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&player_uuid)
&& let Some(session) = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid)
{
let _ = session.action_tx.send(PlayerAction::Message(
pumpkin_util::text::TextComponent::text(message),
));
return Ok(true);
}
Ok(false)
}
async fn disconnect_player(
&mut self,
player_uuid: String,
reason: String,
) -> wasmtime::Result<bool> {
if let Some(proxy) = &self.proxy
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&player_uuid)
&& let Some(session) = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid)
{
let _ = session.action_tx.send(PlayerAction::Disconnect(reason));
return Ok(true);
}
Ok(false)
}
async fn connect_player(
&mut self,
player_uuid: String,
server_name: String,
) -> wasmtime::Result<bool> {
if let Some(proxy) = &self.proxy
&& proxy.config.servers.contains_key(&server_name)
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&player_uuid)
&& let Some(session) = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid)
{
let _ = session
.action_tx
.send(PlayerAction::Connect { server_name });
return Ok(true);
}
Ok(false)
}
async fn send_plugin_message(
&mut self,
player_uuid: String,
channel: String,
data: Vec<u8>,
) -> wasmtime::Result<bool> {
if let Some(proxy) = &self.proxy
&& let Ok(target_uuid) = uuid::Uuid::parse_str(&player_uuid)
&& let Some(session) = proxy
.session_manager
.list_sessions()
.into_iter()
.find(|s| s.uuid == target_uuid)
{
let _ = session
.action_tx
.send(PlayerAction::PluginMessage { channel, data });
return Ok(true);
}
Ok(false)
}
}
impl wit::vine::plugin::context::HostContext for PluginHostState {
async fn register_event(
&mut self,
context: Resource<wit::vine::plugin::context::Context>,
handler_id: u32,
event_type: wit::vine::plugin::event::EventType,
priority: wit::vine::plugin::types::EventPriority,
) -> wasmtime::Result<()> {
let plugin = self
.plugin
.as_ref()
.and_then(|p| p.upgrade())
.ok_or_else(|| wasmtime::Error::msg("Plugin reference is no longer valid"))?;
let api_event_type = match event_type {
wit::vine::plugin::event::EventType::ProxyPing => EventType::ProxyPing,
wit::vine::plugin::event::EventType::PlayerPreLogin => EventType::PlayerPreLogin,
wit::vine::plugin::event::EventType::PlayerLogin => EventType::PlayerLogin,
wit::vine::plugin::event::EventType::PlayerJoin => EventType::PlayerJoin,
wit::vine::plugin::event::EventType::ServerConnect => EventType::ServerConnect,
wit::vine::plugin::event::EventType::ServerConnected => EventType::ServerConnected,
wit::vine::plugin::event::EventType::PlayerDisconnect => EventType::PlayerDisconnect,
wit::vine::plugin::event::EventType::PlayerChat => EventType::PlayerChat,
wit::vine::plugin::event::EventType::PlayerCommand => EventType::PlayerCommand,
wit::vine::plugin::event::EventType::PluginMessage => EventType::PluginMessage,
};
let api_priority = match priority {
wit::vine::plugin::types::EventPriority::Lowest => EventPriority::Lowest,
wit::vine::plugin::types::EventPriority::Low => EventPriority::Low,
wit::vine::plugin::types::EventPriority::Normal => EventPriority::Normal,
wit::vine::plugin::types::EventPriority::High => EventPriority::High,
wit::vine::plugin::types::EventPriority::Highest => EventPriority::Highest,
wit::vine::plugin::types::EventPriority::Monitor => EventPriority::Monitor,
};
if let Some(proxy) = &self.proxy {
proxy
.plugin_manager
.register_event_handler(api_event_type, api_priority, handler_id, plugin)
.await;
}
let ctx_res = self.get_context(&context)?;
let mut events = ctx_res.context.registered_events.lock().await;
events.push((handler_id, api_event_type, api_priority));
Ok(())
}
async fn register_command(
&mut self,
context: Resource<wit::vine::plugin::context::Context>,
command_id: u32,
name: String,
description: String,
aliases: Vec<String>,
permission: Option<String>,
) -> wasmtime::Result<()> {
let plugin = self
.plugin
.as_ref()
.and_then(|p| p.upgrade())
.ok_or_else(|| wasmtime::Error::msg("Plugin reference is no longer valid"))?;
if let Some(proxy) = &self.proxy {
proxy
.plugin_manager
.register_command(command_id, name, description, aliases, permission, plugin)
.await;
}
let ctx_res = self.get_context(&context)?;
let mut commands = ctx_res.context.registered_commands.lock().await;
commands.push(command_id);
Ok(())
}
async fn get_data_folder(
&mut self,
_context: Resource<wit::vine::plugin::context::Context>,
) -> wasmtime::Result<String> {
Ok(self.data_folder.to_string_lossy().to_string())
}
async fn drop(
&mut self,
rep: Resource<wit::vine::plugin::context::Context>,
) -> wasmtime::Result<()> {
let _ = self
.resource_table
.delete::<ContextResource>(Resource::new_own(rep.rep()));
Ok(())
}
}

View File

@ -0,0 +1,12 @@
use wasmtime::component::bindgen;
bindgen!({
path: "wit",
world: "plugin",
imports: {
default: async | trappable,
},
exports: {
default: async | store | trappable,
},
});

863
src/plugin/mod.rs Normal file
View File

@ -0,0 +1,863 @@
pub mod api;
pub mod loader;
pub use api::*;
pub use loader::wasm::WasmPlugin;
use loader::wasm::WasmPluginLoader;
use loader::wasm::wasm_host::state::ProxyPluginContext;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{OnceCell, RwLock};
use tracing::{error, info, warn};
use crate::command::{CommandSender, ProxyCommandSource};
use pumpkin_command::{ArgumentBuilder, CommandSource};
#[derive(Clone)]
pub struct RegisteredEventHandler {
pub handler_id: u32,
pub priority: EventPriority,
pub plugin: Arc<WasmPlugin>,
}
pub struct PluginManager {
plugins_dir: PathBuf,
loader: WasmPluginLoader,
loaded_plugins: RwLock<Vec<Arc<WasmPlugin>>>,
event_handlers: RwLock<HashMap<EventType, Vec<RegisteredEventHandler>>>,
proxy_ctx: OnceCell<ProxyPluginContext>,
}
impl PluginManager {
pub fn new(
plugins_dir: PathBuf,
) -> Result<Arc<Self>, loader::wasm::wasm_host::PluginInitError> {
let loader = WasmPluginLoader::new()?;
Ok(Arc::new(Self {
plugins_dir,
loader,
loaded_plugins: RwLock::new(Vec::new()),
event_handlers: RwLock::new(HashMap::new()),
proxy_ctx: OnceCell::new(),
}))
}
pub fn init_proxy_context(
self: &Arc<Self>,
config: Arc<crate::config::Config>,
session_manager: Arc<crate::session::SessionManager>,
command_dispatcher: Arc<crate::command::CommandDispatcher>,
) {
let ctx = ProxyPluginContext {
config,
session_manager,
command_dispatcher,
plugin_manager: self.clone(),
};
let _ = self.proxy_ctx.set(ctx);
}
pub async fn register_event_handler(
&self,
event_type: EventType,
priority: EventPriority,
handler_id: u32,
plugin: Arc<WasmPlugin>,
) {
let mut handlers = self.event_handlers.write().await;
let list = handlers.entry(event_type).or_default();
list.push(RegisteredEventHandler {
handler_id,
priority,
plugin,
});
list.sort_by_key(|h| h.priority);
}
pub async fn register_command(
&self,
command_id: u32,
name: String,
description: String,
aliases: Vec<String>,
permission: Option<String>,
plugin: Arc<WasmPlugin>,
) {
if let Some(proxy) = self.proxy_ctx.get() {
let names = std::iter::once(name.clone())
.chain(aliases)
.collect::<Vec<_>>();
for cmd_name in names {
let plugin_cmd = plugin.clone();
let perm_check = permission.clone();
let cmd_builder = pumpkin_command::argument_builder::command(cmd_name, description.clone())
.requires(move |src: &ProxyCommandSource| {
if let Some(ref p) = perm_check {
src.has_permission(p)
} else {
true
}
})
.executes(PluginCommandExecutor {
command_id,
plugin: plugin_cmd.clone(),
})
.then(
pumpkin_command::argument_builder::argument(
"args",
pumpkin_command::argument_types::core::string::StringArgumentType::GreedyPhrase,
)
.suggests(PluginCommandSuggestionProvider {
command_id,
plugin: plugin_cmd.clone(),
})
.executes(PluginCommandExecutor {
command_id,
plugin: plugin_cmd,
}),
);
proxy.command_dispatcher.register(cmd_builder);
}
info!(
"Registered command '/{}' from plugin '{}'",
name, plugin.metadata.name
);
}
}
pub async fn load_plugins(&self) -> Result<usize, Box<dyn std::error::Error>> {
if !self.plugins_dir.exists() {
let _ = fs::create_dir_all(&self.plugins_dir);
info!("Created plugins directory '{}'", self.plugins_dir.display());
return Ok(0);
}
let mut count = 0;
let start_time = std::time::Instant::now();
let read_dir = match fs::read_dir(&self.plugins_dir) {
Ok(d) => d,
Err(e) => {
warn!("Failed to read plugins directory: {}", e);
return Ok(0);
}
};
let proxy = self.proxy_ctx.get().cloned();
for entry in read_dir.flatten() {
let path = entry.path();
if path.is_file() && self.loader.can_load(&path) {
info!("Loading plugin from '{}'...", path.display());
match self.loader.load(&path, proxy.clone()).await {
Ok((plugin, metadata)) => {
let data_folder =
PathBuf::from("plugins").join("data").join(&metadata.name);
let context = Arc::new(PluginContext::new(metadata.clone(), data_folder));
match plugin.on_load(context).await {
Ok(()) => {
info!(
"Loaded plugin '{}' v{} by {}",
metadata.name,
metadata.version,
metadata.authors.join(", ")
);
self.loaded_plugins.write().await.push(plugin);
count += 1;
}
Err(e) => {
error!("Plugin '{}' failed to on_load: {}", metadata.name, e);
}
}
}
Err(e) => {
error!("Failed to load plugin '{}': {}", path.display(), e);
}
}
}
}
info!("Loaded {} plugin(s) in {:?}", count, start_time.elapsed());
Ok(count)
}
pub async fn unload_plugins(&self) {
let plugins = {
let mut lock = self.loaded_plugins.write().await;
std::mem::take(&mut *lock)
};
for plugin in plugins {
let data_folder = PathBuf::from("plugins")
.join("data")
.join(&plugin.metadata.name);
let context = Arc::new(PluginContext::new(plugin.metadata.clone(), data_folder));
if let Err(e) = plugin.on_unload(context).await {
error!("Error unloading plugin '{}': {}", plugin.metadata.name, e);
} else {
info!("Unloaded plugin '{}'", plugin.metadata.name);
}
}
self.event_handlers.write().await.clear();
}
// --- Event dispatchers ---
pub async fn fire_proxy_ping(&self, event: &mut ProxyPingEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::ProxyPing).cloned().unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::ProxyPing(
loader::wasm::wasm_host::wit::vine::plugin::event::ProxyPingEvent {
client_ip: event.client_ip.clone(),
protocol_version: event.protocol_version,
virtual_host: event.virtual_host.clone(),
motd: event.motd.clone(),
max_players: event.max_players,
online_players: event.online_players,
version_name: event.version_name.clone(),
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::ProxyPing(res)) => {
event.motd = res.motd;
event.max_players = res.max_players;
event.online_players = res.online_players;
event.version_name = res.version_name;
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling ProxyPing: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_player_pre_login(&self, event: &mut PlayerPreLoginEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerPreLogin)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerPreLogin(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerPreLoginEvent {
client_ip: event.client_ip.clone(),
username: event.username.clone(),
protocol_version: event.protocol_version,
virtual_host: event.virtual_host.clone(),
cancelled: event.cancelled,
cancel_reason: event.cancel_reason.clone(),
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerPreLogin(
res,
)) => {
event.cancelled = res.cancelled;
event.cancel_reason = res.cancel_reason;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling PlayerPreLogin: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_player_login(&self, event: &mut PlayerLoginEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerLogin)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerLogin(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerLoginEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
client_ip: event.client_ip.clone(),
cancelled: event.cancelled,
cancel_reason: event.cancel_reason.clone(),
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerLogin(res)) => {
event.cancelled = res.cancelled;
event.cancel_reason = res.cancel_reason;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling PlayerLogin: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_player_join(&self, event: &PlayerJoinEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerJoin).cloned().unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerJoin(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerJoinEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
client_ip: event.client_ip.clone(),
initial_server: event.initial_server.clone(),
},
);
let _ = handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await;
}
}
pub async fn fire_server_connect(&self, event: &mut ServerConnectEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::ServerConnect)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::ServerConnect(
loader::wasm::wasm_host::wit::vine::plugin::event::ServerConnectEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
current_server: event.current_server.clone(),
target_server: event.target_server.clone(),
cancelled: event.cancelled,
cancel_reason: event.cancel_reason.clone(),
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::ServerConnect(
res,
)) => {
event.target_server = res.target_server;
event.cancelled = res.cancelled;
event.cancel_reason = res.cancel_reason;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling ServerConnect: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_server_connected(&self, event: &ServerConnectedEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::ServerConnected)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::ServerConnected(
loader::wasm::wasm_host::wit::vine::plugin::event::ServerConnectedEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
server_name: event.server_name.clone(),
},
);
let _ = handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await;
}
}
pub async fn fire_player_disconnect(&self, event: &PlayerDisconnectEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerDisconnect)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt =
loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerDisconnect(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerDisconnectEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
last_server: event.last_server.clone(),
},
);
let _ = handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await;
}
}
pub async fn fire_player_chat(&self, event: &mut PlayerChatEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerChat).cloned().unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerChat(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerChatEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
message: event.message.clone(),
cancelled: event.cancelled,
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerChat(res)) => {
event.message = res.message;
event.cancelled = res.cancelled;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling PlayerChat: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_player_command(&self, event: &mut PlayerCommandEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PlayerCommand)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerCommand(
loader::wasm::wasm_host::wit::vine::plugin::event::PlayerCommandEvent {
uuid: event.uuid.clone(),
username: event.username.clone(),
command: event.command.clone(),
cancelled: event.cancelled,
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::PlayerCommand(
res,
)) => {
event.command = res.command;
event.cancelled = res.cancelled;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling PlayerCommand: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
pub async fn fire_plugin_message(&self, event: &mut PluginMessageEvent) {
let handlers = {
let map = self.event_handlers.read().await;
map.get(&EventType::PluginMessage)
.cloned()
.unwrap_or_default()
};
for handler in handlers {
let wit_evt = loader::wasm::wasm_host::wit::vine::plugin::event::Event::PluginMessage(
loader::wasm::wasm_host::wit::vine::plugin::event::PluginMessageEvent {
channel: event.channel.clone(),
data: event.data.clone(),
player_uuid: event.player_uuid.clone(),
server_name: event.server_name.clone(),
cancelled: event.cancelled,
},
);
match handler
.plugin
.handle_event(handler.handler_id, wit_evt)
.await
{
Ok(loader::wasm::wasm_host::wit::vine::plugin::event::Event::PluginMessage(
res,
)) => {
event.data = res.data;
event.cancelled = res.cancelled;
if event.cancelled {
break;
}
}
Ok(_) => {}
Err(e) => {
error!(
"Error in plugin '{}' handling PluginMessage: {}",
handler.plugin.metadata.name, e
);
}
}
}
}
}
struct PluginCommandExecutor {
command_id: u32,
plugin: Arc<WasmPlugin>,
}
impl pumpkin_command::node::CommandExecutor<ProxyCommandSource> for PluginCommandExecutor {
fn execute(
&self,
context: &pumpkin_command::context::command_context::CommandContext<ProxyCommandSource>,
) -> pumpkin_command::node::CommandExecutorResult {
let args: Vec<String> = if let Ok(phrase) =
pumpkin_command::argument_types::core::string::StringArgumentType::get(context, "args")
{
phrase.split_whitespace().map(|s| s.to_string()).collect()
} else {
Vec::new()
};
let sender = match &context.source.sender {
CommandSender::Console => loader::wasm::wasm_host::wit::vine::plugin::types::CommandSender {
sender_type:
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSenderType::Console,
name: "CONSOLE".to_string(),
player_uuid: None,
},
CommandSender::Player { username, .. } => {
let uuid = context
.source
.session_manager
.get_session(username)
.map(|s| s.uuid.to_string());
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSender {
sender_type:
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSenderType::Player,
name: username.clone(),
player_uuid: uuid,
}
}
};
let plugin = self.plugin.clone();
let command_id = self.command_id;
let handle = tokio::runtime::Handle::current();
let result = tokio::task::block_in_place(|| {
handle.block_on(async move { plugin.handle_command(command_id, sender, args).await })
});
match result {
Ok(Ok(code)) => Ok(code),
Ok(Err(err_msg)) => {
context
.source
.send_message(pumpkin_util::text::TextComponent::text(format!(
"§cError: {err_msg}"
)));
Ok(0)
}
Err(e) => {
context
.source
.send_message(pumpkin_util::text::TextComponent::text(format!(
"§cPlugin command error: {e}"
)));
Ok(0)
}
}
}
}
struct PluginCommandSuggestionProvider {
command_id: u32,
plugin: Arc<WasmPlugin>,
}
impl pumpkin_command::suggestion::provider::SuggestionProvider<ProxyCommandSource>
for PluginCommandSuggestionProvider
{
fn suggest(
&self,
context: &pumpkin_command::context::command_context::CommandContext<ProxyCommandSource>,
builder: pumpkin_command::suggestion::suggestions::SuggestionsBuilder,
) -> pumpkin_command::suggestion::provider::SuggestionProviderResult {
let args: Vec<String> = if let Ok(phrase) =
pumpkin_command::argument_types::core::string::StringArgumentType::get(context, "args")
{
phrase.split_whitespace().map(|s| s.to_string()).collect()
} else {
Vec::new()
};
let sender = match &context.source.sender {
CommandSender::Console => loader::wasm::wasm_host::wit::vine::plugin::types::CommandSender {
sender_type:
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSenderType::Console,
name: "CONSOLE".to_string(),
player_uuid: None,
},
CommandSender::Player { username, .. } => {
let uuid = context
.source
.session_manager
.get_session(username)
.map(|s| s.uuid.to_string());
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSender {
sender_type:
loader::wasm::wasm_host::wit::vine::plugin::types::CommandSenderType::Player,
name: username.clone(),
player_uuid: uuid,
}
}
};
let plugin = self.plugin.clone();
let command_id = self.command_id;
let handle = tokio::runtime::Handle::current();
let suggestions_res = tokio::task::block_in_place(|| {
handle.block_on(async move {
plugin
.handle_command_suggestion(command_id, sender, args)
.await
})
});
let mut builder = builder;
if let Ok(suggestions) = suggestions_res {
for s in suggestions {
builder = builder.suggest(s);
}
}
builder.build()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::CommandDispatcher;
use crate::config::Config;
use crate::session::SessionManager;
use std::sync::Arc;
#[tokio::test]
async fn test_plugin_manager_empty_dir() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let config = Arc::new(Config::default());
let dispatcher = Arc::new(CommandDispatcher::new());
let session_manager = Arc::new(SessionManager::new());
let manager =
PluginManager::new(temp_dir.path().to_path_buf()).expect("failed to create manager");
manager.init_proxy_context(config, session_manager, dispatcher);
let count = manager.load_plugins().await.expect("load failed");
assert_eq!(count, 0);
manager.unload_plugins().await;
}
#[tokio::test]
async fn test_plugin_manager_event_dispatch() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let config = Arc::new(Config::default());
let dispatcher = Arc::new(CommandDispatcher::new());
let session_manager = Arc::new(SessionManager::new());
let manager =
PluginManager::new(temp_dir.path().to_path_buf()).expect("failed to create manager");
manager.init_proxy_context(config, session_manager, dispatcher);
// Proxy ping
let mut ping = ProxyPingEvent {
client_ip: "127.0.0.1".to_string(),
protocol_version: 765,
virtual_host: "localhost".to_string(),
motd: "A Minecraft Proxy".to_string(),
max_players: 100,
online_players: 0,
version_name: "Vine 1.21".to_string(),
};
manager.fire_proxy_ping(&mut ping).await;
assert_eq!(ping.motd, "A Minecraft Proxy");
assert_eq!(ping.max_players, 100);
// Player pre-login
let mut pre_login = PlayerPreLoginEvent {
client_ip: "127.0.0.1".to_string(),
username: "Player1".to_string(),
protocol_version: 765,
virtual_host: "localhost".to_string(),
cancelled: false,
cancel_reason: None,
};
manager.fire_player_pre_login(&mut pre_login).await;
assert!(!pre_login.cancelled);
// Player login
let mut login = PlayerLoginEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
client_ip: "127.0.0.1".to_string(),
cancelled: false,
cancel_reason: None,
};
manager.fire_player_login(&mut login).await;
assert!(!login.cancelled);
// Server connect
let mut connect = ServerConnectEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
current_server: None,
target_server: "lobby".to_string(),
cancelled: false,
cancel_reason: None,
};
manager.fire_server_connect(&mut connect).await;
assert_eq!(connect.target_server, "lobby");
assert!(!connect.cancelled);
// Server connected
let connected = ServerConnectedEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
server_name: "lobby".to_string(),
};
manager.fire_server_connected(&connected).await;
// Player join
let join = PlayerJoinEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
client_ip: "127.0.0.1".to_string(),
initial_server: "lobby".to_string(),
};
manager.fire_player_join(&join).await;
// Player chat
let mut chat = PlayerChatEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
message: "Hello proxy!".to_string(),
cancelled: false,
};
manager.fire_player_chat(&mut chat).await;
assert_eq!(chat.message, "Hello proxy!");
assert!(!chat.cancelled);
// Player command
let mut cmd = PlayerCommandEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
command: "ping".to_string(),
cancelled: false,
};
manager.fire_player_command(&mut cmd).await;
assert!(!cmd.cancelled);
// Plugin message
let mut msg = PluginMessageEvent {
channel: "vine:test".to_string(),
data: vec![1, 2, 3],
player_uuid: Some("00000000-0000-0000-0000-000000000001".to_string()),
server_name: Some("lobby".to_string()),
cancelled: false,
};
manager.fire_plugin_message(&mut msg).await;
assert!(!msg.cancelled);
// Player disconnect
let disc = PlayerDisconnectEvent {
uuid: "00000000-0000-0000-0000-000000000001".to_string(),
username: "Player1".to_string(),
last_server: Some("lobby".to_string()),
};
manager.fire_player_disconnect(&disc).await;
}
#[test]
fn test_event_priority_ordering() {
assert!(EventPriority::Lowest < EventPriority::Low);
assert!(EventPriority::Low < EventPriority::Normal);
assert!(EventPriority::Normal < EventPriority::High);
assert!(EventPriority::High < EventPriority::Highest);
assert!(EventPriority::Highest < EventPriority::Monitor);
}
#[test]
fn test_plugin_metadata_serde() {
let meta = PluginMetadata {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
authors: vec!["Author1".to_string()],
description: Some("A test plugin".to_string()),
dependencies: vec!["other-plugin".to_string()],
};
let json = serde_json::to_string(&meta).expect("serialization failed");
let decoded: PluginMetadata = serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(decoded.name, "test-plugin");
assert_eq!(decoded.version, "1.0.0");
assert_eq!(decoded.authors, vec!["Author1".to_string()]);
assert_eq!(decoded.dependencies, vec!["other-plugin".to_string()]);
}
}

View File

@ -27,6 +27,7 @@ pub struct ProxyServer {
online_players: Arc<AtomicUsize>,
session_manager: Arc<SessionManager>,
command_dispatcher: Arc<CommandDispatcher>,
plugin_manager: Arc<crate::plugin::PluginManager>,
}
impl ProxyServer {
@ -68,6 +69,14 @@ impl ProxyServer {
let router = Arc::new(Router::new(config.routing.clone(), config.servers.clone()));
let plugins_dir = std::path::PathBuf::from(&config.plugins.plugin_dir);
let plugin_manager = crate::plugin::PluginManager::new(plugins_dir)?;
plugin_manager.init_proxy_context(
config.clone(),
session_manager.clone(),
command_dispatcher.clone(),
);
Ok(Self {
config,
keystore,
@ -78,6 +87,7 @@ impl ProxyServer {
online_players,
session_manager,
command_dispatcher,
plugin_manager,
})
}
@ -221,6 +231,16 @@ impl ProxyServer {
}
});
if self.config.plugins.enabled {
info!(
"Loading plugins from '{}'...",
self.config.plugins.plugin_dir
);
if let Err(e) = self.plugin_manager.load_plugins().await {
error!("Failed to load plugins: {}", e);
}
}
loop {
tokio::select! {
accept_res = listener.accept() => {
@ -235,6 +255,7 @@ impl ProxyServer {
let rate_limiter = self.rate_limiter.clone();
let session_manager = self.session_manager.clone();
let command_dispatcher = self.command_dispatcher.clone();
let plugin_manager = self.plugin_manager.clone();
tokio::spawn(async move {
if let Err(e) = network::configure_client_socket(&stream, &config.network) {
@ -274,6 +295,7 @@ impl ProxyServer {
online_players,
session_manager,
command_dispatcher,
plugin_manager,
);
if let Err(err) = handler.handle(stream, leftover_bytes).await {
@ -296,6 +318,11 @@ impl ProxyServer {
info!("Vine proxy has stopped accepting connections.");
if self.config.plugins.enabled {
info!("Unloading plugins...");
self.plugin_manager.unload_plugins().await;
}
if let Some(handle) = telemetry_handle {
let timeout_secs = self.config.server.shutdown_timeout_secs.max(1);
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), handle).await;

View File

@ -14,6 +14,10 @@ pub enum PlayerAction {
Transfer { host: String, port: u16 },
/// Send a system chat message to the player
Message(TextComponent),
/// Disconnect/kick the player with a reason
Disconnect(String),
/// Send a custom plugin message / payload to the player
PluginMessage { channel: String, data: Vec<u8> },
}
/// Represents an active connected player session on the proxy
@ -23,6 +27,8 @@ pub struct PlayerSession {
pub uuid: Uuid,
pub current_server: String,
pub action_tx: mpsc::UnboundedSender<PlayerAction>,
pub client_ip: String,
pub protocol_version: u32,
}
/// Thread-safe registry of connected player sessions
@ -111,6 +117,8 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: tx,
client_ip: "127.0.0.1:12345".to_string(),
protocol_version: 765,
};
manager.register(session);
@ -135,6 +143,8 @@ mod tests {
uuid: Uuid::new_v4(),
current_server: "lobby".to_string(),
action_tx: tx,
client_ip: "127.0.0.1:12346".to_string(),
protocol_version: 765,
};
manager.register(session);
assert_eq!(manager.get_session("Alex").unwrap().current_server, "lobby");

View File

@ -122,7 +122,7 @@ impl StatusHandler {
}
/// Resolves MOTD description for the virtual host or falls back to global default
fn resolve_motd_text(&self, host: Option<&str>) -> String {
pub fn resolve_motd_text(&self, host: Option<&str>) -> String {
if let Some(h) = host {
let clean_host = h.split(':').next().unwrap_or(h).trim();
@ -161,6 +161,58 @@ impl StatusHandler {
CStatusResponse::new(json)
}
/// Create custom CStatusResponse packet with overridden fields from plugins
pub fn create_custom_status_packet(
&self,
client_version: JavaMinecraftVersion,
motd: &str,
max_players: u32,
online_players: u32,
version_name: &str,
virtual_host: Option<&str>,
) -> CStatusResponse {
let protocol = if client_version == JavaMinecraftVersion::Unknown {
self.motd.protocol_version
} else {
client_version.protocol_version()
};
let icon_uri = self.icon_manager.get_icon_for_host(virtual_host);
let sample: Vec<PlayerSample> = if self.motd.show_sample_players {
self.motd
.sample_players
.iter()
.map(|name| PlayerSample {
name,
id: Uuid::nil().to_string(),
})
.collect()
} else {
Vec::new()
};
let status = StatusJson {
version: StatusVersion {
name: version_name,
protocol,
},
players: StatusPlayers {
max: max_players,
online: online_players as usize,
sample,
},
description: StatusDescription {
text: motd.to_string(),
},
enforce_secure_chat: false,
favicon: icon_uri.as_deref(),
};
let json = serde_json::to_string(&status).unwrap_or_else(|_| "{}".to_string());
CStatusResponse::new(json)
}
/// Create CPingResponse packet
pub fn create_pong_packet(payload: i64) -> CPingResponse {
CPingResponse::new(payload)

6
wit/command.wit Normal file
View File

@ -0,0 +1,6 @@
interface command {
use types.{command-sender};
/// Reply to a command sender
reply: func(sender: command-sender, message: string);
}

15
wit/context.wit Normal file
View File

@ -0,0 +1,15 @@
interface context {
use types.{event-priority};
use event.{event-type};
resource context {
/// Register an event listener for an event type with priority
register-event: func(handler-id: u32, event-type: event-type, priority: event-priority);
/// Register a proxy command
register-command: func(command-id: u32, name: string, description: string, aliases: list<string>, permission: option<string>);
/// Get path to the plugin's data folder (e.g. plugins/data/<plugin-name>)
get-data-folder: func() -> string;
}
}

106
wit/event.wit Normal file
View File

@ -0,0 +1,106 @@
interface event {
use types.{event-priority};
enum event-type {
proxy-ping,
player-pre-login,
player-login,
player-join,
server-connect,
server-connected,
player-disconnect,
player-chat,
player-command,
plugin-message,
}
record proxy-ping-event {
client-ip: string,
protocol-version: u32,
virtual-host: string,
motd: string,
max-players: u32,
online-players: u32,
version-name: string,
}
record player-pre-login-event {
client-ip: string,
username: string,
protocol-version: u32,
virtual-host: string,
cancelled: bool,
cancel-reason: option<string>,
}
record player-login-event {
uuid: string,
username: string,
client-ip: string,
cancelled: bool,
cancel-reason: option<string>,
}
record player-join-event {
uuid: string,
username: string,
client-ip: string,
initial-server: string,
}
record server-connect-event {
uuid: string,
username: string,
current-server: option<string>,
target-server: string,
cancelled: bool,
cancel-reason: option<string>,
}
record server-connected-event {
uuid: string,
username: string,
server-name: string,
}
record player-disconnect-event {
uuid: string,
username: string,
last-server: option<string>,
}
record player-chat-event {
uuid: string,
username: string,
message: string,
cancelled: bool,
}
record player-command-event {
uuid: string,
username: string,
command: string,
cancelled: bool,
}
record plugin-message-event {
channel: string,
data: list<u8>,
player-uuid: option<string>,
server-name: option<string>,
cancelled: bool,
}
variant event {
proxy-ping(proxy-ping-event),
player-pre-login(player-pre-login-event),
player-login(player-login-event),
player-join(player-join-event),
server-connect(server-connect-event),
server-connected(server-connected-event),
player-disconnect(player-disconnect-event),
player-chat(player-chat-event),
player-command(player-command-event),
plugin-message(plugin-message-event),
}
}

11
wit/logging.wit Normal file
View File

@ -0,0 +1,11 @@
interface logging {
enum level {
trace,
debug,
info,
warn,
error,
}
log: func(level: level, message: string);
}

9
wit/metadata.wit Normal file
View File

@ -0,0 +1,9 @@
interface metadata {
record plugin-metadata {
name: string,
version: string,
authors: list<string>,
description: option<string>,
dependencies: list<string>,
}
}

34
wit/plugin.wit Normal file
View File

@ -0,0 +1,34 @@
package vine:plugin@0.1.0;
world plugin {
use context.{context};
use event.{event};
use types.{command-sender};
use metadata.{plugin-metadata};
import logging;
import proxy;
import context;
import command;
/// Called once by the host to initialize the plugin instance.
export init-plugin: func();
/// Called when the plugin is loaded into the proxy.
export on-load: func(ctx: context) -> result<_, string>;
/// Called when the plugin is being unloaded.
export on-unload: func(ctx: context) -> result<_, string>;
/// Provides plugin metadata to the proxy host.
export get-metadata: func() -> plugin-metadata;
/// Dispatches an event to the plugin handler.
export handle-event: func(handler-id: u32, evt: event) -> event;
/// Dispatches a command execution to the plugin.
export handle-command: func(command-id: u32, sender: command-sender, args: list<string>) -> result<s32, string>;
/// Dispatches command suggestion / tab completion to the plugin.
export handle-command-suggestion: func(command-id: u32, sender: command-sender, args: list<string>) -> list<string>;
}

33
wit/proxy.wit Normal file
View File

@ -0,0 +1,33 @@
interface proxy {
use types.{player-info, server-info};
/// Returns the total number of connected players.
get-player-count: func() -> u32;
/// Returns a list of all connected players.
get-players: func() -> list<player-info>;
/// Returns information for a specific player by username.
get-player-by-name: func(username: string) -> option<player-info>;
/// Returns information for a specific player by UUID string.
get-player-by-uuid: func(uuid: string) -> option<player-info>;
/// Returns a list of configured backend servers.
get-servers: func() -> list<server-info>;
/// Broadcasts a system chat message to all connected players and console.
broadcast: func(message: string);
/// Sends a system chat message to a specific player by UUID.
send-message: func(player-uuid: string, message: string) -> bool;
/// Disconnects/kicks a player by UUID with the given reason.
disconnect-player: func(player-uuid: string, reason: string) -> bool;
/// Requests switching/connecting a player to a backend server.
connect-player: func(player-uuid: string, server-name: string) -> bool;
/// Sends a plugin message / custom payload to a player.
send-plugin-message: func(player-uuid: string, channel: string, data: list<u8>) -> bool;
}

35
wit/types.wit Normal file
View File

@ -0,0 +1,35 @@
interface types {
record player-info {
uuid: string,
username: string,
current-server: string,
client-ip: string,
protocol-version: u32,
}
record server-info {
name: string,
address: string,
player-count: u32,
}
enum event-priority {
lowest,
low,
normal,
high,
highest,
monitor,
}
enum command-sender-type {
console,
player,
}
record command-sender {
sender-type: command-sender-type,
name: string,
player-uuid: option<string>,
}
}