Clean up core and add support for TLS client certs

master
mutantmonkey 2017-04-09 22:50:00 -07:00
parent e4d28c0990
commit 538fec692d
3 changed files with 200 additions and 155 deletions

View File

@ -7,9 +7,14 @@ Licensed under the Eiffel Forum License 2.
http://inamidst.com/phenny/ http://inamidst.com/phenny/
""" """
import sys, os, time, threading, signal import os
import signal
import sys
import threading
import time
class Watcher(object):
class Watcher(object):
# Cf. http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496735 # Cf. http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496735
def __init__(self): def __init__(self):
self.child = os.fork() self.child = os.fork()
@ -18,51 +23,65 @@ class Watcher(object):
self.watch() self.watch()
def watch(self): def watch(self):
try: os.wait() try:
os.wait()
except KeyboardInterrupt: except KeyboardInterrupt:
self.kill() self.kill()
sys.exit() sys.exit()
def kill(self): def kill(self):
try: os.kill(self.child, signal.SIGKILL) try:
except OSError: pass os.kill(self.child, signal.SIGKILL)
except OSError:
pass
def sig_term(self, signum, frame): def sig_term(self, signum, frame):
self.kill() self.kill()
sys.exit() sys.exit()
def run_phenny(config):
if hasattr(config, 'delay'):
delay = config.delay
else: delay = 20
def connect(config): def run_phenny(config):
if hasattr(config, 'delay'):
delay = config.delay
else:
delay = 20
def connect(config):
import bot import bot
p = bot.Phenny(config) p = bot.Phenny(config)
p.run(config.host, config.port, config.ssl, config.ipv6,
config.ca_certs)
try: Watcher() ssl_context = p.get_ssl_context(config.ca_certs)
if config.ssl_cert and config.ssl_key:
ssl_context.load_cert_chain(config.ssl_cert, config.ssl_key)
p.run(config.host, config.port, config.ssl, config.ipv6, None,
ssl_context)
try:
Watcher()
except Exception as e: except Exception as e:
print('Warning:', e, '(in __init__.py)', file=sys.stderr) print('Warning:', e, '(in __init__.py)', file=sys.stderr)
while True: while True:
try: connect(config) try:
connect(config)
except KeyboardInterrupt: except KeyboardInterrupt:
sys.exit() sys.exit()
if not isinstance(delay, int): if not isinstance(delay, int):
break break
warning = 'Warning: Disconnected. Reconnecting in %s seconds...' % delay msg = "Warning: Disconnected. Reconnecting in {0} seconds..."
print(warning, file=sys.stderr) print(msg.format(delay), file=sys.stderr)
time.sleep(delay) time.sleep(delay)
def run(config):
def run(config):
t = threading.Thread(target=run_phenny, args=(config,)) t = threading.Thread(target=run_phenny, args=(config,))
if hasattr(t, 'run'): if hasattr(t, 'run'):
t.run() t.run()
else: t.start() else:
t.start()
if __name__ == '__main__': if __name__ == '__main__':
print(__doc__) print(__doc__)

175
irc.py
View File

