diff --git a/flake.nix b/flake.nix index 819cb66..d7a7738 100644 --- a/flake.nix +++ b/flake.nix @@ -3,5 +3,8 @@ nixCargoIntegration.url = "github:yusdacra/nix-cargo-integration"; }; - outputs = inputs: inputs.nixCargoIntegration.lib.makeOutputs { root = ./.; }; + outputs = inputs: + (inputs.nixCargoIntegration.lib.makeOutputs { root = ./.; }) // { + nixosModules.glados = { imports = [ ./glados-module.nix ]; }; + }; } diff --git a/glados-module.nix b/glados-module.nix new file mode 100644 index 0000000..999d6d0 --- /dev/null +++ b/glados-module.nix @@ -0,0 +1,112 @@ +{ pkgs, lib, config, ... }: + +let + cfg = config.services.glados; + ownerArgs = list: toString (map (id: "--owner ${id}") list); +in { + options.services.glados = { + enable = lib.mkEnableOption "GLaDOS systemd service"; + + discord = { + guildId = lib.mkOption { + type = lib.types.str; + description = "Discord guild ID"; + example = "000000000000000000"; + }; + + ownerIds = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = "Discord guild owner IDs"; + example = "[ 000000000000000000 ]"; + }; + + adminChannel = lib.mkOption { + type = lib.types.str; + description = "Discord Admin channel ID"; + example = "000000000000000000"; + }; + + infoChannel = lib.mkOption { + type = lib.types.str; + description = "Discord Info channel ID"; + example = "000000000000000000"; + }; + + botToken = lib.mkOption { + type = lib.types.str; + description = "Discord bot token"; + example = "AAAAAAAAAAAAAAAAAAAAAAAA.AAAAAA.AAAAAAAAAAAAAAAAAAA-AAAAAAA"; + }; + + camRole = lib.mkOption { + type = lib.types.str; + description = "Role used for Minecraft cam accounts"; + example = "000000000000000000"; + }; + }; + + influx = { + host = lib.mkOption { + type = lib.types.str; + description = "Influx db host url"; + example = "localhost:8000"; + }; + + database = lib.mkOption { + type = lib.types.str; + description = "Influx database to store time series in"; + example = "minecraft"; + }; + + user = lib.mkOption { + type = lib.types.str; + description = "Influx database user"; + example = "minecraft"; + }; + + password = lib.mkOption { + type = lib.types.str; + description = "Influx database user password"; + example = "minecraft"; + }; + }; + + rcon = { + address = lib.mkOption { + type = lib.types.str; + description = "RCON address"; + example = "your-host.com:12345"; + }; + + password = lib.mkOption { + type = lib.types.str; + description = "RCON password"; + example = "your-secret-password"; + }; + }; + + }; + + config = mkIf cfg.enable { + systemd.services.glados = { + wantedBy = [ "default.target" ]; + serviceConfig = { + ExecStart = '' + ${pkgs.glados}/bin/glados + --guild-id ${cfg.discord.guildId} + ${ownerArgs cfg.discord.ownerIds} + --admin-channel ${cfg.discord.adminChannel} + --info-channel ${cfg.discord.infoChannel} + --discord-token ${cfg.discord.botToken} + --cam-group-name ${cfg.discord.camRole} + --influx-host ${cfg.influx.host} + --influx-db ${cfg.influx.database} + --influx-user ${cfg.influx.user} + --influx-password ${cfg.influx.password} + --rcon-address ${cfg.rcon.address} + --rcon-password ${cfg.rcon.password} + ''; + }; + }; + }; +} diff --git a/src/conf.rs b/src/conf.rs index 3a46562..1c04381 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -77,6 +77,9 @@ pub struct Args { #[structopt(long, value_name = "PW", env = "INFLUX_PW", hide_env_values = true)] pub influx_password: String, + + #[structopt(long, value_name = "NAME", env = "MC_CAM_GROUP")] + pub cam_group_name: String, } fn validate_token(raw: String) -> std::result::Result<(), String> { diff --git a/src/error.rs b/src/error.rs index 0dc4efb..3938c2b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,6 +22,8 @@ pub enum Error { RconTpsCmd(#[source] nom::Err>), #[error("Parsing results of `list` command: {_0}")] RconListCmd(#[source] nom::Err>), + #[error("Parsing results of `whois` command: {_0}")] + RconWhoIsCmd(#[source] nom::Err>), } pub trait ResultExt { diff --git a/src/main.rs b/src/main.rs index 7f37377..c0b3148 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,8 @@ mod tracking; use discord::Bot; use error::Result; +type UUID<'s> = &'s str; + #[tokio::main(flavor = "current_thread")] async fn main() { match real_main().await { diff --git a/src/rcon.rs b/src/rcon.rs index c1e49cf..98f1476 100644 --- a/src/rcon.rs +++ b/src/rcon.rs @@ -2,6 +2,10 @@ use std::time::Duration; use serenity::client::Context; +mod commands; + +pub use commands::*; + use crate::{ conf::ARGS, data, @@ -29,19 +33,41 @@ impl RconHandle { where C: RconCommand, { + let cmd_str = cmd.as_string(); let mut rcon = ctx.data.write().await; let rcon = rcon.get_mut::().expect("BUG: No rcon client"); rcon.reconnect_until_success().await; - let cmd_str = cmd.as_string(); - let raw_output = rcon - .inner + let result = rcon.execute_raw_command(&cmd_str).await; + let raw_output = match result { + Ok(raw_output) => raw_output, + Err(why) => { + match why { + rcon::Error::Io(_) => { + // Reset the connection and retry + tracing::warn!("IO error while executing RCON command, retrying"); + rcon.inner = None; + rcon.reconnect_until_success().await; + rcon.execute_raw_command(&cmd_str) + .await + .map_err(|why| Error::RconCommand(cmd_str, why))? + } + why => return Err(Error::RconCommand(cmd_str, why)), + } + } + }; + cmd.parse(raw_output) + } + + async fn execute_raw_command( + &mut self, + cmd: &str, + ) -> ::std::result::Result { + self.inner .as_mut() // We must be connected here .unwrap() - .cmd(&cmd_str) + .cmd(&cmd) .await - .map_err(|why| Error::RconCommand(cmd_str, why))?; - cmd.parse(raw_output) } async fn reconnect_until_success(&mut self) { diff --git a/src/rcon/commands.rs b/src/rcon/commands.rs new file mode 100644 index 0000000..97168e2 --- /dev/null +++ b/src/rcon/commands.rs @@ -0,0 +1,5 @@ +mod luckperms_info; +mod whois; + +pub use luckperms_info::LuckPermsParentOfCmd; +pub use whois::{WhoIs, WhoIsCmd}; diff --git a/src/rcon/commands/luckperms_info.rs b/src/rcon/commands/luckperms_info.rs new file mode 100644 index 0000000..a3ef440 --- /dev/null +++ b/src/rcon/commands/luckperms_info.rs @@ -0,0 +1,18 @@ +use crate::{error::Result, rcon::RconCommand, UUID}; + +type ParentGroup = String; + +pub struct LuckPermsParentOfCmd<'s>(pub UUID<'s>); + +impl RconCommand for LuckPermsParentOfCmd<'_> { + type Output = ParentGroup; + + fn as_string(&self) -> String { + format!("lp user {} parent info", self.0) + } + + fn parse(&self, raw: String) -> Result { + // TODO: Currently broken, lp commands don't work via rcon + Ok(raw) + } +} diff --git a/src/rcon/commands/whois.rs b/src/rcon/commands/whois.rs new file mode 100644 index 0000000..6b1ade5 --- /dev/null +++ b/src/rcon/commands/whois.rs @@ -0,0 +1,99 @@ +use nom::{ + branch::alt, + bytes::complete::{is_not, tag}, + combinator::{map, value}, + sequence::{preceded, tuple}, + IResult, Parser, +}; + +use crate::{ + error::{Error, Result}, + rcon::RconCommand, +}; + +pub struct WhoIsCmd<'s>(pub &'s str); + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct WhoIs { + pub nick: String, + pub uuid: String, + pub afk: bool, +} + +impl RconCommand for WhoIsCmd<'_> { + type Output = WhoIs; + + fn as_string(&self) -> String { + format!("whois {}", self.0) + } + + fn parse(&self, raw: String) -> Result { + parse_whois_result(&raw) + .map(|(_, whois)| whois) + .map_err(|why| Error::RconWhoIsCmd(why.to_owned())) + } +} + +// §6 ====== WhoIs:§c gloom_17 §6======\n§6 - Nick:§r gloom_17\n§6 - UUID:§r 1fea9fa1-7ee0-4c5f-b4d8-97185709cf33\n§6 - Health:§r 18.56/20\n§6 - Hunger:§r 16/20 (+0 saturation)\n§6 - Exp:§r 1,545 (Level 31)\n§6 - Location:§r (world, 443, 64, 73)\n§6 - Playtime:§r 2 days 2 hours 32 minutes\n§6 - Money:§r $0\n§6 - IP Address:§r /213.142.97.187\n§6 - Gamemode:§r survival\n§6 - God mode:§r §4false§r\n§6 - OP:§r §4false§r\n§6 - Fly mode:§r §4false§r (not flying)\n§6 - Speed:§r 0.2\n§6 - AFK:§r §4false§r\n§6 - Jail:§r §4false§r\n§6 - Muted:§r §4false§r\n + +pub fn parse_whois_result(inp: &str) -> IResult<&str, WhoIs> { + let list = tuple(( + prop("Nick", parse_string), + prop("UUID", parse_string), + prop("Health", ignore), + prop("Hunger", ignore), + prop("Exp", ignore), + prop("Location", ignore), + prop("Playtime", ignore), + prop("Money", ignore), + prop("IP Address", ignore), + prop("Gamemode", ignore), + prop("God mode", ignore), + prop("OP", ignore), + prop("Fly mode", ignore), + prop("Speed", ignore), + prop("AFK", parse_bool), + prop("Jail", ignore), + prop("Muted", ignore), + )); + let parser = preceded(header, list); + map( + parser, + |(nick, uuid, (), (), (), (), (), (), (), (), (), (), (), (), afk, (), ())| WhoIs { + nick, + uuid, + afk, + }, + )(inp) +} + +pub fn header(inp: &str) -> IResult<&str, ()> { + value( + (), + tuple((tag("§6 ====== WhoIs:§c "), is_not(" "), tag(" §6======\n"))), + )(inp) +} + +pub fn prop<'i, P, O, E>(name: &'i str, parser: P) -> impl Parser<&'i str, O, E> +where + P: Parser<&'i str, O, E>, + E: nom::error::ParseError<&'i str>, +{ + map( + tuple((tag("§6 - "), tag(name), tag(":§r "), parser, tag("\n"))), + |(_, _, _, res, _)| res, + ) +} + +pub fn parse_string(inp: &str) -> IResult<&str, String> { + map(is_not("\n"), |s: &str| s.to_owned())(inp) +} + +pub fn ignore(inp: &str) -> IResult<&str, ()> { + value((), is_not("\n"))(inp) +} + +pub fn parse_bool(inp: &str) -> IResult<&str, bool> { + alt((value(true, tag("§4true§r")), value(false, tag("§4false§r"))))(inp) +} diff --git a/src/state.rs b/src/state.rs index 6097dd7..b40aaa8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,12 +8,15 @@ use crate::{ conf::ARGS, data, error::{Error, Result, ResultExt}, + rcon::{LuckPermsParentOfCmd, RconHandle, WhoIsCmd}, }; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct State { /// Keeps track of the current guild members pub guild_members: HashSet, + /// Players we've seen on the Minecraft server + pub known_players: HashSet, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -22,6 +25,16 @@ pub struct GuildMember { pub id: UserId, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinecraftPlayer { + /// Unique identifier + pub uuid: String, + /// Current ingame name + pub ign: String, + /// Is this account a cam account + pub is_cam_acc: bool, +} + impl State { pub async fn load() -> Result> { let db = FileDatabase::load_from_path_or_default(&ARGS.database_path)?; @@ -68,6 +81,32 @@ impl State { } } +impl MinecraftPlayer { + pub async fn from_uuid(ctx: &Context, uuid: String) -> Result { + // Try fetching the player from our state + let player = State::read(ctx, |state| state.known_players.get(&uuid).cloned()).await?; + // If player is none, query the rcon for missing information + let player = match player { + Some(player) => player, + None => { + let whois = RconHandle::cmd(&WhoIsCmd(&uuid), ctx).await?; + let parent_group = RconHandle::cmd(&LuckPermsParentOfCmd(&uuid), ctx).await?; + let player = MinecraftPlayer { + uuid: whois.uuid, + ign: whois.nick, + is_cam_acc: parent_group == ARGS.cam_group_name, + }; + State::write(ctx, |state| state.known_players.insert(player.clone())) + .await + .log_warn("adding player to set of known players"); + player + } + }; + + Ok(player) + } +} + impl From for GuildMember { fn from(id: UserId) -> Self { Self { id } @@ -86,10 +125,30 @@ impl PartialEq for GuildMember { } } -impl Eq for GuildMember {} - impl Borrow for GuildMember { fn borrow(&self) -> &UserId { &self.id } } + +impl Eq for GuildMember {} + +impl Hash for MinecraftPlayer { + fn hash(&self, state: &mut H) { + self.uuid.hash(state); + } +} + +impl PartialEq for MinecraftPlayer { + fn eq(&self, other: &Self) -> bool { + self.uuid.eq(&other.uuid) + } +} + +impl Eq for MinecraftPlayer {} + +impl Borrow for MinecraftPlayer { + fn borrow(&self) -> &String { + &self.uuid + } +} diff --git a/src/tracking.rs b/src/tracking.rs index 36b9e21..7aa8917 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -12,7 +12,7 @@ pub async fn track_server_status(ctx: Context) { loop { tracing::info!("Collecting status information"); // Fetch status via rcon and if successful, push it to the influx db - if let Some(status) = Status::get_from_rcon(&ctx) + if let Some(status) = Status::assemble(&ctx) .await .log_warn("fetching status via rcon") { diff --git a/src/tracking/status.rs b/src/tracking/status.rs index dbcbc92..8a6b3e9 100644 --- a/src/tracking/status.rs +++ b/src/tracking/status.rs @@ -1,10 +1,17 @@ use influx_db_client::Point; use serenity::client::Context; +use std::collections::HashMap; + mod player_info; mod tps; -use crate::{error::Result, influx::InfluxDb, rcon::RconHandle}; +use crate::{ + error::Result, + influx::InfluxDb, + rcon::RconHandle, + state::{MinecraftPlayer, State}, +}; use self::{ player_info::{ListCmd, PlayerInfo}, @@ -13,14 +20,37 @@ use self::{ pub struct Status { pub tps: Tps, - pub player_info: PlayerInfo, + pub amount_online: u32, + /// List of players associated with their online status. + pub players: HashMap, } impl Status { - pub async fn get_from_rcon(ctx: &Context) -> Result { + pub async fn assemble(ctx: &Context) -> Result { let tps = RconHandle::cmd(&TpsCmd, ctx).await?; - let player_info = RconHandle::cmd(&ListCmd, ctx).await?; - Ok(Status { tps, player_info }) + let PlayerInfo { + amount_online, + players: online_players, + } = RconHandle::cmd(&ListCmd, ctx).await?; + // Fetch all known offline players aswell + let mut players: HashMap<_, _> = State::read(ctx, |state| { + state + .known_players + .clone() + .into_iter() + .map(|player| (player, false)) + .collect() + }) + .await?; + for (_ign, uuid) in online_players { + players.insert(MinecraftPlayer::from_uuid(ctx, uuid).await?, true); + } + + Ok(Status { + tps, + amount_online, + players, + }) } pub async fn send_to_influx_db(&self, ctx: &Context) -> Result { @@ -28,12 +58,16 @@ impl Status { let status = Point::new("status") .add_field("tps", self.tps.min1) // Safe, since we started with u32 - .add_field("player_count", self.player_info.amount_online as i64); + .add_field("player_count", self.amount_online as i64); points.push(status); - for player in &self.player_info.players { - let point = Point::new("activity").add_tag("user_id", player.uuid.clone()); - points.push(point); + let mut activity = Point::new("activity"); + for (player, is_online) in &self.players { + // Map is_online to {0, 1} as usual, to make grafana more happy + let is_online = *is_online as i64; + let name = format!("{} ({})", player.ign.clone(), player.uuid.clone()); + activity = activity.add_field(name, is_online); } + points.push(activity); InfluxDb::write_points(points.into_iter(), ctx).await } diff --git a/src/tracking/status/player_info.rs b/src/tracking/status/player_info.rs index 62c9833..d05a688 100644 --- a/src/tracking/status/player_info.rs +++ b/src/tracking/status/player_info.rs @@ -14,13 +14,11 @@ use crate::{ pub struct ListCmd; +#[derive(Debug)] pub struct PlayerInfo { pub amount_online: u32, - pub players: Vec, -} - -pub struct Player { - pub uuid: String, + /// (IGN, UUID) + pub players: Vec<(String, String)>, } impl RconCommand for ListCmd { @@ -49,7 +47,7 @@ fn parse_list_result(inp: &str) -> IResult<&str, PlayerInfo> { tag(" of a max of "), complete::u32, tag(" players online: "), - separated_list0(tag(", "), parse_player), + separated_list0(tag(", "), parse_players), )), |(_, num, _, _max, _, players)| PlayerInfo { amount_online: num, @@ -58,11 +56,14 @@ fn parse_list_result(inp: &str) -> IResult<&str, PlayerInfo> { ))(inp) } -fn parse_player(inp: &str) -> IResult<&str, Player> { +/// Parse `"IGN (0000-...-0000)"` into `("IGN", "0000-...-0000")`. +fn parse_players(inp: &str) -> IResult<&str, (String, String)> { map( - tuple((is_not(" "), delimited(char('('), is_not(")"), char(')')))), - |(_name, uuid): (&str, &str)| Player { - uuid: uuid.to_owned(), - }, + tuple(( + is_not(" "), + char(' '), + delimited(char('('), is_not(")"), char(')')), + )), + |(name, _, uuid): (&str, _, &str)| (name.to_owned(), uuid.to_owned()), )(inp) }