mirror of
https://gitlab.com/veloren/veloren.git
synced 2025-07-26 05:12:29 +00:00
Merge branch 'zesterer/ip-banning' into 'master'
Initial implementation of IP banning See merge request veloren/veloren!3363
This commit is contained in:
@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Reworked Myrmidon dungeon
|
||||
- Wild Legoom and Goblin mobs
|
||||
- Bloodservants, Strigoi, and Scarlet Spectacles added to Halloween event
|
||||
- IP Bans
|
||||
|
||||
### Changed
|
||||
|
||||
|
@ -16,6 +16,7 @@ command-body-desc = Change your body to different species
|
||||
command-buff-desc = Cast a buff on player
|
||||
command-build-desc = Toggles build mode on and off
|
||||
command-ban-desc = Ban a player with a given username, for a given duration (if provided). Pass true for overwrite to alter an existing ban.
|
||||
command-ban-ip-desc = Ban a player with a given username, for a given duration (if provided). Unlike the normal ban this also additionally bans the IP-address associated with this user. Pass true for overwrite to alter an existing ban.
|
||||
command-battlemode-desc = Set your battle mode to:
|
||||
+ pvp (player vs player)
|
||||
+ pve (player vs environment).
|
||||
@ -96,7 +97,8 @@ command-rtsim_info-desc = Display information about an rtsim NPC
|
||||
command-rtsim_npc-desc = List rtsim NPCs that fit a given query (e.g: simulated,merchant) in order of distance
|
||||
command-rtsim_purge-desc = Purge rtsim data on next startup
|
||||
command-rtsim_tp-desc = Teleport to an rtsim npc
|
||||
command-unban-desc = Remove the ban for the given username
|
||||
command-unban-desc = Remove the ban for the given username. If there is an linked IP ban it will be removed as well.
|
||||
command-unban-ip-desc = Remove just the IP ban for the given username.
|
||||
command-version-desc = Prints server version
|
||||
command-waypoint-desc = Set your waypoint to your current position
|
||||
command-weather_zone-desc = Create a weather zone
|
||||
@ -227,6 +229,7 @@ command-adminify-role-upgraded = Role for player { $player } upgraded to { $role
|
||||
command-adminify-removed-role = Role removed from player { $player }: { $role }
|
||||
command-ban-added = Added { $player } to the banlist with reason: { $reason }
|
||||
command-ban-already-added = { $player } is already on the banlist
|
||||
command-ban-ip-added = Added { $username } to the regular banlist and IP banlist with reason: { $reason }
|
||||
command-faction-join = Please join a faction with /join_faction
|
||||
command-group-join = Please create a group first
|
||||
command-group_invite-invited-to-group = Invited { $player } to the group.
|
||||
@ -240,6 +243,7 @@ command-sudo-no-permission-for-non-players = You don't have permission to sudo n
|
||||
command-time_scale-current = The current time scale is { $scale }.
|
||||
command-time_scale-changed = Set time scale to { $scale }.
|
||||
command-unban-successful = { $player } was successfully unbanned.
|
||||
command-unban-ip-successful = The IP banned via user "{ $player }" was successfully unbanned (this user will remain banned)
|
||||
command-unban-already-unbanned = { $player } was already unbanned.
|
||||
command-version-current = Server is running { $hash }[{ $date }]
|
||||
command-whitelist-added = Added to whitelist: { $username }
|
||||
@ -259,6 +263,13 @@ command-death_effect-unknown = Unknown death effect { $effect }.
|
||||
command-spot-spot_not_found = Didn't find any spots of that kind in this world.
|
||||
command-spot-world_feature = The `worldgen` feature has to be enabled to run this command.
|
||||
command-cannot-send-message-hidden = Cannot send messages as a hidden spectator.
|
||||
command-destroyed-tethers = All tethers destroyed! You are now free
|
||||
command-destroyed-no-tethers = You're not connected to any tethers
|
||||
command-dismounted = Dismounted
|
||||
command-no-dismount = You're not riding or being ridden
|
||||
command-client-has-no-socketaddr = Cannot get socker addr (connected via mpsc connection) for { $target }
|
||||
command-parse-duration-error = Could not parse duration: { $error }
|
||||
command-ip-ban-require-online = { $error }. IP ban needs the target player to be online.
|
||||
|
||||
# Unreachable/untestable but added for consistency
|
||||
|
||||
@ -268,7 +279,4 @@ command-kit-inventory-unavailable = Could not get inventory
|
||||
command-inventory-cant-fit-item = Can't fit item to inventory
|
||||
# Emitted by /disconnect_all when you don't exist (?)
|
||||
command-you-dont-exist = You do not exist, so you cannot use this command
|
||||
command-destroyed-tethers = All tethers destroyed! You are now free
|
||||
command-destroyed-no-tethers = You're not connected to any tethers
|
||||
command-dismounted = Dismounted
|
||||
command-no-dismount = You're not riding or being ridden
|
||||
command-entity-has-no-client = Player has no client client component: { $target }
|
||||
|
@ -334,6 +334,7 @@ pub enum ServerChatCommand {
|
||||
AreaRemove,
|
||||
Aura,
|
||||
Ban,
|
||||
BanIp,
|
||||
BattleMode,
|
||||
BattleModeForce,
|
||||
Body,
|
||||
@ -414,6 +415,7 @@ pub enum ServerChatCommand {
|
||||
TimeScale,
|
||||
Tp,
|
||||
Unban,
|
||||
UnbanIp,
|
||||
Version,
|
||||
Waypoint,
|
||||
WeatherZone,
|
||||
@ -486,6 +488,16 @@ impl ServerChatCommand {
|
||||
Content::localized("command-ban-desc"),
|
||||
Some(Moderator),
|
||||
),
|
||||
ServerChatCommand::BanIp => cmd(
|
||||
vec![
|
||||
PlayerName(Required),
|
||||
Boolean("overwrite", "true".to_string(), Optional),
|
||||
Any("ban duration", Optional),
|
||||
Message(Optional),
|
||||
],
|
||||
Content::localized("command-ban-ip-desc"),
|
||||
Some(Admin),
|
||||
),
|
||||
#[rustfmt::skip]
|
||||
ServerChatCommand::BattleMode => cmd(
|
||||
vec![Enum(
|
||||
@ -959,6 +971,11 @@ impl ServerChatCommand {
|
||||
Content::localized("command-unban-desc"),
|
||||
Some(Moderator),
|
||||
),
|
||||
ServerChatCommand::UnbanIp => cmd(
|
||||
vec![PlayerName(Required)],
|
||||
Content::localized("command-unban-ip-desc"),
|
||||
Some(Moderator),
|
||||
),
|
||||
ServerChatCommand::Version => {
|
||||
cmd(vec![], Content::localized("command-version-desc"), None)
|
||||
},
|
||||
@ -1066,6 +1083,7 @@ impl ServerChatCommand {
|
||||
ServerChatCommand::AreaRemove => "area_remove",
|
||||
ServerChatCommand::Aura => "aura",
|
||||
ServerChatCommand::Ban => "ban",
|
||||
ServerChatCommand::BanIp => "ban_ip",
|
||||
ServerChatCommand::BattleMode => "battlemode",
|
||||
ServerChatCommand::BattleModeForce => "battlemode_force",
|
||||
ServerChatCommand::Body => "body",
|
||||
@ -1135,6 +1153,7 @@ impl ServerChatCommand {
|
||||
ServerChatCommand::RtsimPurge => "rtsim_purge",
|
||||
ServerChatCommand::RtsimChunk => "rtsim_chunk",
|
||||
ServerChatCommand::Unban => "unban",
|
||||
ServerChatCommand::UnbanIp => "unban_ip",
|
||||
ServerChatCommand::Version => "version",
|
||||
ServerChatCommand::Waypoint => "waypoint",
|
||||
ServerChatCommand::Wiring => "wiring",
|
||||
|
@ -39,6 +39,20 @@ pub enum ConnectAddr {
|
||||
Mpsc(u64),
|
||||
}
|
||||
|
||||
impl ConnectAddr {
|
||||
/// Returns the `Some` if the protocol is TCP or QUIC and `None` if the
|
||||
/// protocol is a local channel (mpsc).
|
||||
pub fn socket_addr(&self) -> Option<SocketAddr> {
|
||||
match self {
|
||||
Self::Tcp(addr) => Some(*addr),
|
||||
Self::Udp(addr) => Some(*addr),
|
||||
Self::Mpsc(_) => None,
|
||||
#[cfg(feature = "quic")]
|
||||
Self::Quic(addr, _, _) => Some(*addr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a Tcp, Quic, Udp or Mpsc listen address
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ListenAddr {
|
||||
@ -49,8 +63,8 @@ pub enum ListenAddr {
|
||||
Mpsc(u64),
|
||||
}
|
||||
|
||||
/// a Participant can throw different events, you are obligated to carefully
|
||||
/// empty the queue from time to time
|
||||
/// A Participant can throw different events, you are obligated to carefully
|
||||
/// empty the queue from time to time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ParticipantEvent {
|
||||
ChannelCreated(ConnectAddr),
|
||||
@ -412,7 +426,7 @@ impl Network {
|
||||
Ok(participant)
|
||||
}
|
||||
|
||||
/// returns a [`Participant`] created from a [`ListenAddr`] you
|
||||
/// Returns a [`Participant`] created from a [`ListenAddr`] you
|
||||
/// called [`listen`] on before. This function will either return a
|
||||
/// working [`Participant`] ready to open [`Streams`] on OR has returned
|
||||
/// a [`NetworkError`] (e.g. Network got closed)
|
||||
|
@ -1,8 +1,8 @@
|
||||
use common_net::msg::{ClientType, ServerGeneral, ServerMsg};
|
||||
use network::{Message, Participant, Stream, StreamError, StreamParams};
|
||||
use network::{ConnectAddr, Message, Participant, Stream, StreamError, StreamParams};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use specs::Component;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::{net::SocketAddr, sync::atomic::AtomicBool};
|
||||
|
||||
/// Client handles ALL network related information of everything that connects
|
||||
/// to the server Client DOES NOT handle game states
|
||||
@ -13,6 +13,8 @@ use std::sync::atomic::AtomicBool;
|
||||
pub struct Client {
|
||||
pub client_type: ClientType,
|
||||
pub participant: Option<Participant>,
|
||||
pub current_ip_addrs: Vec<SocketAddr>,
|
||||
connected_from_addr: ConnectAddr,
|
||||
pub last_ping: f64,
|
||||
pub login_msg_sent: AtomicBool,
|
||||
pub locale: Option<String>,
|
||||
@ -48,6 +50,7 @@ impl Client {
|
||||
pub(crate) fn new(
|
||||
client_type: ClientType,
|
||||
participant: Participant,
|
||||
connected_from: ConnectAddr,
|
||||
last_ping: f64,
|
||||
locale: Option<String>,
|
||||
general_stream: Stream,
|
||||
@ -66,6 +69,8 @@ impl Client {
|
||||
Client {
|
||||
client_type,
|
||||
participant: Some(participant),
|
||||
current_ip_addrs: connected_from.socket_addr().into_iter().collect(),
|
||||
connected_from_addr: connected_from,
|
||||
last_ping,
|
||||
locale,
|
||||
login_msg_sent: AtomicBool::new(false),
|
||||
@ -84,6 +89,8 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connected_from_addr(&self) -> &ConnectAddr { &self.connected_from_addr }
|
||||
|
||||
pub(crate) fn send<M: Into<ServerMsg>>(&self, msg: M) -> Result<(), StreamError> {
|
||||
// TODO: hack to avoid locking stream mutex while serializing the message,
|
||||
// remove this when the mutexes on the Streams are removed
|
||||
|
@ -8,12 +8,11 @@ use crate::{
|
||||
location::Locations,
|
||||
login_provider::LoginProvider,
|
||||
settings::{
|
||||
server_description::ServerDescription, Ban, BanAction, BanInfo, EditableSetting,
|
||||
SettingError, WhitelistInfo, WhitelistRecord,
|
||||
banlist::NormalizedIpAddr, server_description::ServerDescription, BanInfo, BanOperation,
|
||||
BanOperationError, EditableSetting, SettingError, WhitelistInfo, WhitelistRecord,
|
||||
},
|
||||
sys::terrain::SpawnEntityData,
|
||||
wiring,
|
||||
wiring::OutputFormula,
|
||||
wiring::{self, OutputFormula},
|
||||
Server, Settings, StateExt,
|
||||
};
|
||||
|
||||
@ -75,7 +74,10 @@ use hashbrown::{HashMap, HashSet};
|
||||
use humantime::Duration as HumanDuration;
|
||||
use rand::{thread_rng, Rng};
|
||||
use specs::{storage::StorageEntry, Builder, Entity as EcsEntity, Join, LendJoin, WorldExt};
|
||||
use std::{fmt::Write, num::NonZeroU32, ops::DerefMut, str::FromStr, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
fmt::Write, net::SocketAddr, num::NonZeroU32, ops::DerefMut, str::FromStr, sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use vek::*;
|
||||
use wiring::{Circuit, Wire, WireNode, WiringAction, WiringActionEffect, WiringElement};
|
||||
#[cfg(feature = "worldgen")]
|
||||
@ -145,6 +147,7 @@ fn do_command(
|
||||
ServerChatCommand::AreaRemove => handle_area_remove,
|
||||
ServerChatCommand::Aura => handle_aura,
|
||||
ServerChatCommand::Ban => handle_ban,
|
||||
ServerChatCommand::BanIp => handle_ban_ip,
|
||||
ServerChatCommand::BattleMode => handle_battlemode,
|
||||
ServerChatCommand::BattleModeForce => handle_battlemode_force,
|
||||
ServerChatCommand::Body => handle_body,
|
||||
@ -214,6 +217,7 @@ fn do_command(
|
||||
ServerChatCommand::RtsimPurge => handle_rtsim_purge,
|
||||
ServerChatCommand::RtsimChunk => handle_rtsim_chunk,
|
||||
ServerChatCommand::Unban => handle_unban,
|
||||
ServerChatCommand::UnbanIp => handle_unban_ip,
|
||||
ServerChatCommand::Version => handle_version,
|
||||
ServerChatCommand::Waypoint => handle_waypoint,
|
||||
ServerChatCommand::Wiring => handle_spawn_wiring,
|
||||
@ -279,6 +283,24 @@ fn uuid(server: &Server, entity: EcsEntity, descriptor: &str) -> CmdResult<Uuid>
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_addr(server: &Server, entity: EcsEntity, descriptor: &str) -> CmdResult<SocketAddr> {
|
||||
server
|
||||
.state
|
||||
.ecs()
|
||||
.read_storage::<Client>()
|
||||
.get(entity)
|
||||
.ok_or_else(|| {
|
||||
Content::localized_with_args("command-entity-has-no-client", [("target", descriptor)])
|
||||
})?
|
||||
.connected_from_addr()
|
||||
.socket_addr()
|
||||
.ok_or_else(|| {
|
||||
Content::localized_with_args("command-client-has-no-socketaddr", [(
|
||||
"target", descriptor,
|
||||
)])
|
||||
})
|
||||
}
|
||||
|
||||
fn real_role(server: &Server, uuid: Uuid, descriptor: &str) -> CmdResult<AdminRole> {
|
||||
server
|
||||
.editable_settings()
|
||||
@ -467,7 +489,47 @@ fn edit_setting_feedback<S: EditableSetting>(
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
Err(SettingError::Io(err)) => {
|
||||
Err(setting_error) => edit_setting_error_feedback(server, client, setting_error, || info),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_banlist_feedback(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
result: Result<(), BanOperationError>,
|
||||
// Message to provide if the edit was succesful (even if an IO error occurred, since the
|
||||
// setting will still be changed in memory)
|
||||
info: impl FnOnce() -> Content,
|
||||
// Message to provide if the edit was cancelled due to it having no effect.
|
||||
failure: impl FnOnce() -> Content,
|
||||
) -> CmdResult<()> {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
server.notify_client(
|
||||
client,
|
||||
ServerGeneral::server_msg(ChatType::CommandInfo, info()),
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
// TODO: whether there is a typo and the supplied username has no ban entry or if the
|
||||
// target was already banned/unbanned, the user of this command will always get the same
|
||||
// error message here, which seems like it could be misleading.
|
||||
Err(BanOperationError::NoEffect) => Err(failure()),
|
||||
Err(BanOperationError::EditFailed(setting_error)) => {
|
||||
edit_setting_error_feedback(server, client, setting_error, info)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_setting_error_feedback<S: EditableSetting>(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
setting_error: SettingError<S>,
|
||||
info: impl FnOnce() -> Content,
|
||||
) -> CmdResult<()> {
|
||||
match setting_error {
|
||||
SettingError::Io(err) => {
|
||||
let info = info();
|
||||
warn!(
|
||||
?err,
|
||||
"Failed to write settings file to disk, but succeeded in memory (success message: \
|
||||
@ -486,7 +548,7 @@ fn edit_setting_feedback<S: EditableSetting>(
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
Err(SettingError::Integrity(err)) => Err(Content::localized_with_args(
|
||||
SettingError::Integrity(err) => Err(Content::localized_with_args(
|
||||
"command-error-while-evaluating-request",
|
||||
[("error", format!("{err:?}"))],
|
||||
)),
|
||||
@ -2026,7 +2088,7 @@ fn handle_make_volume(
|
||||
) -> CmdResult<()> {
|
||||
use comp::body::ship::figuredata::VoxelCollider;
|
||||
|
||||
//let () = parse_args!(args);
|
||||
//let () = parse_cmd_args!(args);
|
||||
let pos = position(server, target, "target")?;
|
||||
let ship = comp::ship::Body::Volume;
|
||||
let sz = parse_cmd_args!(args, u32).unwrap_or(15);
|
||||
@ -4511,6 +4573,36 @@ fn handle_kick(
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ban_info(server: &mut Server, client: EcsEntity, client_uuid: Uuid) -> CmdResult<BanInfo> {
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
let ban_info = BanInfo {
|
||||
performed_by: client_uuid,
|
||||
performed_by_username: client_username,
|
||||
performed_by_role: client_role.into(),
|
||||
};
|
||||
Ok(ban_info)
|
||||
}
|
||||
|
||||
fn ban_end_date(
|
||||
now: chrono::DateTime<Utc>,
|
||||
parse_duration: Option<HumanDuration>,
|
||||
) -> CmdResult<Option<chrono::DateTime<Utc>>> {
|
||||
let end_date = parse_duration
|
||||
.map(|duration| chrono::Duration::from_std(duration.into()))
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
Content::localized_with_args(
|
||||
"command-parse-duration-error",
|
||||
[("error", format!("{err:?}"))]
|
||||
)
|
||||
})?
|
||||
// On overflow (someone adding some ridiculous time span), just make the ban infinite.
|
||||
// (end date of None is an infinite ban)
|
||||
.and_then(|duration| now.checked_add_signed(duration));
|
||||
Ok(end_date)
|
||||
}
|
||||
|
||||
fn handle_ban(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
@ -4524,57 +4616,48 @@ fn handle_ban(
|
||||
let reason = reason_opt.unwrap_or_default();
|
||||
let overwrite = overwrite.unwrap_or(false);
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let ban_info = make_ban_info(server, client, client_uuid)?;
|
||||
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
|
||||
let now = Utc::now();
|
||||
let end_date = parse_duration
|
||||
.map(|duration| chrono::Duration::from_std(duration.into()))
|
||||
.transpose()
|
||||
.map_err(|err| Content::Plain(format!("Error converting to duration: {}", err)))?
|
||||
// On overflow (someone adding some ridiculous time span), just make the ban infinite.
|
||||
.and_then(|duration| now.checked_add_signed(duration));
|
||||
let end_date = ban_end_date(now, parse_duration)?;
|
||||
|
||||
let ban_info = BanInfo {
|
||||
performed_by: client_uuid,
|
||||
performed_by_username: client_username,
|
||||
performed_by_role: client_role.into(),
|
||||
let result = server.editable_settings_mut().banlist.ban_operation(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
BanOperation::Ban {
|
||||
reason: reason.clone(),
|
||||
info: ban_info,
|
||||
end_date,
|
||||
},
|
||||
overwrite,
|
||||
);
|
||||
let (result, ban_info) = match result {
|
||||
Ok(info) => (Ok(()), info),
|
||||
Err(err) => (Err(err), None),
|
||||
};
|
||||
|
||||
let ban = Ban {
|
||||
reason: reason.clone(),
|
||||
info: Some(ban_info),
|
||||
end_date,
|
||||
};
|
||||
let ban_info = ban.info();
|
||||
|
||||
let edit = server
|
||||
.editable_settings_mut()
|
||||
.banlist
|
||||
.ban_action(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
BanAction::Ban(ban),
|
||||
overwrite,
|
||||
)
|
||||
.map(|result| {
|
||||
(
|
||||
Content::localized_with_args("command-ban-added", [
|
||||
("player", username.to_owned()),
|
||||
("reason", reason.to_owned()),
|
||||
]),
|
||||
result,
|
||||
)
|
||||
});
|
||||
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
Content::localized_with_args("command-ban-already-added", [("player", username)])
|
||||
})?;
|
||||
edit_banlist_feedback(
|
||||
server,
|
||||
client,
|
||||
result,
|
||||
|| {
|
||||
Content::localized_with_args("command-ban-added", [
|
||||
("player", username.clone()),
|
||||
("reason", reason),
|
||||
])
|
||||
},
|
||||
|| {
|
||||
Content::localized_with_args("command-ban-already-added", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
)?;
|
||||
// If the player is online kick them (this may fail if the player is a hardcoded
|
||||
// admin; we don't care about that case because hardcoded admins can log on even
|
||||
// if they're on the ban list).
|
||||
@ -4584,7 +4667,7 @@ fn handle_ban(
|
||||
server,
|
||||
(client, client_uuid),
|
||||
(target_player, player_uuid),
|
||||
DisconnectReason::Banned(ban_info),
|
||||
ban_info.map_or(DisconnectReason::Shutdown, DisconnectReason::Banned),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@ -4700,6 +4783,102 @@ fn handle_aura(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_ban_ip(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
_target: EcsEntity,
|
||||
args: Vec<String>,
|
||||
action: &ServerChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
if let (Some(username), overwrite, parse_duration, reason_opt) =
|
||||
parse_cmd_args!(args, String, bool, HumanDuration, String)
|
||||
{
|
||||
let reason = reason_opt.unwrap_or_default();
|
||||
let overwrite = overwrite.unwrap_or(false);
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let ban_info = make_ban_info(server, client, client_uuid)?;
|
||||
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
let player_entity = find_uuid(server.state.ecs(), player_uuid).map_err(|err| {
|
||||
Content::localized_with_args("command-ip-ban-require-online", [("error", err)])
|
||||
})?;
|
||||
let player_ip_addr =
|
||||
NormalizedIpAddr::from(socket_addr(server, player_entity, &username)?.ip());
|
||||
|
||||
let now = Utc::now();
|
||||
let end_date = ban_end_date(now, parse_duration)?;
|
||||
|
||||
let result = server.editable_settings_mut().banlist.ban_operation(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
BanOperation::BanIp {
|
||||
reason: reason.clone(),
|
||||
info: ban_info,
|
||||
end_date,
|
||||
ip: player_ip_addr,
|
||||
},
|
||||
overwrite,
|
||||
);
|
||||
let (result, ban_info) = match result {
|
||||
Ok(info) => (Ok(()), info),
|
||||
Err(err) => (Err(err), None),
|
||||
};
|
||||
|
||||
edit_banlist_feedback(
|
||||
server,
|
||||
client,
|
||||
result,
|
||||
|| {
|
||||
Content::localized_with_args("command-ban-ip-added", [
|
||||
("player", username.clone()),
|
||||
("reason", reason),
|
||||
])
|
||||
},
|
||||
|| {
|
||||
Content::localized_with_args("command-ban-already-added", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
)?;
|
||||
|
||||
// Kick all online players with this IP address them (this may fail if the
|
||||
// player is a hardcoded admin; we don't care about that case because
|
||||
// hardcoded admins can log on even if they're on the ban list).
|
||||
let ecs = server.state.ecs();
|
||||
let players_to_kick = (
|
||||
&ecs.entities(),
|
||||
&ecs.read_storage::<Client>(),
|
||||
&ecs.read_storage::<comp::Player>(),
|
||||
)
|
||||
.join()
|
||||
.filter(|(_, client, _)| {
|
||||
client
|
||||
.current_ip_addrs
|
||||
.iter()
|
||||
.any(|socket_addr| NormalizedIpAddr::from(socket_addr.ip()) == player_ip_addr)
|
||||
})
|
||||
.map(|(entity, _, player)| (entity, player.uuid()))
|
||||
.collect::<Vec<_>>();
|
||||
for (player_entity, player_uuid) in players_to_kick {
|
||||
let _ = kick_player(
|
||||
server,
|
||||
(client, client_uuid),
|
||||
(player_entity, player_uuid),
|
||||
ban_info
|
||||
.clone()
|
||||
.map_or(DisconnectReason::Shutdown, DisconnectReason::Banned),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(action.help_content())
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_battlemode(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
@ -4856,43 +5035,91 @@ fn handle_unban(
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let client_username = uuid_to_username(server, client, client_uuid)?;
|
||||
let client_role = real_role(server, client_uuid, "client")?;
|
||||
let ban_info = make_ban_info(server, client, client_uuid)?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let ban_info = BanInfo {
|
||||
performed_by: client_uuid,
|
||||
performed_by_username: client_username,
|
||||
performed_by_role: client_role.into(),
|
||||
let unban = BanOperation::Unban { info: ban_info };
|
||||
|
||||
let result = server.editable_settings_mut().banlist.ban_operation(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
unban,
|
||||
false,
|
||||
);
|
||||
|
||||
edit_banlist_feedback(
|
||||
server,
|
||||
client,
|
||||
result.map(|_| ()),
|
||||
// TODO: it would be useful to indicate here whether an IP ban was also removed but we
|
||||
// don't have that info.
|
||||
|| {
|
||||
Content::localized_with_args("command-unban-successful", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
|| {
|
||||
Content::localized_with_args("command-unban-already-unbanned", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Err(action.help_content())
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_unban_ip(
|
||||
server: &mut Server,
|
||||
client: EcsEntity,
|
||||
_target: EcsEntity,
|
||||
args: Vec<String>,
|
||||
action: &ServerChatCommand,
|
||||
) -> CmdResult<()> {
|
||||
if let Some(username) = parse_cmd_args!(args, String) {
|
||||
let player_uuid = find_username(server, &username)?;
|
||||
|
||||
let client_uuid = uuid(server, client, "client")?;
|
||||
let ban_info = make_ban_info(server, client, client_uuid)?;
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let unban = BanOperation::UnbanIp {
|
||||
info: ban_info,
|
||||
uuid: player_uuid,
|
||||
};
|
||||
|
||||
let unban = BanAction::Unban(ban_info);
|
||||
let result = server.editable_settings_mut().banlist.ban_operation(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
unban,
|
||||
false,
|
||||
);
|
||||
|
||||
let edit = server
|
||||
.editable_settings_mut()
|
||||
.banlist
|
||||
.ban_action(
|
||||
server.data_dir().as_ref(),
|
||||
now,
|
||||
player_uuid,
|
||||
username.clone(),
|
||||
unban,
|
||||
false,
|
||||
)
|
||||
.map(|result| {
|
||||
(
|
||||
Content::localized_with_args("command-unban-successful", [(
|
||||
"player",
|
||||
username.to_owned(),
|
||||
)]),
|
||||
result,
|
||||
)
|
||||
});
|
||||
|
||||
edit_setting_feedback(server, client, edit, || {
|
||||
Content::localized_with_args("command-unban-already-unbanned", [("player", username)])
|
||||
})
|
||||
edit_banlist_feedback(
|
||||
server,
|
||||
client,
|
||||
result.map(|_| ()),
|
||||
|| {
|
||||
Content::localized_with_args("command-unban-ip-successful", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
|| {
|
||||
Content::localized_with_args("command-unban-already-unbanned", [(
|
||||
"player",
|
||||
username.clone(),
|
||||
)])
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Err(action.help_content())
|
||||
}
|
||||
|
@ -108,7 +108,7 @@ impl ConnectionHandler {
|
||||
}
|
||||
|
||||
async fn init_participant(
|
||||
participant: Participant,
|
||||
mut participant: Participant,
|
||||
client_sender: Sender<IncomingClient>,
|
||||
info_requester_sender: Sender<Sender<ServerInfoPacket>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@ -142,9 +142,33 @@ impl ConnectionHandler {
|
||||
Some(client_type) => client_type?,
|
||||
};
|
||||
|
||||
use network::ParticipantEvent;
|
||||
let connected_from = match select!(
|
||||
_ = tokio::time::sleep(TIMEOUT).fuse() => None,
|
||||
connected_from = participant.fetch_event().fuse() => Some(connected_from),
|
||||
) {
|
||||
None => {
|
||||
error!("Did not receive initial channel created event. This is a bug!");
|
||||
return Ok(());
|
||||
},
|
||||
Some(Err(err)) => {
|
||||
debug!("Participant error when trying to receive event: {err:?}");
|
||||
return Ok(());
|
||||
},
|
||||
Some(Ok(ParticipantEvent::ChannelDeleted(_))) => {
|
||||
error!(
|
||||
"Received channel deleted event instead of the initial channel created event. \
|
||||
This is a bug!"
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
Some(Ok(ParticipantEvent::ChannelCreated(connected_from))) => connected_from,
|
||||
};
|
||||
|
||||
let client = Client::new(
|
||||
client_type,
|
||||
participant,
|
||||
connected_from,
|
||||
server_data.time,
|
||||
None,
|
||||
general_stream,
|
||||
|
@ -175,11 +175,15 @@ pub fn handle_client_disconnect(
|
||||
) -> Event {
|
||||
span!(_guard, "handle_client_disconnect");
|
||||
let mut emit_logoff_event = true;
|
||||
|
||||
// Entity deleted below and persist_entity doesn't require a `Client` component,
|
||||
// so we can just remove the Client component to get ownership of the
|
||||
// participant.
|
||||
if let Some(client) = server
|
||||
.state()
|
||||
.ecs()
|
||||
.write_storage::<Client>()
|
||||
.get_mut(entity)
|
||||
.remove(entity)
|
||||
{
|
||||
// NOTE: There are not and likely will not be a way to safeguard against
|
||||
// receiving multiple `ServerEvent::ClientDisconnect` messages in a tick
|
||||
@ -193,7 +197,7 @@ pub fn handle_client_disconnect(
|
||||
.with_label_values(&[get_reason_str(&reason)])
|
||||
.inc();
|
||||
|
||||
if let Some(participant) = client.participant.take() {
|
||||
if let Some(participant) = client.participant {
|
||||
let pid = participant.remote_pid();
|
||||
server.runtime.spawn(
|
||||
async {
|
||||
@ -269,6 +273,8 @@ pub fn handle_client_disconnect(
|
||||
/// that this function will not be called twice on an entity with the same
|
||||
/// character id.
|
||||
pub(super) fn persist_entity(state: &mut State, entity: EcsEntity) -> EcsEntity {
|
||||
// NOTE: `Client` component may already be removed by the caller to close the
|
||||
// connection. Don't depend on it here!
|
||||
if let (
|
||||
Some(presence),
|
||||
Some(skill_set),
|
||||
|
@ -1,4 +1,7 @@
|
||||
use crate::settings::{AdminRecord, BanEntry, WhitelistRecord};
|
||||
use crate::{
|
||||
settings::{banlist::NormalizedIpAddr, AdminRecord, Ban, Banlist, WhitelistRecord},
|
||||
Client,
|
||||
};
|
||||
use authc::{AuthClient, AuthClientError, AuthToken, Uuid};
|
||||
use chrono::Utc;
|
||||
use common::comp::AdminRole;
|
||||
@ -9,6 +12,24 @@ use std::{str::FromStr, sync::Arc};
|
||||
use tokio::{runtime::Runtime, sync::oneshot};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Determines whether a user is banned, given a ban record connected to a user,
|
||||
/// the `AdminRecord` of that user (if it exists), and the current time.
|
||||
pub fn ban_applies(
|
||||
ban: &Ban,
|
||||
admin: Option<&AdminRecord>,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
// Make sure the ban is active, and that we can't override it.
|
||||
//
|
||||
// If we are an admin and our role is at least as high as the role of the
|
||||
// person who banned us, we can override the ban; we negate this to find
|
||||
// people who cannot override it.
|
||||
let exceeds_ban_role = |admin: &AdminRecord| {
|
||||
AdminRole::from(admin.role) >= AdminRole::from(ban.performed_by_role())
|
||||
};
|
||||
!ban.is_expired(now) && !admin.map_or(false, exceeds_ban_role)
|
||||
}
|
||||
|
||||
fn derive_uuid(username: &str) -> Uuid {
|
||||
let mut state = 144066263297769815596495629667062367629;
|
||||
|
||||
@ -93,34 +114,40 @@ impl LoginProvider {
|
||||
|
||||
pub(crate) fn login<R>(
|
||||
pending: &mut PendingLogin,
|
||||
client: &Client,
|
||||
admins: &HashMap<Uuid, AdminRecord>,
|
||||
whitelist: &HashMap<Uuid, WhitelistRecord>,
|
||||
banlist: &HashMap<Uuid, BanEntry>,
|
||||
banlist: &Banlist,
|
||||
player_count_exceeded: impl FnOnce(String, Uuid) -> (bool, R),
|
||||
) -> Option<Result<R, RegisterError>> {
|
||||
match pending.pending_r.try_recv() {
|
||||
Ok(Err(e)) => Some(Err(e)),
|
||||
Ok(Ok((username, uuid))) => {
|
||||
let now = Utc::now();
|
||||
// We ignore mpsc connections since those aren't to an external
|
||||
// process.
|
||||
let ip = client
|
||||
.connected_from_addr()
|
||||
.socket_addr()
|
||||
.map(|s| s.ip())
|
||||
.map(NormalizedIpAddr::from);
|
||||
// Hardcoded admins can always log in.
|
||||
let admin = admins.get(&uuid);
|
||||
if let Some(ban) = banlist
|
||||
.uuid_bans()
|
||||
.get(&uuid)
|
||||
.and_then(|ban_record| ban_record.current.action.ban())
|
||||
.and_then(|ban_entry| ban_entry.current.action.ban())
|
||||
.into_iter()
|
||||
.chain(ip.and_then(|ip| {
|
||||
banlist
|
||||
.ip_bans()
|
||||
.get(&ip)
|
||||
.and_then(|ban_entry| ban_entry.current.action.ban())
|
||||
}))
|
||||
.find(|ban| ban_applies(ban, admin, now))
|
||||
{
|
||||
// Make sure the ban is active, and that we can't override it.
|
||||
//
|
||||
// If we are an admin and our role is at least as high as the role of the
|
||||
// person who banned us, we can override the ban; we negate this to find
|
||||
// people who cannot override it.
|
||||
let exceeds_ban_role = |admin: &AdminRecord| {
|
||||
Into::<AdminRole>::into(admin.role)
|
||||
>= Into::<AdminRole>::into(ban.performed_by_role())
|
||||
};
|
||||
if !ban.is_expired(now) && !admin.map_or(false, exceeds_ban_role) {
|
||||
// Get ban info and send a copy of it
|
||||
return Some(Err(RegisterError::Banned(ban.info())));
|
||||
}
|
||||
// Get ban info and send a copy of it
|
||||
return Some(Err(RegisterError::Banned(ban.info())));
|
||||
}
|
||||
|
||||
// non-admins can only join if the whitelist is empty (everyone can join)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,6 @@ use std::{
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Errors that can occur during edits to a settings file.
|
||||
pub enum Error<S: EditableSetting> {
|
||||
/// An error occurred validating the settings file.
|
||||
@ -17,13 +16,37 @@ pub enum Error<S: EditableSetting> {
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
impl<S: EditableSetting> fmt::Debug for Error<S> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Error::Integrity(err) => fmt::Formatter::debug_tuple(f, "Integrity")
|
||||
.field(err)
|
||||
.finish(),
|
||||
Error::Io(err) => fmt::Formatter::debug_tuple(f, "Io").field(err).finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as Error, but carries the validated settings in the Io case.
|
||||
enum ErrorInternal<S: EditableSetting> {
|
||||
Integrity(S::Error),
|
||||
Io(std::io::Error, S),
|
||||
}
|
||||
|
||||
impl<S: EditableSetting> fmt::Debug for ErrorInternal<S> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ErrorInternal::Integrity(err) => fmt::Formatter::debug_tuple(f, "Integrity")
|
||||
.field(err)
|
||||
.finish(),
|
||||
ErrorInternal::Io(err, _setting) => fmt::Formatter::debug_tuple(f, "Io")
|
||||
.field(err)
|
||||
.field(&"EditableSetting not required to impl Debug")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Version {
|
||||
/// This was an old version of the settings file, so overwrite with the
|
||||
/// modern config.
|
||||
@ -160,7 +183,7 @@ pub trait EditableSetting: Clone + Default {
|
||||
}
|
||||
}
|
||||
|
||||
/// If the result of calling f is None,we return None (this constitutes an
|
||||
/// If the result of calling f is None, we return None (this constitutes an
|
||||
/// early return and lets us abandon the in-progress edit). For
|
||||
/// example, this can be used to avoid adding a new ban entry if someone
|
||||
/// is already banned and the user didn't explicitly specify that they
|
||||
|
@ -8,7 +8,8 @@ pub use editable::{EditableSetting, Error as SettingError};
|
||||
|
||||
pub use admin::{AdminRecord, Admins};
|
||||
pub use banlist::{
|
||||
Ban, BanAction, BanEntry, BanError, BanErrorKind, BanInfo, BanKind, BanRecord, Banlist,
|
||||
Ban, BanEntry, BanError, BanErrorKind, BanInfo, BanKind, BanOperation, BanOperationError,
|
||||
BanRecord, Banlist,
|
||||
};
|
||||
pub use server_description::ServerDescriptions;
|
||||
pub use whitelist::{Whitelist, WhitelistInfo, WhitelistRecord};
|
||||
|
@ -1,6 +1,7 @@
|
||||
pub mod character_screen;
|
||||
pub mod general;
|
||||
pub mod in_game;
|
||||
pub mod network_events;
|
||||
pub mod ping;
|
||||
pub mod register;
|
||||
pub mod terrain;
|
||||
@ -24,6 +25,7 @@ pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) {
|
||||
dispatch::<terrain::Sys>(dispatch_builder, &[]);
|
||||
dispatch::<pets::Sys>(dispatch_builder, &[]);
|
||||
dispatch::<loot::Sys>(dispatch_builder, &[]);
|
||||
dispatch::<network_events::Sys>(dispatch_builder, &[]);
|
||||
}
|
||||
|
||||
/// handles all send msg and calls a handle fn
|
||||
|
104
server/src/sys/msg/network_events.rs
Normal file
104
server/src/sys/msg/network_events.rs
Normal file
@ -0,0 +1,104 @@
|
||||
use crate::{client::Client, settings::banlist::NormalizedIpAddr, EditableSettings};
|
||||
use common::{
|
||||
comp::Player,
|
||||
event::{ClientDisconnectEvent, EventBus},
|
||||
};
|
||||
use common_ecs::{Job, Origin, Phase, System};
|
||||
use common_net::msg::{DisconnectReason, ServerGeneral};
|
||||
use network::ParticipantEvent;
|
||||
use specs::{Entities, Join, Read, ReadExpect, ReadStorage, WriteStorage};
|
||||
|
||||
/// This system consumes events from the `Participant::try_fetch_event`. These
|
||||
/// currently indicate channels being created and destroyed which potentially
|
||||
/// corresponds to the client using new addresses.
|
||||
///
|
||||
/// New addresses are checked against the existing IP bans. If a match is found
|
||||
/// that client will be kicked. Otherwise, the IP is added to the set of IPs
|
||||
/// that client has used. When a new IP ban is created, the set of IP addrs used
|
||||
/// by each client is scanned and any clients with matches are kicked.
|
||||
///
|
||||
/// We could retain addresses of removed channels and use them when banning but
|
||||
/// that would use a potentially unknown amount of memory (so they are removed).
|
||||
#[derive(Default)]
|
||||
pub struct Sys;
|
||||
impl<'a> System<'a> for Sys {
|
||||
type SystemData = (
|
||||
Entities<'a>,
|
||||
ReadStorage<'a, Player>,
|
||||
WriteStorage<'a, Client>,
|
||||
Read<'a, EventBus<ClientDisconnectEvent>>,
|
||||
ReadExpect<'a, EditableSettings>,
|
||||
);
|
||||
|
||||
const NAME: &'static str = "msg::network_events";
|
||||
const ORIGIN: Origin = Origin::Server;
|
||||
const PHASE: Phase = Phase::Create;
|
||||
|
||||
fn run(
|
||||
_job: &mut Job<Self>,
|
||||
(entities, players, mut clients, client_disconnect_event_bus, editable_settings): Self::SystemData,
|
||||
) {
|
||||
let now = chrono::Utc::now();
|
||||
let mut client_disconnect_emitter = client_disconnect_event_bus.emitter();
|
||||
|
||||
for (entity, client) in (&entities, &mut clients).join() {
|
||||
while let Some(Ok(Some(event))) = client
|
||||
.participant
|
||||
.as_mut()
|
||||
.map(|participant| participant.try_fetch_event())
|
||||
{
|
||||
match event {
|
||||
ParticipantEvent::ChannelCreated(connect_addr) => {
|
||||
// Ignore mpsc connections
|
||||
if let Some(addr) = connect_addr.socket_addr() {
|
||||
client.current_ip_addrs.push(addr);
|
||||
|
||||
let banned = editable_settings
|
||||
.banlist
|
||||
.ip_bans()
|
||||
.get(&NormalizedIpAddr::from(addr.ip()))
|
||||
.and_then(|ban_entry| ban_entry.current.action.ban())
|
||||
.and_then(|ban| {
|
||||
// Hardcoded admins can always log in.
|
||||
let admin = players.get(entity).and_then(|player| {
|
||||
editable_settings.admins.get(&player.uuid())
|
||||
});
|
||||
crate::login_provider::ban_applies(ban, admin, now)
|
||||
.then(|| ban.info())
|
||||
});
|
||||
|
||||
if let Some(ban_info) = banned {
|
||||
// Kick client
|
||||
client_disconnect_emitter.emit(ClientDisconnectEvent(
|
||||
entity,
|
||||
common::comp::DisconnectReason::Kicked,
|
||||
));
|
||||
let _ = client.send(ServerGeneral::Disconnect(
|
||||
DisconnectReason::Banned(ban_info),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
ParticipantEvent::ChannelDeleted(connect_addr) => {
|
||||
// Ignore mpsc connections
|
||||
if let Some(addr) = connect_addr.socket_addr() {
|
||||
if let Some(i) = client
|
||||
.current_ip_addrs
|
||||
.iter()
|
||||
.rev()
|
||||
.position(|a| *a == addr)
|
||||
{
|
||||
client.current_ip_addrs.remove(i);
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Channel deleted but its address isn't present in \
|
||||
client.current_ip_addrs!"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -42,11 +42,6 @@ impl<'a> System<'a> for Sys {
|
||||
(&entities, &mut clients).par_join().for_each_init(
|
||||
|| client_disconnect.emitter(),
|
||||
|client_disconnect_emitter, (entity, client)| {
|
||||
// ignore network events
|
||||
while let Some(Ok(Some(_))) =
|
||||
client.participant.as_mut().map(|p| p.try_fetch_event())
|
||||
{}
|
||||
|
||||
let res = super::try_recv_all(client, 4, Self::handle_ping_msg);
|
||||
|
||||
match res {
|
||||
|
@ -229,6 +229,7 @@ impl<'a> System<'a> for Sys {
|
||||
mut new_players_guard,
|
||||
) = match LoginProvider::login(
|
||||
pending,
|
||||
client,
|
||||
&read_data.editable_settings.admins,
|
||||
&read_data.editable_settings.whitelist,
|
||||
&read_data.editable_settings.banlist,
|
||||
|
@ -793,7 +793,10 @@ fn verify_cmd_list_sorted() {
|
||||
#[test]
|
||||
fn test_complete_command() {
|
||||
assert_eq!(complete_command("mu", '/'), vec!["/mute".to_string()]);
|
||||
assert_eq!(complete_command("unba", '/'), vec!["/unban".to_string()]);
|
||||
assert_eq!(complete_command("unba", '/'), vec![
|
||||
"/unban".to_string(),
|
||||
"/unban_ip".to_string()
|
||||
]);
|
||||
assert_eq!(complete_command("make_", '/'), vec![
|
||||
"/make_block".to_string(),
|
||||
"/make_npc".to_string(),
|
||||
|
Reference in New Issue
Block a user