Slowly getting more usable

This commit is contained in:
Malte Tammena 2021-12-05 11:35:20 +01:00
parent 3455f323fc
commit cbb93dd2e0
13 changed files with 394 additions and 30 deletions

View file

@ -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 ]; };
};
}

112
glados-module.nix Normal file
View file

@ -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}
'';
};
};
};
}

View file

@ -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> {

View file

@ -22,6 +22,8 @@ pub enum Error {
RconTpsCmd(#[source] nom::Err<nom::error::Error<String>>),
#[error("Parsing results of `list` command: {_0}")]
RconListCmd(#[source] nom::Err<nom::error::Error<String>>),
#[error("Parsing results of `whois` command: {_0}")]
RconWhoIsCmd(#[source] nom::Err<nom::error::Error<String>>),
}
pub trait ResultExt<T> {

View file

@ -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 {

View file

@ -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::<data::Rcon>().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<String, rcon::Error> {
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) {

5
src/rcon/commands.rs Normal file
View file

@ -0,0 +1,5 @@
mod luckperms_info;
mod whois;
pub use luckperms_info::LuckPermsParentOfCmd;
pub use whois::{WhoIs, WhoIsCmd};

View file

@ -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<Self::Output> {
// TODO: Currently broken, lp commands don't work via rcon
Ok(raw)
}
}

View file

@ -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<Self::Output> {
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)
}

View file

@ -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<GuildMember>,
/// Players we've seen on the Minecraft server
pub known_players: HashSet<MinecraftPlayer>,
}
#[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<FileDatabase<Self, Yaml>> {
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<Self> {
// 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<UserId> for GuildMember {
fn from(id: UserId) -> Self {
Self { id }
@ -86,10 +125,30 @@ impl PartialEq for GuildMember {
}
}
impl Eq for GuildMember {}
impl Borrow<UserId> for GuildMember {
fn borrow(&self) -> &UserId {
&self.id
}
}
impl Eq for GuildMember {}
impl Hash for MinecraftPlayer {
fn hash<H: std::hash::Hasher>(&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<String> for MinecraftPlayer {
fn borrow(&self) -> &String {
&self.uuid
}
}

View file

@ -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")
{

View file

@ -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<MinecraftPlayer, bool>,
}
impl Status {
pub async fn get_from_rcon(ctx: &Context) -> Result<Self> {
pub async fn assemble(ctx: &Context) -> Result<Self> {
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
}

View file

@ -14,13 +14,11 @@ use crate::{
pub struct ListCmd;
#[derive(Debug)]
pub struct PlayerInfo {
pub amount_online: u32,
pub players: Vec<Player>,
}
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)
}