refact: smf-cleanup

This commit is contained in:
XDreamist 2025-06-22 18:16:01 +05:30
parent 46e7837346
commit c9c54ac81a
3 changed files with 56 additions and 415 deletions

View File

@ -1,14 +1,3 @@
;Database configuration
[database]
;Only tested with MySQL at the moment
lib = MySQLdb
name = smf
user = smf
password = secret
prefix = smf_
host = 127.0.0.1
port = 3306
;Django configuration
[django]
;If true, the authenticator will use Django to handle user authentication instead of the database

View File

@ -1,41 +1,23 @@
import sys
import Ice
import _thread
import urllib.request, urllib.error, urllib.parse
import logging
import configparser
import bcrypt
from threading import Timer
from optparse import OptionParser
from logging import (debug,
info,
warning,
error,
critical,
exception,
getLogger)
from hashlib import sha1
from logging import debug, info, warning, error, critical, exception, getLogger
# === Configuration Helpers ===
def x2bool(s):
"""Helper function to convert strings from the config to bool"""
"""Convert config strings to boolean"""
if isinstance(s, bool):
return s
elif isinstance(s, str):
return s.lower() in ['1', 'true']
raise ValueError()
#
#--- Default configuration values
#
# === Default Configuration ===
cfgfile = 'CVoipAuth.ini'
default = {'database':(('lib', str, 'MySQLdb'),
('password', str, 'secret'),
('host', str, '127.0.0.1'),
('port', int, 3306)),
'django':(('enabled', x2bool, False),
default = {'django':(('enabled', x2bool, False),
('project', str, 'CVoipPanel'),
('settings', str, 'CVoipPanel.settings')),
@ -61,35 +43,29 @@ default = {'database':(('lib', str, 'MySQLdb'),
'log':(('level', int, logging.DEBUG),
('file', str, 'CVoipAuth.log'))}
#
#--- Helper classes
#
# === Helper classes ===
class config(object):
"""
Small abstraction for config loading
"""
def __init__(self, filename = None, default = None):
if not filename or not default: return
cfg = configparser.ConfigParser()
cfg.optionxform = str
cfg.read(filename)
for h,v in default.items():
if not v:
# Output this whole section as a list of raw key/value tuples
for section, values in default.items():
if not values:
try:
self.__dict__[h] = cfg.items(h)
self.__dict__[section] = cfg.items(section)
except configparser.NoSectionError:
self.__dict__[h] = []
self.__dict__[section] = []
else:
self.__dict__[h] = config()
for name, conv, vdefault in v:
self.__dict__[section] = config()
for name, conv, vdefault in values:
try:
self.__dict__[h].__dict__[name] = conv(cfg.get(h, name))
val = cfg.get(section, name)
self.__dict__[section].__dict__[name] = conv(val)
except (ValueError, configparser.NoSectionError, configparser.NoOptionError):
self.__dict__[h].__dict__[name] = vdefault
self.__dict__[section].__dict__[name] = vdefault
# === HTML Entity Handling ===
def entity_decode(string):
"""
Python reverse implementation of php htmlspecialchars
@ -118,87 +94,7 @@ def entity_encode(string):
ret = ret.replace(s, t)
return ret
class threadDbException(Exception): pass
class threadDB(object):
"""
Small abstraction to handle database connections for multiple
threads
"""
db_connections = {}
def connection(cls):
tid = _thread.get_ident()
try:
con = cls.db_connections[tid]
except:
info('Connecting to database server (%s %s:%d %s) for thread %d',
cfg.database.lib, cfg.database.host, cfg.database.port, cfg.database.name, tid)
try:
con = db.connect(host = cfg.database.host,
port = cfg.database.port,
user = cfg.database.user,
passwd = cfg.database.password,
db = cfg.database.name,
charset = 'utf8')
# Transactional engines like InnoDB initiate a transaction even
# on SELECTs-only. Thus, we auto-commit so smfauth gets recent data.
con.autocommit(True)
except db.Error as e:
error('Could not connect to database: %s', str(e))
raise threadDbException()
cls.db_connections[tid] = con
return con
connection = classmethod(connection)
def cursor(cls):
return cls.connection().cursor()
cursor = classmethod(cursor)
def execute(cls, *args, **kwargs):
if "threadDB__retry_execution__" in kwargs:
# Have a magic keyword so we can call ourselves while preventing
# an infinite loop
del kwargs["threadDB__retry_execution__"]
retry = False
else:
retry = True
c = cls.cursor()
try:
c.execute(*args, **kwargs)
except db.OperationalError as e:
error('Database operational error %d: %s', e.args[0], e.args[1])
c.close()
cls.invalidate_connection()
if retry:
# Make sure we only retry once
info('Retrying database operation')
kwargs["threadDB__retry_execution__"] = True
c = cls.execute(*args, **kwargs)
else:
error('Database operation failed ultimately')
raise threadDbException()
return c
execute = classmethod(execute)
def invalidate_connection(cls):
tid = _thread.get_ident()
con = cls.db_connections.pop(tid, None)
if con:
debug('Invalidate connection to database for thread %d', tid)
con.close()
invalidate_connection = classmethod(invalidate_connection)
def disconnect(cls):
while cls.db_connections:
tid, con = cls.db_connections.popitem()
debug('Close database connection for thread %d', tid)
con.close()
disconnect = classmethod(disconnect)
# === Main Application ===
def do_main_program():
#
#--- Authenticator implementation
@ -222,101 +118,60 @@ def do_main_program():
if cfg.ice.watchdog > 0:
self.failedWatch = True
self.checkConnection()
# Serve till we are stopped
self.communicator().waitForShutdown()
if hasattr(self, 'watchdog'):
self.watchdog.cancel()
if self.interrupted():
warning('Caught interrupt, shutting down')
threadDB.disconnect()
warning('Interrupt received - shutting down')
return 0
def initializeIceConnection(self):
"""
Establishes the two-way Ice connection and adds the authenticator to the
configured servers
"""
ice = self.communicator()
if cfg.ice.secret:
debug('Using shared ice secret')
ice.getImplicitContext().put("secret", cfg.ice.secret)
elif not cfg.glacier.enabled:
warning('Consider using an ice secret to improve security')
warning('No Ice secret configured - security risk')
if cfg.glacier.enabled:
#info('Connecting to Glacier2 server (%s:%d)', glacier_host, glacier_port)
error('Glacier support not implemented yet')
#TODO: Implement this
info('Connecting to Ice at %s:%d', cfg.ice.host, cfg.ice.port)
proxy = ice.stringToProxy(f'Meta:tcp -h {cfg.ice.host} -p {cfg.ice.port}')
self.meta = MumbleServer.MetaPrx.uncheckedCast(proxy)
info('Connecting to Ice server (%s:%d)', cfg.ice.host, cfg.ice.port)
base = ice.stringToProxy('Meta:tcp -h %s -p %d' % (cfg.ice.host, cfg.ice.port))
self.meta = MumbleServer.MetaPrx.uncheckedCast(base)
adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h %s' % cfg.ice.host)
adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', f'tcp -h {cfg.ice.host}')
adapter.activate()
metacbprx = adapter.addWithUUID(metaCallback(self))
self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(metacbprx)
authprx = adapter.addWithUUID(CVoipAuthenticator())
self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx)
self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(
adapter.addWithUUID(metaCallback(self))
)
self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast(
adapter.addWithUUID(CVoipAuthenticator())
)
return self.attachCallbacks()
def attachCallbacks(self, quiet = False):
"""
Attaches all callbacks for meta and authenticators
"""
# Ice.ConnectionRefusedException
#debug('Attaching callbacks')
def attachCallbacks(self, quiet=False):
try:
if not quiet: info('Attaching meta callback')
if not quiet:
info('Attaching meta callback')
self.meta.addCallback(self.metacb)
for server in self.meta.getBootedServers():
if not cfg.murmur.servers or server.id() in cfg.murmur.servers:
if not quiet: info('Setting authenticator for virtual server %d', server.id())
if not quiet:
info('Configuring authenticator for server %d', server.id())
server.setAuthenticator(self.auth)
except (MumbleServer.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException) as e:
if isinstance(e, Ice.ConnectionRefusedException):
error('Server refused connection')
elif isinstance(e, MumbleServer.InvalidSecretException) or \
isinstance(e, Ice.UnknownUserException) and (e.unknown == 'MumbleServer::InvalidSecretException'):
error('Invalid ice secret')
else:
# We do not actually want to handle this one, re-raise it
raise e
self.connected = True
return True
except (MumbleServer.InvalidSecretException, Ice.UnknownUserException) as e:
error('Connection failed: %s', str(e))
self.connected = False
return False
self.connected = True
return True
def checkConnection(self):
"""
Tries reapplies all callbacks to make sure the authenticator
survives server restarts and disconnects.
"""
#debug('Watchdog run')
try:
if not self.attachCallbacks(quiet = not self.failedWatch):
self.failedWatch = True
else:
self.attachCallbacks(quiet=not self.failedWatch)
self.failedWatch = False
except Ice.Exception as e:
error('Failed connection check, will retry in next watchdog run (%ds)', cfg.ice.watchdog)
debug(str(e))
error('Connection check failed: %s', str(e))
self.failedWatch = True
# Renew the timer
self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection)
self.watchdog.start()
@ -431,12 +286,6 @@ def do_main_program():
@fortifyIceFu(authenticateFortifyResult)
@checkSecret
def authenticate(self, name, pw, certlist, certhash, strong, current = None):
"""
This function is called to authenticate a user
"""
debug(f'The user\'s password: {pw}')
# Search for the user in the database
FALL_THROUGH = -2
AUTH_REFUSED = -1
@ -458,7 +307,7 @@ def do_main_program():
try:
user = User.objects.get(username=name)
debug('User found: %s', user.username)
if user.check_password(pw):
if user.check_password(pw) and pw not in ('', None):
# Successful authentication
uid = user.id + cfg.user.id_offset
groups = [group.name for group in user.groups.all()]
@ -471,47 +320,7 @@ def do_main_program():
info('Refuse Connection for unknown user "%s"', name)
return (AUTH_REFUSED, None, None)
if name == 'SuperUser':
debug('Forced fall through for SuperUser')
return (FALL_THROUGH, None, None)
try:
sql = 'SELECT id_member, passwd, id_group, member_name, real_name, additional_groups, is_activated FROM %smembers WHERE LOWER(member_name) = LOWER(%%s)' % cfg.database.prefix
cur = threadDB.execute(sql, [name])
except threadDbException:
return (FALL_THROUGH, None, None)
res = cur.fetchone()
cur.close()
if not res:
info('Fall through for unknown user "%s"', name)
return (FALL_THROUGH, None, None)
uid, upw, ugroupid, uname, urealname, uadditgroups, activated = res
if activated == 1 and smf_check_hash(pw, upw, uname):
# Authenticated, fetch group memberships
try:
if uadditgroups:
groupids = str(ugroupid) + ',' + uadditgroups
else:
groupids = str(ugroupid)
sql = 'SELECT group_name FROM %smembergroups WHERE id_group IN (%s)' % (cfg.database.prefix, groupids)
cur = threadDB.execute(sql)
except threadDbException:
return (FALL_THROUGH, None, None)
groups = cur.fetchall()
cur.close()
if groups:
groups = [a[0] for a in groups]
info('User authenticated: "%s" (%d)', name, uid + cfg.user.id_offset)
debug('Group memberships: %s', str(groups))
return (uid + cfg.user.id_offset, entity_decode(urealname), groups)
info('Failed authentication attempt for user: "%s" (%d)', name, uid + cfg.user.id_offset)
info('Failed authentication attempt for user: "%s"', name)
return (AUTH_REFUSED, None, None)
@fortifyIceFu((False, None))
@ -537,21 +346,6 @@ def do_main_program():
debug('nameToId SuperUser -> forced fall through')
return FALL_THROUGH
try:
sql = 'SELECT id_member FROM %smembers WHERE LOWER(member_name) = LOWER(%%s)' % cfg.database.prefix
cur = threadDB.execute(sql, [name])
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if not res:
debug('nameToId %s -> ?', name)
return FALL_THROUGH
debug('nameToId %s -> %d', name, (res[0] + cfg.user.id_offset))
return res[0] + cfg.user.id_offset
@fortifyIceFu("")
@checkSecret
def idToName(self, id, current = None):
@ -565,23 +359,6 @@ def do_main_program():
return FALL_THROUGH
bbid = id - cfg.user.id_offset
# Fetch the user from the database
try:
sql = 'SELECT member_name FROM %smembers WHERE id_member = %%s' % cfg.database.prefix
cur = threadDB.execute(sql, [bbid])
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if res:
if res[0] == 'SuperUser':
debug('idToName %d -> "SuperUser" catched')
return FALL_THROUGH
debug('idToName %d -> "%s"', id, res[0])
return res[0]
debug('idToName %d -> ?', id)
return FALL_THROUGH
@ -599,66 +376,6 @@ def do_main_program():
debug('idToTexture %d -> fall through', id)
return FALL_THROUGH
# Otherwise get the users texture from smf
bbid = id - cfg.user.id_offset
try:
sql = 'SELECT avatar FROM %smembers WHERE id_member = %%s' % cfg.database.prefix
cur = threadDB.execute(sql, [bbid])
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if not res:
debug('idToTexture %d -> user unknown, fall through', id)
return FALL_THROUGH
avatar = res[0]
if not avatar:
# Either the user has none or it is in the attachments, check there
try:
sql = '''SELECT id_attach, file_hash, filename, attachment_type FROM %sattachments WHERE approved = true AND
(attachment_type = 0 OR attachment_type = 1) AND id_member = %%s''' % cfg.database.prefix
cur = threadDB.execute(sql, [bbid])
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if not res:
# No uploaded avatar found, seems like the user didn't set one
debug('idToTexture %d -> no texture available for this user, fall through', id)
return FALL_THROUGH
fid, fhash, filename, fattachtype = res
if cfg.forum.path.startswith('file://'):
# We are supposed to load this from the local fs
avatar_file = cfg.forum.path + 'attachments/%d_%s' % (fid, fhash)
elif fattachtype == 0:
avatar_file = cfg.forum.path + 'index.php?action=dlattach;attach=%d;type=avatar' % fid
elif fattachtype == 1:
avatar_file = cfg.forum.path + 'avatars/' + filename
elif "://" in avatar:
# ...or it is a external link
avatar_file = avatar
else:
warning("avatar with an unexpected value, fall through")
return FALL_THROUGH
if avatar_file in self.texture_cache:
return self.texture_cache[avatar_file]
try:
handle = urllib.request.urlopen(avatar_file)
filecontent = handle.read()
handle.close()
except urllib.error.URLError as e:
warning('Image download for "%s" (%d) failed: %s', avatar_file, id, str(e))
return FALL_THROUGH
self.texture_cache[avatar_file] = filecontent
return self.texture_cache[avatar_file]
@fortifyIceFu(-2)
@checkSecret
def registerUser(self, name, current = None):
@ -683,31 +400,6 @@ def do_main_program():
debug('unregisterUser %d -> fall through', id)
return FALL_THROUGH
@fortifyIceFu({})
@checkSecret
def getRegisteredUsers(self, filter, current = None):
"""
Returns a list of usernames in the smf database which contain
filter as a substring.
"""
if not filter:
filter = '%'
try:
sql = 'SELECT id_member, member_name FROM %smembers WHERE is_activated = 1 AND member_name LIKE %%s' % cfg.database.prefix
cur = threadDB.execute(sql, [filter])
except threadDbException:
return {}
res = cur.fetchall()
cur.close()
if not res:
debug('getRegisteredUsers -> empty list for filter "%s"', filter)
return {}
debug ('getRegisteredUsers -> %d results for filter "%s"', len(res), filter)
return dict([(a + cfg.user.id_offset, b) for a,b in res])
@fortifyIceFu(-1)
@checkSecret
def setInfo(self, id, info, current = None):
@ -767,9 +459,7 @@ def do_main_program():
def error(self, message):
self._log.error(message)
#
#--- Start of authenticator
#
# === Start of authenticator ===
info('Starting cvoip authenticator')
initdata = Ice.InitializationData()
initdata.properties = Ice.createProperties([], initdata.properties)
@ -784,30 +474,7 @@ def do_main_program():
state = app.main(sys.argv[:1], initData = initdata)
info('Shutdown complete')
#
#--- Python implementation of the smf check hash function
#
def smf_check_hash(password, hash, username):
"""
Python implementation of the smf check hash function
"""
ret = False
try:
# SMF 2.1 uses a bcrypt hash, try that first
ret = bcrypt.hashpw((username.lower() + password).encode('utf-8'), hash.encode('utf-8')) == hash
except ValueError:
# The sha1 password hash from SMF 2.0 and earlier will cause a salt value error
# In that case, try the legacy sha1 hash
ret = sha1((username.lower() + password).encode('utf-8')).hexdigest() == hash
return ret
#
#--- Start of program
#
# === Entry Point ===
if __name__ == '__main__':
# Parse commandline options
parser = OptionParser()
@ -836,16 +503,8 @@ if __name__ == '__main__':
print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr)
sys.exit(1)
try:
db = __import__(cfg.database.lib)
except ImportError as e:
print('Fatal error, could not import database library "%s", '\
'please install the missing dependency and restart the authenticator' % cfg.database.lib, file=sys.stderr)
sys.exit(1)
# Initialize logger
if cfg.log.file:
if cfg.log.file and option.logfile:
try:
logfile = open(cfg.log.file, 'a')
except IOError as e:
@ -886,7 +545,3 @@ if __name__ == '__main__':
do_main_program()
finally:
context.__exit__(None, None, None)
# Change django dB to postgres - Done
# An extra option to display foreground logs - Done

View File

@ -1,18 +1,15 @@
annotated-types==0.7.0
anyio==4.9.0
asgiref==3.8.1
bcrypt==4.3.0
Django==5.2.2
idna==3.10
lockfile==0.12.2
mysqlclient==2.2.7
psycopg==3.2.9
psycopg-binary==3.2.9
pydantic==2.11.5
pydantic_core==2.33.2
python-daemon==3.1.2
sniffio==1.3.1
sqlparse==0.5.3
starlette==0.46.2
typing-inspection==0.4.1
typing_extensions==4.14.0