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/
"""
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
def __init__(self):
self.child = os.fork()
@ -18,51 +23,65 @@ class Watcher(object):
self.watch()
def watch(self):
try: os.wait()
try:
os.wait()
except KeyboardInterrupt:
self.kill()
sys.exit()
def kill(self):
try: os.kill(self.child, signal.SIGKILL)
except OSError: pass
try:
os.kill(self.child, signal.SIGKILL)
except OSError:
pass
def sig_term(self, signum, frame):
self.kill()
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
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:
print('Warning:', e, '(in __init__.py)', file=sys.stderr)
while True:
try: connect(config)
while True:
try:
connect(config)
except KeyboardInterrupt:
sys.exit()
sys.exit()
if not isinstance(delay, int):
break
warning = 'Warning: Disconnected. Reconnecting in %s seconds...' % delay
print(warning, file=sys.stderr)
msg = "Warning: Disconnected. Reconnecting in {0} seconds..."
print(msg.format(delay), file=sys.stderr)
time.sleep(delay)
def run(config):
def run(config):
t = threading.Thread(target=run_phenny, args=(config,))
if hasattr(t, 'run'):
t.run()
else: t.start()
else:
t.start()
if __name__ == '__main__':
print(__doc__)

175
irc.py
View File

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

121
phenny
View File

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