@ -7,30 +7,35 @@ Licensed under the Eiffel Forum License 2.
http://inamidst.com/phenny/ http://inamidst.com/phenny/
""" """
import sys, re, time, traceback import asynchat
import socket, asyncore, asynchat import asyncore
import re
import socket
import ssl import ssl
import sys
import time
class Origin(object): class Origin(object):
source = re.compile(r'([^!]*)!?([^@]*)@?(.*)') source = re.compile(r'([^!]*)!?([^@]*)@?(.*)')
def __init__(self, bot, source, args): def __init__(self, bot, source, args):
if not source: if not source:
source = "" source = ""
match = Origin.source.match(source) match = Origin.source.match(source)
self.nick, self.user, self.host = match.groups() self.nick, self.user, self.host = match.groups()
if len(args) > 1: if len(args) > 1:
target = args[1] target = args[1]
else: target = None else:
target = None
mappings = {bot.nick: self.nick, None: None} mappings = {bot.nick: self.nick, None: None}
self.sender = mappings.get(target, target) self.sender = mappings.get(target, target)
class Bot(asynchat.async_chat): class Bot(asynchat.async_chat):
def __init__(self, nick, name, channels, password=None): def __init__(self, nick, name, channels, password=None):
asynchat.async_chat.__init__(self) asynchat.async_chat.__init__(self)
self.set_terminator(b'\n') self.set_terminator(b'\n')
self.buffer = b'' self.buffer = b''
@ -52,97 +57,91 @@ class Bot(asynchat.async_chat):
asynchat.async_chat.initiate_send(self) asynchat.async_chat.initiate_send(self)
self.sending.release() self.sending.release()
# def push(self, *args, **kargs): # def push(self, *args, **kargs):
# asynchat.async_chat.push(self, *args, **kargs) # asynchat.async_chat.push(self, *args, **kargs)
def __write(self, args, text=None): def __write(self, args, text=None):
# print 'PUSH: %r %r %r' % (self, args, text) # print 'PUSH: %r %r %r' % (self, args, text)
try: try:
if text is not None: if text is not None:
# 510 because CR and LF count too, as nyuszika7h points out # 510 because CR and LF count too, as nyuszika7h points out
self.push((b' '.join(args) + b' :' + text)[:510] + b'\r\n') self.push((b' '.join(args) + b' :' + text)[:510] + b'\r\n')
else: else:
self.push(b' '.join(args)[:512] + b'\r\n') self.push(b' '.join(args)[:512] + b'\r\n')
except IndexError: except IndexError:
pass pass
def write(self, args, text=None): def write(self, args, text=None):
"""This is a safe version of __write""" """This is a safe version of __write"""
def safe(input): def safe(input):
if type(input) == str: if type(input) == str:
input = input.replace('\n', '') input = input.replace('\n', '')
input = input.replace('\r', '') input = input.replace('\r', '')
return input.encode('utf-8') return input.encode('utf-8')
else: else:
return input return input
try: try:
args = [safe(arg) for arg in args] args = [safe(arg) for arg in args]
if text is not None: if text is not None:
text = safe(text) text = safe(text)
self.__write(args, text) self.__write(args, text)
except Exception as e: except Exception as e:
raise raise
#pass
def run(self, host, port=6667, ssl=False, def run(self, host, port=6667, ssl=False, ipv6=False, ca_certs=None,
ipv6=False, ca_certs=None): ssl_context=None):
self.ca_certs = ca_certs if ssl_context is None:
self.initiate_connect(host, port, ssl, ipv6) ssl_context = self.get_ssl_context(ca_certs)
self.initiate_connect(host, port, ssl, ipv6, ssl_context)
def initiate_connect(self, host, port, use_ssl, ipv6): def get_ssl_context(self, ca_certs):
if self.verbose: return ssl.create_default_context(
purpose=ssl.Purpose.SERVER_AUTH,
cafile=ca_certs)
def initiate_connect(self, host, port, use_ssl, ipv6, ssl_context):
if self.verbose:
message = 'Connecting to %s:%s...' % (host, port) message = 'Connecting to %s:%s...' % (host, port)
print(message, end=' ', file=sys.stderr) print(message, end=' ', file=sys.stderr)
if ipv6 and socket.has_ipv6: if ipv6 and socket.has_ipv6:
af = socket.AF_INET6 af = socket.AF_INET6
else: else:
af = socket.AF_INET af = socket.AF_INET
self.create_socket(af, socket.SOCK_STREAM, use_ssl, host) self.create_socket(af, socket.SOCK_STREAM, use_ssl, host, ssl_context)
self.connect((host, port)) self.connect((host, port))
try: asyncore.loop() try:
except KeyboardInterrupt: asyncore.loop()
except KeyboardInterrupt:
sys.exit() sys.exit()
def create_socket(self, family, type, use_ssl=False, hostname=None): def create_socket(self, family, type, use_ssl=False, hostname=None,
ssl_context=None):
self.family_and_type = family, type self.family_and_type = family, type
sock = socket.socket(family, type) sock = socket.socket(family, type)
if use_ssl: if use_ssl:
# this stuff is all new in python 3.4, so fallback if needed sock = ssl_context.wrap_socket(sock, server_hostname=hostname)
try:
context = ssl.create_default_context(
purpose=ssl.Purpose.SERVER_AUTH,
cafile=self.ca_certs)
sock = context.wrap_socket(sock, server_hostname=hostname)
except:
if self.ca_certs is None:
# default to standard path on most non-EL distros
ca_certs = "/etc/ssl/certs/ca-certificates.crt"
else:
ca_certs = self.ca_certs
sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1,
cert_reqs=ssl.CERT_REQUIRED, ca_certs=ca_certs)
# FIXME: this doesn't work with SSL enabled # FIXME: this doesn't work with SSL enabled
#sock.setblocking(False) #sock.setblocking(False)
self.set_socket(sock) self.set_socket(sock)
def handle_connect(self): def handle_connect(self):
if self.verbose: if self.verbose:
print('connected!', file=sys.stderr) print('connected!', file=sys.stderr)
if self.password: if self.password:
self.write(('PASS', self.password)) self.write(('PASS', self.password))
self.write(('NICK', self.nick)) self.write(('NICK', self.nick))
self.write(('USER', self.user, '+iw', self.nick), self.name) self.write(('USER', self.user, '+iw', self.nick), self.name)
def handle_close(self): def handle_close(self):
self.close() self.close()
print('Closed!', file=sys.stderr) print('Closed!', file=sys.stderr)
def collect_incoming_data(self, data): def collect_incoming_data(self, data):
self.buffer += data self.buffer += data
def found_terminator(self): def found_terminator(self):
line = self.buffer line = self.buffer
if line.endswith(b'\r'): if line.endswith(b'\r'):
line = line[:-1] line = line[:-1]
self.buffer = b'' self.buffer = b''
@ -151,35 +150,39 @@ class Bot(asynchat.async_chat):
except UnicodeDecodeError: except UnicodeDecodeError:
line = line.decode('iso-8859-1') line = line.decode('iso-8859-1')
if line.startswith(':'): if line.startswith(':'):
source, line = line[1:].split(' ', 1) source, line = line[1:].split(' ', 1)
else: source = None else:
source = None
if ' :' in line: if ' :' in line:
argstr, text = line.split(' :', 1) argstr, text = line.split(' :', 1)
else: argstr, text = line, '' else:
argstr, text = line, ''
args = argstr.split() args = argstr.split()
origin = Origin(self, source, args) origin = Origin(self, source, args)
self.dispatch(origin, tuple([text] + args)) self.dispatch(origin, tuple([text] + args))
if args[0] == 'PING': if args[0] == 'PING':
self.write(('PONG', text)) self.write(('PONG', text))
def dispatch(self, origin, args): def dispatch(self, origin, args):
pass pass
def msg(self, recipient, text): def msg(self, recipient, text):
self.sending.acquire() self.sending.acquire()
# Cf. http://swhack.com/logs/2006-03-01#T19-43-25 # Cf. http://swhack.com/logs/2006-03-01#T19-43-25
if isinstance(text, str): if isinstance(text, str):
try: text = text.encode('utf-8') try:
except UnicodeEncodeError as e: text = text.encode('utf-8')
except UnicodeEncodeError as e:
text = e.__class__ + ': ' + str(e) text = e.__class__ + ': ' + str(e)
if isinstance(recipient, str): if isinstance(recipient, str):
try: recipient = recipient.encode('utf-8') try:
except UnicodeEncodeError as e: recipient = recipient.encode('utf-8')
except UnicodeEncodeError as e:
return return
# Split long messages # Split long messages
@ -197,23 +200,23 @@ class Bot(asynchat.async_chat):
# No messages within the last 3 seconds? Go ahead! # No messages within the last 3 seconds? Go ahead!
# Otherwise, wait so it's been at least 0.8 seconds + penalty # Otherwise, wait so it's been at least 0.8 seconds + penalty
if self.stack: if self.stack:
elapsed = time.time() - self.stack[-1][0] elapsed = time.time() - self.stack[-1][0]
if elapsed < 3: if elapsed < 3:
penalty = float(max(0, len(text) - 50)) / 70 penalty = float(max(0, len(text) - 50)) / 70
wait = 0.8 + penalty wait = 0.8 + penalty
if elapsed < wait: if elapsed < wait:
time.sleep(wait - elapsed) time.sleep(wait - elapsed)
# Loop detection # Loop detection
messages = [m[1] for m in self.stack[-8:]] messages = [m[1] for m in self.stack[-8:]]
if messages.count(text) >= 5: if messages.count(text) >= 5:
text = '...' text = '...'
if messages.count('...') >= 3: if messages.count('...') >= 3:
self.sending.release() self.sending.release()
return return
def safe(input): def safe(input):
if type(input) == str: if type(input) == str:
input = input.encode('utf-8') input = input.encode('utf-8')
input = input.replace(b'\n', b'') input = input.replace(b'\n', b'')
@ -228,43 +231,47 @@ class Bot(asynchat.async_chat):
text = "\x01ACTION {0}\x01".format(text) text = "\x01ACTION {0}\x01".format(text)
return self.msg(recipient, text) return self.msg(recipient, text)
def notice(self, dest, text): def notice(self, dest, text):
self.write(('NOTICE', dest), text) self.write(('NOTICE', dest), text)
def error(self, origin): def error(self, origin):
try: try:
import traceback import traceback
trace = traceback.format_exc() trace = traceback.format_exc()
print(trace) print(trace)
lines = list(reversed(trace.splitlines())) lines = list(reversed(trace.splitlines()))
report = [lines[0].strip()] report = [lines[0].strip()]
for line in lines: for line in lines:
line = line.strip() line = line.strip()
if line.startswith('File "/'): if line.startswith('File "/'):
report.append(line[0].lower() + line[1:]) report.append(line[0].lower() + line[1:])
break break
else: report.append('source unknown') else:
report.append('source unknown')
self.msg(origin.sender, report[0] + ' (' + report[1] + ')') self.msg(origin.sender, report[0] + ' (' + report[1] + ')')
except: self.msg(origin.sender, "Got an error.") except:
self.msg(origin.sender, "Got an error.")
class TestBot(Bot): class TestBot(Bot):
def f_ping(self, origin, match, args): def f_ping(self, origin, match, args):
delay = m.group(1) delay = match.group(1)
if delay is not None: if delay is not None:
import time import time
time.sleep(int(delay)) time.sleep(int(delay))
self.msg(origin.sender, 'pong (%s)' % delay) self.msg(origin.sender, 'pong (%s)' % delay)
else: self.msg(origin.sender, 'pong') else:
self.msg(origin.sender, 'pong')
f_ping.rule = r'^\.ping(?:[ \t]+(\d+))?$' f_ping.rule = r'^\.ping(?:[ \t]+(\d+))?$'
def main(): def main():
bot = TestBot('testbot007', 'testbot007', ['#wadsworth']) bot = TestBot('testbot007', 'testbot007', ['#wadsworth'])
bot.run('irc.freenode.net') bot.run('irc.freenode.net')
print(__doc__) print(__doc__)
if __name__=="__main__":
if __name__ == "__main__":
main() main()

