optools/mumble/usermgmt.py

454 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import getpass
import hashlib
import os
import pprint
import sqlite3
import subprocess
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:
_repeat = False
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()
if self.args['operation'] in ('add', 'rm', 'edit'):
_cmd = ['systemctl', 'restart', 'murmur']
subprocess.run(_cmd)
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()