basic functionality. editing still not there, but it's Usable(TM)
This commit is contained in:
parent
c49112a28d
commit
08d3958b47
2
mumble/TODO
Normal file
2
mumble/TODO
Normal file
@ -0,0 +1,2 @@
|
||||
-add lsChans()
|
||||
-lsACL? lsBans? edit these?
|
448
mumble/usermgmt.py
Executable file
448
mumble/usermgmt.py
Executable file
@ -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()
|
Loading…
Reference in New Issue
Block a user