station/crates/game_server/src/commands.rs

507 lines
17 KiB
Rust

//! Server commands, typed into the server console or sent by players as
//! `/command` chat messages.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{components::Character, protocol::GameLog};
use crate::{
config, map_io, persistence,
players::{self, Controller, PlayerId, Players, RejectedLogins},
};
/// Who issued a command.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandSource {
/// The dedicated server's terminal.
Console,
Player {
client: ClientId,
admin: bool,
/// Host of the embedded server; may also stop it.
owner: bool,
},
}
impl CommandSource {
fn is_admin(self) -> bool {
match self {
Self::Console => true,
Self::Player { admin, owner, .. } => admin || owner,
}
}
fn may_stop(self) -> bool {
match self {
Self::Console => true,
Self::Player { owner, .. } => owner,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Permission {
Everyone,
Admin,
Stop,
}
/// Usage, description and who may run it.
const COMMANDS: &[(&str, &str, Permission)] = &[
("help", "list commands", Permission::Everyone),
("players", "list online players", Permission::Everyone),
("say MESSAGE", "message all players", Permission::Admin),
(
"kick PLAYER [REASON]",
"disconnect a player",
Permission::Admin,
),
(
"ban PLAYER",
"ban a player by name or id",
Permission::Admin,
),
("unban PLAYER", "lift a ban", Permission::Admin),
("bans", "list banned players", Permission::Admin),
(
"whitelist on|off|list",
"toggle or show the whitelist",
Permission::Admin,
),
(
"whitelist add|remove PLAYER",
"edit the whitelist",
Permission::Admin,
),
(
"admin add|remove PLAYER",
"grant or revoke admin rights",
Permission::Admin,
),
("admin list", "list admins", Permission::Admin),
("rejected", "recently rejected logins", Permission::Admin),
("save", "save the world now", Permission::Admin),
(
"export [FILE]",
"export the world to a Tiled map",
Permission::Admin,
),
(
"import [FILE]",
"replace the world from a Tiled map (keeps characters)",
Permission::Admin,
),
(
"config get KEY",
"show a setting, e.g. `config get access.whitelist`",
Permission::Admin,
),
(
"config set KEY VALUE",
"change a setting and write it to the config file",
Permission::Admin,
),
("stop", "save and shut down the server", Permission::Stop),
];
/// Usage and description of the commands available to `source`.
pub fn help(source: CommandSource) -> Vec<(&'static str, &'static str)> {
COMMANDS
.iter()
.filter(|(_, _, p)| allowed(source, *p))
.map(|(usage, what, _)| (*usage, *what))
.collect()
}
fn allowed(source: CommandSource, permission: Permission) -> bool {
match permission {
Permission::Everyone => true,
Permission::Admin => source.is_admin(),
Permission::Stop => source.may_stop(),
}
}
fn permission(command: &str) -> Option<Permission> {
COMMANDS
.iter()
.find(|(usage, _, _)| usage.split(' ').next() == Some(command))
.map(|(_, _, p)| *p)
}
/// Runs a command line and returns the reply.
pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec<String> {
let line = line.trim().trim_start_matches('/');
let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
let rest = rest.trim();
if command.is_empty() {
return vec![];
}
match permission(command) {
None => {
return vec![format!(
"Unknown command `{command}`. Type `help` for a list."
)];
}
Some(p) if !allowed(source, p) => {
return vec![format!("You are not allowed to use `{command}`.")];
}
Some(_) => {}
}
let actor = actor_name(world, source);
match command {
"help" => help(source)
.into_iter()
.map(|(usage, what)| format!("{usage:<28} {what}"))
.collect(),
"players" => {
let mut q = world.query::<(&Character, &PlayerId, Has<players::Admin>)>();
let mut names: Vec<String> = q
.iter(world)
.map(|(c, id, admin)| {
let mut entry = c.name.clone();
if admin {
entry.push_str(" [admin]");
}
if source.is_admin() {
entry.push_str(&format!(" ({})", id.0));
}
entry
})
.collect();
names.sort();
let mut reply = vec![format!("{} online", names.len())];
reply.extend(names);
reply
}
"say" if !rest.is_empty() => {
let text = format!("[{actor}] {rest}");
world.commands().server_trigger(ToClients {
targets: SendTargets::All,
message: GameLog { text: text.clone() },
});
world.flush();
vec![text]
}
"kick" if !rest.is_empty() => {
let (target, reason) = rest.split_once(' ').unwrap_or((rest, ""));
let reason = reason.trim();
kick(world, &actor, target, reason)
}
"ban" if !rest.is_empty() => {
let target = resolve(world, rest);
match list_edit(world, "access.banned", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} banned {}.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already banned.", target.label)],
Err(e) => vec![e],
}
}
"unban" if !rest.is_empty() => unlist(world, &actor, "access.banned", rest, "banned"),
"bans" => show_list(world, |c| &c.access.banned, "Banned"),
"whitelist" => whitelist_command(world, &actor, rest),
"admin" => admin_command(world, &actor, rest),
"rejected" => {
let log = &world.resource::<RejectedLogins>().0;
if log.is_empty() {
vec!["No rejected logins.".into()]
} else {
log.iter().cloned().collect()
}
}
"save" => {
if world.contains_resource::<persistence::Storage>() {
persistence::save_world_blocking(world);
vec!["World saved.".into()]
} else {
vec!["Saving is disabled.".into()]
}
}
"config" => config_command(world, rest),
"export" | "import" => {
let path = if rest.is_empty() {
map_io::default_path(world)
} else {
std::path::PathBuf::from(rest)
};
let result = if command == "export" {
map_io::export(world, &path)
} else {
map_io::import(world, &path).map(|()| path.clone())
};
match result {
Ok(path) => {
info!("{actor}: {command} {}", path.display());
let mut message = format!("World {command}ed: {}", path.display());
if command == "import" && !world.contains_resource::<persistence::Storage>() {
message.push_str(" (Saving is disabled; changes are not persistent.)");
}
vec![message]
}
Err(e) => {
error!("{command} {} failed: {e}", path.display());
vec![format!("{command} failed: {e}")]
}
}
}
"stop" => {
info!("{actor} stopped the server");
world.write_message(AppExit::Success);
vec!["Shutting down.".into()]
}
_ => {
let usage: Vec<String> = COMMANDS
.iter()
.filter(|(usage, _, _)| usage.split(' ').next() == Some(command))
.map(|(usage, _, _)| format!("Usage: {usage}"))
.collect();
usage
}
}
}
fn actor_name(world: &mut World, source: CommandSource) -> String {
match source {
CommandSource::Console => "server".into(),
CommandSource::Player { client, .. } => world
.resource::<Players>()
.by_client
.get(&client)
.and_then(|&e| world.get::<Character>(e))
.map_or_else(|| "server".into(), |c| c.name.clone()),
}
}
/// A player named in a command.
struct Target {
/// Value stored in access lists: the player id if known, otherwise the given name.
entry: String,
/// For messages.
label: String,
}
/// Resolves a character name or player id.
fn resolve(world: &mut World, input: &str) -> Target {
let input = input.trim();
if uuid::Uuid::parse_str(input).is_ok() {
let id = input.to_lowercase();
let label = match players::character_name(world, &id) {
Some(name) => format!("{name} ({id})"),
None => id.clone(),
};
return Target { entry: id, label };
}
match players::find_player_by_name(world, input) {
Some(id) => {
let name = players::character_name(world, &id).unwrap_or_else(|| input.to_owned());
Target {
label: format!("{name} ({id})"),
entry: id,
}
}
// Unknown players are listed by name, so they can be banned or
// whitelisted before they ever join.
None => Target {
entry: input.to_owned(),
label: input.to_owned(),
},
}
}
/// Messages online admins and the server log.
fn notify(world: &mut World, text: &str) {
info!("{text}");
players::tell_admins(world, &format!("[admin] {text}"));
}
fn kick(world: &mut World, actor: &str, target: &str, reason: &str) -> Vec<String> {
let target = resolve(world, target);
let mut q = world.query::<(&PlayerId, &Controller)>();
let Some(client) = q
.iter(world)
.find(|(id, _)| id.0 == target.entry)
.map(|(_, c)| c.0)
else {
return vec![format!("{} is not online.", target.label)];
};
let text = if reason.is_empty() {
"You were kicked by an admin.".to_owned()
} else {
format!("You were kicked: {reason}")
};
players::disconnect(&mut world.commands(), client, text);
world.flush();
let msg = format!("{actor} kicked {}.", target.label);
notify(world, &msg);
vec![msg]
}
/// Adds (`add`) or removes a value from a string list setting. Returns whether it changed.
fn list_edit(world: &mut World, key: &str, value: &str, add: bool) -> Result<bool, String> {
let config = world.resource::<config::ServerConfig>();
let mut list = match key {
"access.banned" => config.access.banned.clone(),
"access.whitelist" => config.access.whitelist.clone(),
"access.admins" => config.access.admins.clone(),
_ => unreachable!("not a list setting: {key}"),
};
let before = list.len();
if add {
if list.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return Ok(false);
}
list.push(value.to_owned());
} else {
list.retain(|e| !e.eq_ignore_ascii_case(value));
if list.len() == before {
return Ok(false);
}
}
let toml = toml_edit::Value::from_iter(list.iter().map(String::as_str)).to_string();
config::set_runtime(world, key, toml.trim())
.map(|_| true)
.map_err(|e| e.to_string())
}
/// Removes a player from a list by id or by name.
fn unlist(world: &mut World, actor: &str, key: &str, input: &str, what: &str) -> Vec<String> {
let target = resolve(world, input);
let name = players::character_name(world, &target.entry);
let mut changed = false;
for value in std::iter::once(target.entry.as_str())
.chain(name.as_deref())
.chain(std::iter::once(input.trim()))
{
match list_edit(world, key, value, false) {
Ok(c) => changed |= c,
Err(e) => return vec![e],
}
}
if changed {
let msg = format!("{actor}: {} is no longer {what}.", target.label);
notify(world, &msg);
vec![msg]
} else {
vec![format!("{} is not {what}.", target.label)]
}
}
fn show_list(
world: &mut World,
list: impl Fn(&config::ServerConfig) -> &Vec<String>,
title: &str,
) -> Vec<String> {
let entries = list(world.resource::<config::ServerConfig>()).clone();
if entries.is_empty() {
return vec![format!("{title}: none")];
}
let mut reply = vec![format!("{title}:")];
for entry in entries {
match players::character_name(world, &entry) {
Some(name) => reply.push(format!(" {name} ({entry})")),
None => reply.push(format!(" {entry}")),
}
}
reply
}
fn whitelist_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
let target = target.trim();
match (action, target.is_empty()) {
("on" | "off", true) => {
let on = action == "on";
match config::set_runtime(world, "access.whitelist_enabled", &on.to_string()) {
Ok(_) => {
let msg = format!(
"{actor} turned the whitelist {}.",
if on { "on" } else { "off" }
);
notify(world, &msg);
vec![msg]
}
Err(e) => vec![e.to_string()],
}
}
("list", true) => {
let enabled = world
.resource::<config::ServerConfig>()
.access
.whitelist_enabled;
let title = format!("Whitelist ({})", if enabled { "on" } else { "off" });
show_list(world, |c| &c.access.whitelist, &title)
}
("add", false) => {
let target = resolve(world, target);
match list_edit(world, "access.whitelist", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} whitelisted {}.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already whitelisted.", target.label)],
Err(e) => vec![e],
}
}
("remove", false) => unlist(world, actor, "access.whitelist", target, "whitelisted"),
_ => vec!["Usage: whitelist on|off|list | whitelist add|remove PLAYER".into()],
}
}
fn admin_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
let target = target.trim();
match (action, target.is_empty()) {
("list", true) => show_list(world, |c| &c.access.admins, "Admins"),
("add", false) => {
let target = resolve(world, target);
// Admin rights are bound to player ids, since names can be taken
// by anyone before the character exists.
if uuid::Uuid::parse_str(&target.entry).is_err() {
return vec![format!(
"Unknown player `{}`. Use a character name of this world or a player id.",
target.label
)];
}
match list_edit(world, "access.admins", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} made {} an admin.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already an admin.", target.label)],
Err(e) => vec![e],
}
}
("remove", false) => unlist(world, actor, "access.admins", target, "an admin"),
_ => vec!["Usage: admin list | admin add|remove PLAYER".into()],
}
}
fn config_command(world: &mut World, args: &str) -> Vec<String> {
let mut parts = args.splitn(3, ' ');
match (parts.next(), parts.next(), parts.next()) {
(Some("get"), Some(key), None) => match world.resource::<config::ServerConfig>().get(key) {
Ok(value) => vec![format!("{key} = {value}")],
Err(e) => vec![e.to_string()],
},
(Some("set"), Some(key), Some(value)) => match config::set_runtime(world, key, value) {
Ok(note) => {
let value = world
.resource::<config::ServerConfig>()
.get(key)
.unwrap_or_default();
let mut reply = format!("{key} = {value}");
if let Some(note) = note {
reply.push_str(&format!(" ({note})"));
}
vec![reply]
}
Err(e) => vec![e.to_string()],
},
_ => vec!["Usage: config get KEY | config set KEY VALUE".into()],
}
}