diff --git a/mumble/TODO b/mumble/TODO new file mode 100644 index 0000000..d84f4b7 --- /dev/null +++ b/mumble/TODO @@ -0,0 +1,2 @@ +-add lsChans() +-lsACL? lsBans? edit these? \ No newline at end of file diff --git a/mumble/usermgmt.py b/mumble/usermgmt.py new file mode 100755 index 0000000..0de1986 --- /dev/null +++ b/mumble/usermgmt.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 + +import argparse +import getpass +import hashlib +import os +import pprint +import sqlite3 +import sys + +class Manager(object): + def __init__(self, args): + self.args = args + self.conn = self.connect() + self.conn.row_factory = sqlite3.Row + if 'interactive' not in self.args.keys(): + self.args['interactive'] = False + # Key mappings/types in user_info table; thanks to DireFog in Mumble's IRC channel for help with this. + # src/murmur/ServerDB.h, enum UserInfo + # 0 = User_Name + # 1 = User_Email + # 2 = User_Comment + # 3 = User_Hash + # 4 = User_Password + # 5 = User_LastActive + # 6 = User_KDFIterations + self.infomap = {0: 'name', + 1: 'email', + 2: 'comment', + 3: 'certhash', + 4: 'password', + 5: 'last_active', + 6: 'kdf_iterations'} + + def connect(self): + if not os.path.isfile(self.args['database']): + raise FileNotFoundError('{0} does not exist! Check your path or create the initial databse by running murmurd.') + conn = sqlite3.connect(self.args['database']) + return(conn) + + def add(self): + # SQLDO("INSERT INTO `%1users`... in src/murmur/ServerDB.cpp + if not (self.args['certhash'] or self.args['password']): + raise RuntimeError('You must specify either a certificate hash or a method for getting the password.') + if self.args['certhash']: # it's a certificate fingerprint hash + _e = '{0} is not a valid certificate fingerprint hash.'.format(self.args['certhash']) + try: + # Try *really hard* to mahe sure it's a SHA1. + # SHA1s are 160 bits in length, in hex (the string representations are + # 40 chars). However, we use 162 because of the prefix python3 adds + # automatically: "0b". + h = int(self.args['certhash'], 16) + try: + assert len(bin(h)) == 162 + except AssertionError: + raise ValueError(_e) + except (ValueError, TypeError): + raise ValueError(_e) + if self.args['password']: # it's a password + if self.args['password'] == 'stdin': + self.args['password'] = hashlib.sha1(sys.stdin.read().replace('\n', '').encode('utf-8')).hexdigest().lower() + else: + _repeat = True + while _repeat == True: + _pass_in = getpass.getpass('What password should {0} have (will not echo back)? ') + if not _pass_in or _pass_in == '': + print('Invalid password. Please re-enter: ') + else: + self.args['password'] = hashlib.sha1(_pass_in.replace('\n', '').encode('utf-8')).hexdigest().lower() + # Insert into the "users" table + # I spit on the Mumble developers for not using https://sqlite.org/autoinc.html. + # Warning: this is kind of dangerous, as you can hit a race condition here. + _cur = self.conn.cursor() + _cur.execute("SELECT user_id FROM users WHERE server_id = '{0}'".format(self.args['server'])) + _used_ids = [i[0] for i in _cur.fetchall()] + _used_ids2 = [x for x in range(_used_ids[0], _used_ids[-1] + 1)] + _avail_uids = list(set(_used_ids) ^ set(_used_ids2)) + _qinsert = {} + _qinsert['lastchannel'] = '0' + _qinsert['last_active'] = None # Change this to '' if it complains + _qinsert['texture'] = None # Change this to '' if it complains + _qinsert['uid'] = _avail_uids[0] + for k in ('username', 'server', 'password'): + _qinsert[k] = self.args[k] + for k in _qinsert.keys(): + if not _qinsert[k]: + _qinsert[k] = '' + _q = ("INSERT INTO users (server_id, user_id, name, pw, lastchannel, texture, last_active) " + + "VALUES ('{server}', '{uid}', '{username}', '{password}', '{lastchannel}', '{texture}'," + + "'{last_active}')").format(**_qinsert) + _cur.execute(_q) + self.conn.commit() + # Insert into the "user_info" table + for c in ('name', 'email', 'certhash', 'comment'): + if self.args[c]: + _qinsert = {} + _qinsert['server'] = self.args['server'] + _qinsert['user_id'] = _avail_uids[0] + _qinsert['keyid'] = list(self.infomap.keys())[list(self.infomap.values()).index(c)] + _qinsert['value'] = self.args[c] + _q = ("INSERT INTO user_info (server_id, user_id, key, value) " + + "VALUES ('{server}', '{user_id}', '{keyid}', '{value}')".format(**_qinsert)) + _cur.execute(_q) + self.conn.commit() + _cur.close() + # Insert into the "group_members" table if we need to + if self.args['groups']: + # The groups table, thankfully, has autoincrement. + for g in self.args['groups']: + _ginfo = {} + _minsert = {'server': self.args['server'], + 'uid': _avail_uids[0], + 'addit': 1} + _ginsert = {'server': self.args['server'], + 'name': g, + 'chan_id': 0, + 'inherit': 1, + 'inheritable': 1} + _create = True + _cur = self.conn.cursor() + _q = "SELECT * FROM groups WHERE server_id = '{0}'".format(self.args['server']) + _cur.execute(_q) + for r in _cur.fetchall(): + if r['name'] == g: + _create = False + _ginfo = r + break + if not _ginfo: + create = True # Just in case... + if _create: + _q = ("INSERT INTO groups (server_id, name, channel_id, inherit, inheritable) " + + "VALUES ('{server}', '{name}', '{chan_id}', '{inherit}', '{inheritable}')").format(**_ginsert) + _cur.execute(_q) + self.conn.commit() + _lastins = _cur.lastrowid + _q = ("SELECT * FROM groups WHERE group_id = '{0}' AND server_id = '{1}'").format(_lastins, + self.args['server']) + _cur.execute(_q) + _ginfo = _cur.fetchone() + _minsert['gid'] = _ginfo['group_id'] + _q = ("INSERT INTO group_members (group_id, server_id, user_id, addit) " + + "VALUES ('{gid}', '{server}', '{uid}', '{addit}')").format(**_minsert) + _cur.execute(_q) + self.conn.commit() + _cur.close() + return() + + def rm(self): + _cur = self.conn.cursor() + # First we'll need the user's UID. + _q = "SELECT user_id FROM users WHERE server_id = '{0}' AND name = '{1}'".format(self.args['server'], + self.args['username']) + _cur.execute(_q) + _uid = _cur.fetchone()[0] + # Then we get the groups the user's in; we'll need these in a bit. + _q = "SELECT group_id FROM group_members WHERE server_id = '{0}' AND user_id = '{0}'".format(self.args['server'], + _uid) + _cur.execute(_q) + _groups = [g[0] for g in _cur.fetchall()] + # Okay, now we can delete the user and their metadata... + _qtmpl = "DELETE FROM {0} WHERE server_id = '{1}' AND user_id = '{2}'" + for t in ('users', 'group_members'): + _q = _qtmpl.format(t, self.args['server'], _uid) + _cur.execute(_q) + self.conn.commit() + if not self.args['noprune']: + for t in ('user_info', 'acl'): + _q = _qtmpl.format(t, self.args['server'], _uid) + _cur.execute(_q) + self.conn.commit() + # Now some groups maintenance. + if self.args['prunegrps']: + for gid in _groups: + _q = ("SELECT COUNT(*) FROM group_members WHERE " + + "server_id = '{0}' AND group_id = '{1}'").format(self.args['server'], + gid) + _cur.execute(_q) + if _cur.fetchone()[0] == 0: + _q = ("DELETE FROM group_members WHERE " + + "server_id = '{0}' AND group_id = '{1}'").format(self.args['server'], + gid) + _cur.execute(_q) + self.conn.commit() + _cur.close() + return() + + def lsUsers(self): + users = {} + _fields = ('server_id', 'user_id', 'name', 'pw', 'lastchannel', 'texture', 'last_active') + if self.args['server']: + try: + self.args['server'] = int(self.args['server']) + _q = "SELECT * FROM users WHERE server_id = '{0}'".format(self.args['server']) + except (ValueError, TypeError): + pass # It's set as None, which we'll parse to mean as "all" per the --help output. + else: + _q = 'SELECT * FROM users' + _cur = self.conn.cursor() + _cur.execute(_q) + for r in _cur.fetchall(): + _usr = r['user_id'] + users[_usr] = {} + for f in _fields: + if f != 'user_id': # We set the dict key as this + users[_usr][f] = r[f] + _q = "SELECT * FROM user_info WHERE server_id = '{0}' AND user_id = '{1}'".format(r['server_id'], + r['user_id']) + _cur2 = self.conn.cursor() + _cur2.execute(_q) + for r2 in _cur2.fetchall(): + if r2['key'] in self.infomap.keys(): + users[_usr][self.infomap[r2['key']]] = r2['value'] + _cur2.close() + for k in self.infomap.keys(): + if self.infomap[k] not in users[_usr].keys(): + users[_usr][self.infomap[k]] = None + if users[_usr]['comment']: + users[_usr]['comment'] = ('(truncated)' if len(users[_usr]['comment']) >= 32 else users[_usr]['comment']) + _cur.close() + #pprint.pprint(users) + # Now we print (or just return) the results. Whew. + if not self.args['interactive']: + return(users) + print_tmpl = ('{0:6}\t{1:3}\t{2:12} {3:24} {4:40} {5:40} {6:12} ' + + '{7:19} {8:32}') + print(print_tmpl.format('Server','UID','Username','Email', + 'Password', 'Certhash', 'Last Channel', + 'Last Active', 'Comment'), end = '\n\n') + for uid in users.keys(): + d = users[uid] + print(print_tmpl.format(int(d['server_id']), + int(uid), + str(d['name']), + str(d['email']), + str(d['pw']), + str(d['certhash']), + (str(d['lastchannel']) if not d['lastchannel'] else int(d['lastchannel'])), + str(d['last_active']), + str(d['comment']))) + return() + + def lsGroups(self): + groups = {} + _cur = self.conn.cursor() + # First, we get the groups. + if self.args['server']: + _q = "SELECT * FROM groups WHERE server_id = '{0}'".format(self.args['server']) + else: + _q = "SELECT * FROM groups" + _cur.execute(_q) + for r in _cur.fetchall(): + _gid = r['group_id'] + groups[_gid] = {'server': r['server_id'], + 'name': r['name'], + 'chan_id': r['channel_id'], + 'inherit': r['inherit'], + 'inheritable': r['inheritable']} + groups[_gid]['members'] = {} + _cur2 = self.conn.cursor() + _q2 = "SELECT * FROM group_members WHERE group_id = '{0}' AND server_id = '{1}'".format(_gid, + groups[_gid]['server']) + _cur2.execute(_q2) + for r2 in _cur2.fetchall(): + # True means they are a member of the group. False means they are excluded from the group. + # (Helps override default policies?) + groups[_gid]['members'][r2['user_id']] = (True if r2['addit'] else False) + _cur2.close() + _cur.close() + # Return if we're non-interactive... + if not self.args['interactive']: + return(groups) + # Print the groups + print('GROUPS:') + print_tmpl = ('{0:3}\t{1:16}\t{2:10}\t{3:35}\t{4:30}') + print(print_tmpl.format('GID', 'Name', 'Channel ID', + 'Inherit Parent Channel Permissions?', 'Allow Sub-channels to Inherit?'), end = '\n\n') + for g in groups.keys(): + d = groups[g] + print(print_tmpl.format(g, + d['name'], + d['chan_id'], + str(True if d['inherit'] == 1 else False), + str(True if d['inheritable'] == 1 else False))) + print('\n\nMEMBERSHIPS:') + # And print the members + print_tmpl = ('\t\t{0:3}\t{1:>19}') # UID, Include or Exclude? + for g in groups.keys(): + d = groups[g] + print('{0} ({1}):'.format(d['name'], g)) + if d['members']: + print(print_tmpl.format('UID', 'Include or Exclude?'), end = '\n\n') + for m in d['members'].keys(): + print(print_tmpl.format(m, ('Include' if d['members'][m] == 1 else 'Exclude'))) + else: + print('\t\tNo members found; group is empty.') + return() + + def edit(self): + print('Editing is not currently supported.') + return() + + def close(self): + self.conn.close() + return() + +def parseArgs(): + _db = '/var/lib/murmur/murmur.sqlite' + commonargs = argparse.ArgumentParser(add_help = False) + reqcommon = commonargs.add_argument_group('REQUIRED common arguments') + reqcommon.add_argument('-u', + '--user', + type = str, + dest = 'username', + required = True, + help = 'The username to perform the action for.') + reqcommon.add_argument('-s', + '--server', + type = int, + dest = 'server', + default = 1, + help = 'The server ID. Defaults to \033[1m{0}\033[0m'.format(1)) + commonargs.add_argument('-d', + '--database', + type = str, + dest = 'database', + metavar = '/path/to/murmur.sqlite3', + default = _db, + help = 'The path to the sqlite3 database for Murmur. Default: \033[1m{0}\033[0m'.format(_db)) + args = argparse.ArgumentParser(epilog = 'This program has context-sensitive help (e.g. try "... add --help")') + subparsers = args.add_subparsers(help = 'Operation to perform', + dest = 'operation') + addargs = subparsers.add_parser('add', + parents = [commonargs], + help = 'Add a user to the Murmur database') + delargs = subparsers.add_parser('rm', + parents = [commonargs], + help = 'Remove a user from the Murmur database') + listargs = subparsers.add_parser('ls', + help = 'List users in the Murmur database') + editargs = subparsers.add_parser('edit', + parents = [commonargs], + help = 'Edit a user in the Murmur database') + # Operation-specific optional arguments + addargs.add_argument('-n', + '--name', + type = str, + metavar = '"Firstname Lastname"', + dest = 'name', + default = None, + help = 'The new user\'s (real) name') + addargs.add_argument('-c', + '--comment', + type = str, + metavar = '"This comment becomes the user\'s profile."', + dest = 'comment', + default = None, + help = 'The comment for the new user') + addargs.add_argument('-e', + '--email', + type = str, + metavar = 'email@domain.tld', + dest = 'email', + default = None, + help = 'The email address for the new user') + addargs.add_argument('-C', + '--certhash', + type = str, + metavar = 'CERTIFICATE_FINGERPRINT_HASH', + default = None, + dest = 'certhash', + help = ('The certificate fingerprint hash. See genfprhash.py. ' + + 'If you do not specify this, you must specify -p/--passwordhash')) + addargs.add_argument('-p', + '--passwordhash', + type = str, + dest = 'password', + choices = ['stdin', 'prompt'], + default = None, + help = ('If not specified, you must specify -C/--certhash. Otherwise, either ' + + '\'stdin\' (the password is being piped into this program) or \'prompt\' ' + + '(a password will be asked for in a non-echoing prompt). "prompt" is much more secure and recommended.')) + addargs.add_argument('-g', + '--groups', + type = str, + metavar = 'GROUP1(,GROUP2,GROUP3...)', + default = None, + help = ('A comma-separated list of groups the user should be added to. If a group ' + + 'doesn\'t exist, it will be created')) + # Listing should only take the DB as the "common" arg + listargs.add_argument('-g', + '--groups', + action = 'store_true', + dest = 'groups', + help = 'If specified, list groups (and their members), not users') + listargs.add_argument('-s', + '--server', + type = str, + dest = 'server', + default = None, + help = 'The server ID. Defaults to all servers. Specify one by the numerical ID.') + listargs.add_argument('-d', + '--database', + type = str, + dest = 'database', + metavar = '/path/to/murmur.sqlite3', + default = _db, + help = 'The path to the sqlite3 database for Murmur. Default: \033[1m{0}\033[0m'.format(_db)) + # Deleting args + delargs.add_argument('-n', + '--no-prune', + dest = 'noprune', + action = 'store_true', + help = ('If specified, do NOT remove the ACLs and user info for the user as well (profile, ' + + 'certificate fingerprint, etc.)')) + delargs.add_argument('-P', + '--prune-groups', + dest = 'prunegrps', + action = 'store_true', + help = 'If specified, remove any groups the user was in that are now empty (i.e. the user was the only member)') + return(args) + +def main(): + args = vars(parseArgs().parse_args()) + if not args['operation']: + #raise RuntimeError('You must specify an operation to perform. Try running with -h/--help.') + exit('You must specify an operation to perform. Try running with -h/--help.') + args['interactive'] = True + #pprint.pprint(args) + mgmt = Manager(args) + if args['operation'] == 'add': + if args['groups']: + mgmt.args['groups'] = [g.strip() for g in args['groups'].split(',')] + mgmt.add() + elif args['operation'] == 'rm': + mgmt.rm() + elif args['operation'] == 'ls': + if not args['groups']: + mgmt.lsUsers() + else: + mgmt.lsGroups() + elif args['operation'] == 'edit': + mgmt.edit() + else: + pass # No-op because something went SUPER wrong. + mgmt.close() + +if __name__ == '__main__': + main()