121
phenny
View File

@ -11,19 +11,23 @@ Run ./phenny, then edit ~/.phenny/default.py
Then run ./phenny again Then run ./phenny again
""" """
import sys, os, imp
import argparse import argparse
import imp
import os
import sys
from textwrap import dedent as trim from textwrap import dedent as trim
dotdir = os.path.expanduser('~/.phenny') dotdir = os.path.expanduser('~/.phenny')
def check_python_version():
if sys.version_info < (3, 0): def check_python_version():
error = 'Error: Requires Python 3.0 or later, from www.python.org' if sys.version_info < (3, 4):
error = 'Error: Requires Python 3.4 or later, from www.python.org'
print(error, file=sys.stderr) print(error, file=sys.stderr)
sys.exit(1) sys.exit(1)
def create_default_config(fn):
def create_default_config(fn):
f = open(fn, 'w') f = open(fn, 'w')
print(trim("""\ print(trim("""\
nick = 'phenny' nick = 'phenny'
@ -51,7 +55,7 @@ def create_default_config(fn):
# If you want to enumerate a list of modules rather than disabling # If you want to enumerate a list of modules rather than disabling
# some, use "enable = ['example']", which takes precedent over exclude # some, use "enable = ['example']", which takes precedent over exclude
# #
# enable = [] # enable = []
# Directories to load user modules from # Directories to load user modules from
@ -59,7 +63,7 @@ def create_default_config(fn):
extra = [] extra = []
# Services to load: maps channel names to white or black lists # Services to load: maps channel names to white or black lists
external = { external = {
'#liberal': ['!'], # allow all '#liberal': ['!'], # allow all
'#conservative': [], # allow none '#conservative': [], # allow none
'*': ['!'] # default whitelist, allow all '*': ['!'] # default whitelist, allow all
@ -69,6 +73,7 @@ def create_default_config(fn):
"""), file=f) """), file=f)
f.close() f.close()
def create_default_config_file(dotdir): def create_default_config_file(dotdir):
print('Creating a default config file at ~/.phenny/default.py...') print('Creating a default config file at ~/.phenny/default.py...')
default = os.path.join(dotdir, 'default.py') default = os.path.join(dotdir, 'default.py')
@ -77,10 +82,12 @@ def create_default_config_file(dotdir):
print('Done; now you can edit default.py, and run phenny! Enjoy.') print('Done; now you can edit default.py, and run phenny! Enjoy.')
sys.exit(0) sys.exit(0)
def create_dotdir(dotdir):
def create_dotdir(dotdir):
print('Creating a config directory at ~/.phenny...') print('Creating a config directory at ~/.phenny...')
try: os.mkdir(dotdir) try:
except Exception as e: os.mkdir(dotdir)
except Exception as e:
print('There was a problem creating %s:' % dotdir, file=sys.stderr) print('There was a problem creating %s:' % dotdir, file=sys.stderr)
print(e.__class__, str(e), file=sys.stderr) print(e.__class__, str(e), file=sys.stderr)
print('Please fix this and then run phenny again.', file=sys.stderr) print('Please fix this and then run phenny again.', file=sys.stderr)
@ -88,87 +95,96 @@ def create_dotdir(dotdir):
create_default_config_file(dotdir) create_default_config_file(dotdir)
def check_dotdir():
def check_dotdir():
default = os.path.join(dotdir, 'default.py') default = os.path.join(dotdir, 'default.py')
if not os.path.isdir(dotdir): if not os.path.isdir(dotdir):
create_dotdir(dotdir) create_dotdir(dotdir)
elif not os.path.isfile(default): elif not os.path.isfile(default):
create_default_config_file(dotdir) create_default_config_file(dotdir)
def config_names(config):
def config_names(config):
config = config or 'default' config = config or 'default'
def files(d): def files(d):
names = os.listdir(d) names = os.listdir(d)
return list(os.path.join(d, fn) for fn in names if fn.endswith('.py')) return list(os.path.join(d, fn) for fn in names if fn.endswith('.py'))
here = os.path.join('.', config) here = os.path.join('.', config)
if os.path.isfile(here): if os.path.isfile(here):
return [here] return [here]
if os.path.isfile(here + '.py'): if os.path.isfile(here + '.py'):
return [here + '.py'] return [here + '.py']
if os.path.isdir(here): if os.path.isdir(here):
return files(here) return files(here)
there = os.path.join(dotdir, config) there = os.path.join(dotdir, config)
if os.path.isfile(there): if os.path.isfile(there):
return [there] return [there]
if os.path.isfile(there + '.py'): if os.path.isfile(there + '.py'):
return [there + '.py'] return [there + '.py']
if os.path.isdir(there): if os.path.isdir(there):
return files(there) return files(there)
print("Error: Couldn't find a config file!", file=sys.stderr) print("Error: Couldn't find a config file!", file=sys.stderr)
print('What happened to ~/.phenny/default.py?', file=sys.stderr) print('What happened to ~/.phenny/default.py?', file=sys.stderr)
sys.exit(1) sys.exit(1)
def main(argv=None):
def main(argv=None):
# Step One: Parse The Command Line # Step One: Parse The Command Line
parser = argparse.ArgumentParser(description="A Python IRC bot.") parser = argparse.ArgumentParser(description="A Python IRC bot.")
parser.add_argument('-c', '--config', metavar='fn', parser.add_argument('-c', '--config', metavar='fn',
help='use this configuration file or directory') help='use this configuration file or directory')
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Step Two: Check Dependencies # Step Two: Check Dependencies
check_python_version() # require python2.4 or later check_python_version()
if not args.config: if not args.config:
check_dotdir() # require ~/.phenny, or make it and exit check_dotdir() # require ~/.phenny, or make it and exit
# Step Three: Load The Configurations # Step Three: Load The Configurations
config_modules = [] config_modules = []
for config_name in config_names(args.config): for config_name in config_names(args.config):
name = os.path.basename(config_name).split('.')[0] + '_config' name = os.path.basename(config_name).split('.')[0] + '_config'
module = imp.load_source(name, config_name) module = imp.load_source(name, config_name)
module.filename = config_name module.filename = config_name
if not hasattr(module, 'prefix'): if not hasattr(module, 'prefix'):
module.prefix = r'\.' module.prefix = r'\.'
if not hasattr(module, 'name'): if not hasattr(module, 'name'):
module.name = 'Phenny Palmersbot, http://inamidst.com/phenny/' module.name = 'Phenny Palmersbot, http://inamidst.com/phenny/'
if not hasattr(module, 'port'): if not hasattr(module, 'port'):
module.port = 6667 module.port = 6667
if not hasattr(module, 'ssl'): if not hasattr(module, 'ssl'):
module.ssl = False module.ssl = False
if not hasattr(module, 'ca_certs'): if not hasattr(module, 'ca_certs'):
module.ca_certs = None module.ca_certs = None
if not hasattr(module, 'ssl_cert'):
module.ssl_cert = None
if not hasattr(module, 'ssl_key'):
module.ssl_key = None
if not hasattr(module, 'ipv6'): if not hasattr(module, 'ipv6'):
module.ipv6 = False module.ipv6 = False
if not hasattr(module, 'password'): if not hasattr(module, 'password'):
module.password = None module.password = None
if module.host == 'irc.example.net': if module.host == 'irc.example.net':
error = ('Error: you must edit the config file first!\n' + error = ('Error: you must edit the config file first!\n' +
"You're currently using %s" % module.filename) "You're currently using %s" % module.filename)
print(error, file=sys.stderr) print(error, file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -176,18 +192,21 @@ def main(argv=None):
# Step Four: Load Phenny # Step Four: Load Phenny
try: from __init__ import run try:
except ImportError: from __init__ import run
try: from phenny import run except ImportError:
except ImportError: try:
from phenny import run
except ImportError:
print("Error: Couldn't find phenny to import", file=sys.stderr) print("Error: Couldn't find phenny to import", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Step Five: Initialise And Run The Phennies # Step Five: Initialise And Run The Phennies
# @@ ignore SIGHUP # @@ ignore SIGHUP
for config_module in config_modules: for config_module in config_modules:
run(config_module) # @@ thread this run(config_module) # @@ thread this
if __name__ == '__main__':
if __name__ == '__main__':
main() main()