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