From 72d0c68b2090712a6a7aed2b415311216290ff5f Mon Sep 17 00:00:00 2001 From: atoav <dh@atoav.com> Date: Tue, 28 Apr 2020 14:38:25 +0200 Subject: [PATCH] Allow multiple endpoints in user configuration --- README.md | 19 +++- bbbmon/bbbmon.py | 197 ++++++++++++++-------------------------- bbbmon/configuration.py | 92 +++++++++++++++++++ bbbmon/xmldict.py | 68 ++++++++++++++ pyproject.toml | 2 +- 5 files changed, 245 insertions(+), 133 deletions(-) create mode 100644 bbbmon/configuration.py create mode 100644 bbbmon/xmldict.py diff --git a/README.md b/README.md index 0e3e03d..5a4b197 100644 --- a/README.md +++ b/README.md @@ -39,5 +39,22 @@ Run bbbmon with: poetry run ``` -For bbbmon to run you need to have a `bbbmon.properties` file at the path specified. In this file there should be your servers secret and the server URL. You can find this secret on your server in the file `/usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties` (look for a line starting with `securitySalt=` and copy it to). If in doubt just follow the instructions the CLI gives you. + +# Configuration + +Just run `bbbmon` and it will tell you what it needs. + +You can define one or more endpoints in the `bbbmon.properties` file at the path bbbmon tells you about. In this file you can specify multiple bbb servers each with it's secret and bigbluebutton-URL. You can find the secret on your server in it's config-file via `cat /usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties | grep securitySalt=` + +A example configuration file could look like this: +```toml +[bbb.example.com] +securitySalt=MY_SUPER_SECRET_SECRET +bigbluebutton.web.serverURL=https://bbb.example.com/ + +[Foo's private bbb-server] +securitySalt=MY_SUPER_SECRET_SECRET2 +bigbluebutton.web.serverURL=https://bbb.foo.com/ +``` +The section names in the square brackets will be used as display names (these support utf-8) \ No newline at end of file diff --git a/bbbmon/bbbmon.py b/bbbmon/bbbmon.py index 56fe7e7..64ab8d7 100755 --- a/bbbmon/bbbmon.py +++ b/bbbmon/bbbmon.py @@ -1,22 +1,18 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- - import os import hashlib from datetime import datetime, timedelta import requests import appdirs from xml.etree import cElementTree as ElementTree -from typing import NewType, Optional, Tuple, Iterable - +from typing import NewType, Optional, Tuple, Iterable, List +# Local module imports +from bbbmon.xmldict import XmlListConfig, XmlDictConfig +from bbbmon.configuration import Config, Endpoint, SERVER_PROPERTIES_FILE, Url, Secret -# Default path -SERVER_PROPERTIES_FILE = "/usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties" -# Type definitions -Secret = NewType('Secret', str) -Url = NewType('Url', str) FRIENDLY_KEYNAMES = { "participantCount": "Participants", @@ -27,73 +23,6 @@ FRIENDLY_KEYNAMES = { } - -class XmlListConfig(list): - """ - Helper class to convert XML to python dicts - """ - def __init__(self, aList): - for element in aList: - if element: - # treat like dict - if len(element) == 1 or element[0].tag != element[1].tag: - self.append(XmlDictConfig(element)) - # treat like list - elif element[0].tag == element[1].tag: - self.append(XmlListConfig(element)) - elif element.text: - text = element.text.strip() - if text: - self.append(text) - - -class XmlDictConfig(dict): - ''' - Example usage: - - >>> tree = ElementTree.parse('your_file.xml') - >>> root = tree.getroot() - >>> xmldict = XmlDictConfig(root) - - Or, if you want to use an XML string: - - >>> root = ElementTree.XML(xml_string) - >>> xmldict = XmlDictConfig(root) - - And then use xmldict for what it is... a dict. - ''' - def __init__(self, parent_element): - if parent_element.items(): - self.update(dict(parent_element.items())) - for element in parent_element: - if element: - # treat like dict - we assume that if the first two tags - # in a series are different, then they are all different. - if len(element) == 1 or element[0].tag != element[1].tag: - aDict = XmlDictConfig(element) - # treat like list - we assume that if the first two tags - # in a series are the same, then the rest are the same. - else: - # here, we put the list in dictionary; the key is the - # tag name the list elements all share in common, and - # the value is the list itself - aDict = {element[0].tag: XmlListConfig(element)} - # if the tag has attributes, add those to the dict - if element.items(): - aDict.update(dict(element.items())) - self.update({element.tag: aDict}) - # this assumes that if you've got an attribute in a tag, - # you won't be having any text. This may or may not be a - # good idea -- time will tell. It works for the way we are - # currently doing XML configuration files... - elif element.items(): - self.update({element.tag: dict(element.items())}) - # finally, if there are no child tags and no attributes, extract - # the text - else: - self.update({element.tag: element.text}) - - def generate_checksum(call_name: str, query_string: str, secret: Secret) -> str: """ Generate Checksum for the request header (passed as value for `?checksum=`) @@ -134,8 +63,7 @@ def get_meetings(secret: Secret, bbb_url: Url) -> Iterable[XmlDictConfig]: d = request_meetings(secret, bbb_url) if d["meetings"] is None: - print("There are no active meetings currently.") - exit() + return [] if type(d["meetings"]["meeting"]) is XmlListConfig: meetings = sorted([m for m in d["meetings"]["meeting"] if m["running"] == "true"], key=lambda x:int(x['participantCount']), reverse=True) @@ -181,6 +109,9 @@ def strfdelta(duration: timedelta) -> str: def format_duration(meeting: XmlDictConfig) -> str: + """ + Helper functions for duration + """ duration = get_duration(meeting) return strfdelta(duration) @@ -220,48 +151,55 @@ def print_duration_leaderboard(meetings: Iterable[XmlDictConfig]): print("{:>12} {:<45} {}".format(format_duration(m), m["meetingName"], get_formated_presenter_name(m))) -def print_overview(secret: Secret, bbb_url: Url): +def print_overview(config: Config): """ - Get the meetings and print out an overview of the current bbb-usage + For each endpoint in the configuration get the active meetings and print + out an overview of the current bbb-usage """ - meetings = get_meetings(secret, bbb_url) + for i, endpoint in enumerate(config.endpoints): + meetings = get_meetings(endpoint.secret, endpoint.url) - if len(meetings) == 0: - print("There are no meetings running now.") - exit() + # Print divider if there is more than one endpoint + if i > 0: + print() + print("="*80) + print() + + # If there are no meetings, skip to next endpoint + if len(meetings) == 0: + print("MEETINGS on [{}]: None".format(endpoint.name)) + continue - n_running = len(meetings) - n_recording = len([m for m in meetings if m["recording"] == "true"]) - n_participants = sum([int(m["participantCount"]) for m in meetings]) - n_listeners = sum([int(m["listenerCount"]) for m in meetings]) - n_voice = sum([int(m["voiceParticipantCount"]) for m in meetings]) - n_video = sum([int(m["videoCount"]) for m in meetings]) - n_moderator = sum([int(m["moderatorCount"]) for m in meetings]) - - print("MEETINGS on {}:".format(bbb_url)) - print(" ├─── {:>4} running".format(n_running)) - print(" └─── {:>4} recording".format(n_recording)) - print() - print("PARTICIPANTS across all {} rooms".format(n_running)) - print(" └─┬─ {:>4} total".format(n_participants)) - print(" ├─ {:>4} listening only".format(n_listeners)) - print(" ├─ {:>4} mic on".format(n_voice)) - print(" ├─ {:>4} video on".format(n_video)) - print(" └─ {:>4} moderators".format(n_moderator)) - - print() - print_leaderboard(meetings, "participantCount") - print() - print_leaderboard(meetings, "videoCount") - print() - print_leaderboard(meetings, "voiceParticipantCount") - print() - print_duration_leaderboard(meetings) - - - - -def init_variables() -> Optional[Tuple[Secret, Url]]: + n_running = len(meetings) + n_recording = len([m for m in meetings if m["recording"] == "true"]) + n_participants = sum([int(m["participantCount"]) for m in meetings]) + n_listeners = sum([int(m["listenerCount"]) for m in meetings]) + n_voice = sum([int(m["voiceParticipantCount"]) for m in meetings]) + n_video = sum([int(m["videoCount"]) for m in meetings]) + n_moderator = sum([int(m["moderatorCount"]) for m in meetings]) + + print("MEETINGS on [{}]:".format(endpoint.name)) + print(" ├─── {:>4} running".format(n_running)) + print(" └─── {:>4} recording".format(n_recording)) + print() + print("PARTICIPANTS across all {} rooms".format(n_running)) + print(" └─┬─ {:>4} total".format(n_participants)) + print(" ├─ {:>4} listening only".format(n_listeners)) + print(" ├─ {:>4} mic on".format(n_voice)) + print(" ├─ {:>4} video on".format(n_video)) + print(" └─ {:>4} moderators".format(n_moderator)) + + print() + print_leaderboard(meetings, "participantCount") + print() + print_leaderboard(meetings, "videoCount") + print() + print_leaderboard(meetings, "voiceParticipantCount") + print() + print_duration_leaderboard(meetings) + + +def init_config() -> Optional[Config]: """ Read the config either from the servers bigbluebutton.properties-file or from the user config path. Display a message if neither of these files exist. @@ -272,31 +210,28 @@ def init_variables() -> Optional[Tuple[Secret, Url]]: # Check if we are on the server and try to read that properties file first if os.path.isfile(SERVER_PROPERTIES_FILE): - with open(SERVER_PROPERTIES_FILE, "r") as f: - lines = [l for l in f.readlines()] - secret = Secret([l for l in lines if l.startswith("securitySalt=")][0].replace("securitySalt=", "")).strip() - bbb_url = Url([l for l in lines if l.startswith("bigbluebutton.web.serverURL=")][0].replace("bigbluebutton.web.serverURL=", "")).strip() - bbb_url = "{}/bigbluebutton".format(bbb_url.rstrip('/')) - return (secret, bbb_url) + return Config().from_server() elif os.path.isfile(user_config_path): - with open(user_config_path, "r") as f: - lines = [l for l in f.readlines()] - secret = Secret([l for l in lines if l.startswith("securitySalt=")][0].replace("securitySalt=", "")).strip() - bbb_url = Url([l for l in lines if l.startswith("bigbluebutton.web.serverURL=")][0].replace("bigbluebutton.web.serverURL=", "")).strip() - bbb_url = "{}/bigbluebutton".format(bbb_url.rstrip('/')) - return (secret, bbb_url) + return Config().from_config(user_config_path) else: print("ERROR: There was no config file found. Make sure it exists and is readable:") print("[0] {}".format(SERVER_PROPERTIES_FILE)) print("[1] {}".format(user_config_path)) print() - print("For now the file just needs to contain two lines:") + print("For now the file just needs to contain three lines:") + print("[myservername]") print("securitySalt=YOURSUPERSECRETSECRET") print("bigbluebutton.web.serverURL=https://bbb.example.com/") + print() + print("(You can define multiple server-blocks however)") exit() def main(): - secret, bbb_url = init_variables() - print_overview(secret, bbb_url) \ No newline at end of file + Config = init_config() + print_overview(Config) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/bbbmon/configuration.py b/bbbmon/configuration.py new file mode 100644 index 0000000..0b0d725 --- /dev/null +++ b/bbbmon/configuration.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import configparser +from typing import NewType, Optional, Tuple, Iterable, List + +# Default path +SERVER_PROPERTIES_FILE = "/usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties" + + +# Type definitions +Secret = NewType('Secret', str) +Url = NewType('Url', str) + + +class Config(): + """ + Holds the Server Configurations for multiple endpoints + """ + def __init__(self): + self.endpoints = [] + + def from_server(self, path: str=SERVER_PROPERTIES_FILE) -> 'Config': + """ + If bbbmon is executed on the server, it uses this method to extract the + Url (bigbluebutton.web.serverURL) and the Secret (securitySalt) from the + server. Additionally this method is used as a legacy fallback for user + configuration files that are not a valid ini with [section headers] + """ + with open(path, "r") as f: + lines = [l for l in f.readlines()] + secret = Secret([l for l in lines if l.startswith("securitySalt=")][0].replace("securitySalt=", "")).strip() + bbb_url = Url([l for l in lines if l.startswith("bigbluebutton.web.serverURL=")][0].replace("bigbluebutton.web.serverURL=", "")).strip() + bbb_url = "{}/bigbluebutton".format(bbb_url.rstrip('/')) + endpoint = Endpoint(url=bbb_url, secret=secret) + self.endpoints.append(endpoint) + return self + + def from_config(self, path: str) -> 'Config': + """ + Read config from a given path. If the file has no section headers, try + to use the .from_server(path) method instead + """ + config = configparser.ConfigParser() + try: + config.read(path, encoding='utf-8') + for section in config.sections(): + bbb_url = Url(config[section]["bigbluebutton.web.serverURL"]) + bbb_url = "{}/bigbluebutton".format(bbb_url.rstrip('/')) + secret = Secret(config[section]["securitySalt"]).strip() + endpoint = Endpoint(url=bbb_url, secret=secret, name=section) + self.endpoints.append(endpoint) + return self + # Fallback for config files without sections + except configparser.MissingSectionHeaderError: + self = self.from_server(path) + return self + + def __len__(self): + """ + The length of a Config is represented by the number of endpoints + """ + return len(self.endpoints) + + def __str__(self): + """ + Allow a Config to be represented by a string quickly + """ + l = ["Config"] + for e in ["Endpoint[{}]: {}, SECRET OMITTED".format(e.name, e.url) for e in self.endpoints]: + l.append(e) + return "\n".join(l) + + + + +class Endpoint(): + """ + Objects of this class represent a single endpoint which runs a bigbluebutton + instance. The relevant fields are the url and the secret, the name is either + extracted from the section header of the user configuration file, or – as a + fallback – from the URL + """ + def __init__(self, url: Url, secret: Secret, name: str=None): + self.url = url + self.secret = secret + if name is None: + self.name = url.lower()\ + .lstrip("http://")\ + .lstrip("https://")\ + .rstrip("/bigbluebutton") + else: + self.name = name \ No newline at end of file diff --git a/bbbmon/xmldict.py b/bbbmon/xmldict.py new file mode 100644 index 0000000..e75b488 --- /dev/null +++ b/bbbmon/xmldict.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +class XmlListConfig(list): + """ + Helper class to convert XML to python dicts + """ + def __init__(self, aList): + for element in aList: + if element: + # treat like dict + if len(element) == 1 or element[0].tag != element[1].tag: + self.append(XmlDictConfig(element)) + # treat like list + elif element[0].tag == element[1].tag: + self.append(XmlListConfig(element)) + elif element.text: + text = element.text.strip() + if text: + self.append(text) + + +class XmlDictConfig(dict): + ''' + Example usage: + + >>> tree = ElementTree.parse('your_file.xml') + >>> root = tree.getroot() + >>> xmldict = XmlDictConfig(root) + + Or, if you want to use an XML string: + + >>> root = ElementTree.XML(xml_string) + >>> xmldict = XmlDictConfig(root) + + And then use xmldict for what it is... a dict. + ''' + def __init__(self, parent_element): + if parent_element.items(): + self.update(dict(parent_element.items())) + for element in parent_element: + if element: + # treat like dict - we assume that if the first two tags + # in a series are different, then they are all different. + if len(element) == 1 or element[0].tag != element[1].tag: + aDict = XmlDictConfig(element) + # treat like list - we assume that if the first two tags + # in a series are the same, then the rest are the same. + else: + # here, we put the list in dictionary; the key is the + # tag name the list elements all share in common, and + # the value is the list itself + aDict = {element[0].tag: XmlListConfig(element)} + # if the tag has attributes, add those to the dict + if element.items(): + aDict.update(dict(element.items())) + self.update({element.tag: aDict}) + # this assumes that if you've got an attribute in a tag, + # you won't be having any text. This may or may not be a + # good idea -- time will tell. It works for the way we are + # currently doing XML configuration files... + elif element.items(): + self.update({element.tag: dict(element.items())}) + # finally, if there are no child tags and no attributes, extract + # the text + else: + self.update({element.tag: element.text}) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 47c4815..aa5ea76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bbbmon" -version = "0.1.5" +version = "0.1.6" description = "A small CLI utility to monitor bbb usage" authors = ["David Huss <david.huss@hfbk-hamburg.de>"] maintainers = ["David Huss <david.huss@hfbk-hamburg.de>"] -- GitLab