453 lines
22 KiB
Python
Executable File
453 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:
|
|
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()
|