507 lines
17 KiB
Rust
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()],
|
|
}
|
|
}
|