summaryrefslogtreecommitdiffstats
path: root/Reaktor/IRC
diff options
context:
space:
mode:
Diffstat (limited to 'Reaktor/IRC')
-rw-r--r--Reaktor/IRC/README.md49
-rw-r--r--Reaktor/IRC/getconf.py33
-rwxr-xr-xReaktor/IRC/index6
-rw-r--r--Reaktor/IRC/ircasy.py205
-rwxr-xr-xReaktor/IRC/reaktor.py125
-rw-r--r--Reaktor/IRC/translate_colors.py31
6 files changed, 449 insertions, 0 deletions
diff --git a/Reaktor/IRC/README.md b/Reaktor/IRC/README.md
new file mode 100644
index 00000000..63a0ebd2
--- /dev/null
+++ b/Reaktor/IRC/README.md
@@ -0,0 +1,49 @@
+# //Reaktor/IRC
+
+This component implements a remote shell daemon that exposes the
+executable files (which may be symlinks) below
+`//Reaktor/public_commands/` through IRC.
+
+## Security
+
+Access to the IRC server implies full access to all the exposed executable
+files. The daemon is executing the commands without dropping privileges.
+
+## Quickstart
+
+ #? /bin/sh
+ set -euf
+
+ export nick="$LOGNAME|$HOSTNAME"
+ export host=irc.freenode.org
+ export target='#tincspasm'
+
+ exec Reaktor/IRC/index
+
+## Environment variables
+
+The following environment variables are processed by `//Reaktor/IRC`:
+
+### nick
+
+Use a specific nickname.
+
+Optional if the node running `//Reaktor/IRC` is part of Retiolum, in
+which case it defaults to `Name` in `/etc/tinc/retiolum/tinc.conf`.
+
+### host and port
+
+Connect to a specific IRC server.
+
+Optional if the node running `//Reaktor/IRC` is part of Retiolum, in
+which case it defaults to `supernode` and `6667` (well, it always
+defaults to these two, but they only make science in Retiolum^_^).
+
+### target
+
+Join a specific channel.
+
+As always, this does the right thing for properly configured hosts: it
+uses the default `#retiolum`, which is the only really relevant
+channel.^_^
+
diff --git a/Reaktor/IRC/getconf.py b/Reaktor/IRC/getconf.py
new file mode 100644
index 00000000..168c908c
--- /dev/null
+++ b/Reaktor/IRC/getconf.py
@@ -0,0 +1,33 @@
+#getconf = make_getconf("dateiname.json")
+#getconf(key) -> value
+#oder error
+
+import imp
+import os
+
+
+def make_getconf(filename):
+
+
+ def getconf(prop, default_value=None):
+ prop_split = prop.split('.')
+ string = ''
+ config = load_config(filename)
+ #imp.reload(config)
+ tmp = config.__dict__
+ for pr in prop_split:
+ if pr in tmp:
+ tmp = tmp[pr]
+ else:
+ return default_value
+ return tmp
+
+ return getconf
+
+
+def load_config(filename):
+ dirname = os.path.dirname(filename)
+ modname, ext = os.path.splitext(os.path.basename(filename))
+ file, pathname, description = imp.find_module(modname, [ dirname ])
+ return imp.load_module(modname, file, pathname, description)
+
diff --git a/Reaktor/IRC/index b/Reaktor/IRC/index
new file mode 100755
index 00000000..af557a89
--- /dev/null
+++ b/Reaktor/IRC/index
@@ -0,0 +1,6 @@
+#! /bin/sh
+set -xeuf
+
+# cd //Reaktor
+cd $(dirname $(readlink -f $0))/..
+exec IRC/reaktor.py "$@"
diff --git a/Reaktor/IRC/ircasy.py b/Reaktor/IRC/ircasy.py
new file mode 100644
index 00000000..38f202fc
--- /dev/null
+++ b/Reaktor/IRC/ircasy.py
@@ -0,0 +1,205 @@
+#! /usr/bin/env python
+#
+# //Reaktor/IRC/asybot.py
+#
+from asynchat import async_chat as asychat
+from asyncore import loop
+from socket import AF_INET, SOCK_STREAM,gethostname
+from signal import SIGALRM, signal, alarm
+from datetime import datetime as date, timedelta
+from time import sleep
+from sys import exit
+from re import split, search, match
+from textwrap import TextWrapper
+
+import logging,logging.handlers
+
+# s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g -- removes color codes
+
+
+class asybot(asychat):
+ def __init__(self, server, port, nickname, channels, realname=False, username=False, hostname=False, hammer_interval=10, alarm_timeout=300, kill_timeout=360, loglevel=logging.ERROR):
+ asychat.__init__(self)
+ #logger magic
+ self.log = logging.getLogger('asybot_' + nickname)
+ #hdlr = logging.handlers.SysLogHandler(facility=logging.handlers.SysLogHandler.LOG_DAEMON)
+ #formatter = logging.Formatter( '%(filename)s: %(levelname)s: %(message)s')
+ #hdlr.setFormatter(formatter)
+ #self.log.addHandler(hdlr)
+ logging.basicConfig(level = loglevel)
+
+ self.nickname = nickname
+
+ if realname:
+ self.realname = realname
+ else:
+ self.realname = nickname
+
+ if username:
+ self.username = username
+ else:
+ self.username = nickname
+
+ if hostname:
+ self.hostname = hostname
+ else:
+ self.hostname = nickname
+
+ self.retry = True
+ self.server = server
+ self.port = port
+ self.channels = channels
+ self.data = ''
+ self.myterminator = '\r\n'
+ self.set_terminator(self.myterminator.encode())
+ self.create_socket(AF_INET, SOCK_STREAM)
+ self.connect((self.server, self.port))
+ self.wrapper = TextWrapper(subsequent_indent=" ",width=400)
+
+ self.log.info('=> irc://%s@%s:%s/%s' % (self.nickname, self.server, self.port, self.channels))
+
+ # When we don't receive data for alarm_timeout seconds then issue a
+ # PING every hammer_interval seconds until kill_timeout seconds have
+ # passed without a message. Any incoming message will reset alarm.
+ self.alarm_timeout = alarm_timeout
+ self.hammer_interval = hammer_interval
+ self.kill_timeout = kill_timeout
+ try:
+ signal(SIGALRM, lambda signum, frame: self.alarm_handler())
+ except Exception as e:
+ print('asybot: ' + str(e))
+ self.reset_alarm()
+
+ def reset_alarm(self):
+ self.last_activity = date.now()
+ alarm(self.alarm_timeout)
+
+ def alarm_handler(self):
+ delta = date.now() - self.last_activity
+ if delta > timedelta(seconds=self.kill_timeout):
+ self.log.error('No data for %s. Giving up...' % delta)
+ self.handle_disconnect()
+ else:
+ self.log.error('No data for %s. PINGing server...' % delta)
+ self.push('PING :%s' % self.nickname)
+ alarm(self.hammer_interval)
+
+ def collect_incoming_data(self, data):
+ try:
+ self.data += data.decode()
+ except Exception as e:
+ print('error decoding message: ' + str(e));
+ print('current data: %s' % self.data);
+ print('received data: %s' % data);
+ print('trying to decode as latin1')
+ self.data += data.decode('latin1')
+
+ def found_terminator(self):
+ self.log.debug('<< %s' % self.data)
+
+ message = self.data
+ self.data = ''
+ try:
+ _, prefix, command, params, rest, _ = \
+ split('^(?::(\S+)\s)?(\S+)((?:\s[^:]\S*)*)(?:\s:(.*))?$', message)
+ except Exception as e:
+ print("cannot split message :(\nmsg: %s"%message)
+ return
+ params = params.split(' ')[1:]
+
+ if command == 'PING':
+ self.push('PONG :%s' % rest)
+ self.log.debug("Replying to servers PING with PONG :%s" %rest)
+ self.on_ping(prefix, command, params, rest)
+
+ elif command == 'PRIVMSG':
+ self.on_privmsg(prefix, command, params, rest)
+
+ elif command == 'INVITE':
+ self.on_invite(prefix, command, params, rest)
+
+ elif command == 'KICK':
+ self.on_kick(prefix, command, params, rest)
+
+ elif command == 'JOIN':
+ self.on_join(prefix, command, params, rest)
+
+ elif command == '433':
+ # ERR_NICKNAMEINUSE, retry with another name
+ self.on_nickinuse(prefix, command, params, rest)
+
+ elif command == '376':
+ self.on_welcome(prefix, command, params, rest)
+
+ self.reset_alarm()
+
+ def push(self, message):
+ try:
+ self.log.debug('>> %s' % message)
+ msg = (message + self.myterminator).encode()
+ self.log.debug('>> %s' % msg)
+ asychat.push(self, msg)
+ except:
+ pass
+
+ def disconnect(self):
+ self.push('QUIT')
+ self.close()
+
+ def reconnect(self):
+ if self.connected:
+ self.push('QUIT')
+ self.close()
+ self.create_socket(AF_INET, SOCK_STREAM)
+ self.connect((self.server, self.port))
+
+ def handle_connect(self):
+ self.push('NICK %s' % self.nickname)
+ self.push('USER %s %s %s :%s' %
+ (self.username, self.hostname, self.server, self.realname))
+
+ def handle_disconnect(self):
+ if self.retry:
+ self.reconnect()
+ else:
+ self.log.error('No retry set, giving up')
+
+ def PRIVMSG(self, target, text):
+ for line in self.wrapper.wrap(text):
+ msg = 'PRIVMSG %s :%s' % (','.join(target), line)
+ self.log.info(msg)
+ self.push(msg)
+ sleep(1)
+
+ def ME(self, target, text):
+ self.PRIVMSG(target, ('ACTION ' + text + ''))
+
+ def on_welcome(self, prefix, command, params, rest):
+ self.push('JOIN %s' % ','.join(self.channels))
+
+ def on_kick(self, prefix, command, params, rest):
+ self.log.debug(params)
+ if params[-1] == self.nickname:
+ for chan in params[:-1]:
+ self.channels.remove(chan)
+
+ def on_join(self, prefix, command, params, rest):
+ pass
+
+ def on_ping(self, prefix, command, params, rest):
+ pass
+
+ def on_privmsg(self, prefix, command, params, rest):
+ pass
+
+ def on_invite(self, prefix, command, params, rest):
+ pass
+
+ def on_nickinuse(self, prefix, command, params, rest):
+ regex = search('(\d+)$', self.nickname)
+ if regex:
+ theint = int(regex.group(0))
+ self.nickname = self.nickname.strip(str(theint)) + str(theint + 1)
+ else:
+ self.nickname = self.nickname + '0'
+ self.handle_connect()
diff --git a/Reaktor/IRC/reaktor.py b/Reaktor/IRC/reaktor.py
new file mode 100755
index 00000000..124fa017
--- /dev/null
+++ b/Reaktor/IRC/reaktor.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+import os
+from ircasy import asybot
+from asyncore import loop
+from translate_colors import translate_colors
+import shlex
+from re import split, search, match
+
+default_config = './config.py'
+from getconf import make_getconf
+getconf = None
+
+import logging,logging.handlers
+log = logging.getLogger('asybot')
+#hdlr = logging.handlers.SysLogHandler(address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_DAEMON)
+#formatter = logging.Formatter( '%(filename)s: %(levelname)s: %(message)s')
+#hdlr.setFormatter(formatter)
+#log.addHandler(hdlr)
+
+
+
+class Reaktor(asybot):
+ def __init__(self,config=default_config):
+ self.config = config
+ log.info("using config file %s"%(config))
+ asybot.__init__(self, getconf('irc_server'), getconf('irc_port'), getconf('irc_nickname'), getconf('irc_channels'), hammer_interval=getconf('irc_hammer_interval'), alarm_timeout=getconf('irc_alarm_timeout'), kill_timeout=getconf('irc_kill_timeout'))
+
+ def is_admin(self,prefix):
+ try:
+ with open(getconf('auth_file')) as f:
+ for line in f:
+ if line.strip() == prefix:
+ return True
+ except Exception as e:
+ log.info(e)
+ return False
+
+ def on_join(self, prefix, command, params, rest):
+ for command in getconf('on_join', []):
+ self.execute_command(command, None, prefix, params)
+
+ def on_ping(self, prefix, command, params, rest):
+ for command in getconf('on_ping', []):
+ prefix = '!' # => env = { _prefix: '!', _from: '' }
+ params = command.get('targets') # TODO why don't we get a list here and use ','.join() ?
+ self.execute_command(command, None, prefix, params)
+
+ def on_privmsg(self, prefix, command, params, rest):
+ for command in getconf('commands'):
+ y = match(command['pattern'], rest)
+ if y:
+ if not self.is_admin(prefix):
+ self.PRIVMSG(params,'unauthorized!')
+ else:
+ return self.execute_command(command, y, prefix, params)
+
+ for command in getconf('public_commands'):
+ y = match(command['pattern'], rest)
+ if y:
+ return self.execute_command(command, y, prefix, params)
+
+
+ def execute_command(self, command, match, prefix, target):
+ from os.path import realpath, dirname, join
+ from subprocess import Popen as popen, PIPE
+ from time import time
+
+ #TODO: allow only commands below ./commands/
+ exe = join(dirname(realpath(dirname(__file__))), command['argv'][0])
+ myargv = [exe] + command['argv'][1:]
+ try:
+ if match and match.groupdict().get('args', None):
+ myargv += shlex.split(match.groupdict()['args'])
+ except:
+ log.info("cannot parse args!")
+
+ cwd = getconf('workdir')
+ if not os.access(cwd,os.W_OK):
+ log.error("Workdir '%s' is not Writable! Falling back to root dir"%cwd)
+ cwd = "/"
+
+ env = command.get('env', {})
+ env['_prefix'] = prefix
+ env['_from'] = prefix.split('!', 1)[0]
+
+ env.update(os.environ)
+ log.debug('self:' +self.nickname)
+ # when receiving /query, answer to the user, not to self
+ if self.nickname in target:
+ target.remove(self.nickname)
+ target.append(env['_from'])
+ log.debug('target:' +str(target))
+
+ start = time()
+ try:
+ log.debug("Running : %s"%str(myargv))
+ log.debug("Environ : %s"%(str(env)))
+ p = popen(myargv, bufsize=1, stdout=PIPE, stderr=PIPE, env=env, cwd=cwd)
+ except Exception as error:
+ self.ME(target, 'brain damaged')
+ log.error('OSError@%s: %s' % (myargv, error))
+ return
+ pid = p.pid
+ for line in iter(p.stdout.readline, ''.encode()):
+ try:
+ self.PRIVMSG(target, translate_colors(line.decode()))
+ except Exception as error:
+ log.error('no send: %s' % error)
+ log.debug('%s stdout: %s' % (pid, line))
+ p.wait()
+ elapsed = time() - start
+ code = p.returncode
+ log.info('command: %s -> %s in %d seconds' % (myargv, code, elapsed))
+ [log.debug('%s stderr: %s' % (pid, x)) for x in p.stderr.readlines()]
+
+ if code != 0:
+ self.ME(target, 'mimimi')
+
+if __name__ == "__main__":
+ import sys
+ conf = sys.argv[1] if len(sys.argv) == 2 else default_config
+ getconf = make_getconf(conf)
+ logging.basicConfig(level = logging.DEBUG if getconf('debug') else logging.INFO)
+ Reaktor(conf)
+ loop()
diff --git a/Reaktor/IRC/translate_colors.py b/Reaktor/IRC/translate_colors.py
new file mode 100644
index 00000000..bd716618
--- /dev/null
+++ b/Reaktor/IRC/translate_colors.py
@@ -0,0 +1,31 @@
+
+
+COLOR_MAP = {
+ "\x1b[0m" : "\x0F", # reset
+ "\x1b[37m" : "\x0300",
+ "\x1b[30m" : "\x0301",
+ "\x1b[34m" : "\x0302",
+ "\x1b[32m" : "\x0303",
+ "\x1b[31m" : "\x0304",
+ "\x1b[33m" : "\x0305",
+ "\x1b[35m" : "\x0306",
+ "\x1b[33m" : "\x0307",
+ "\x1b[33m" : "\x0308",
+ "\x1b[32m" : "\x0309",
+ "\x1b[36m" : "\x0310",
+ "\x1b[36m" : "\x0311",
+ "\x1b[34m" : "\x0312",
+ "\x1b[31m" : "\x0313",
+ "\x1b[30m" : "\x0314",
+ "\x1b[37m" : "\x0315",
+ "\x1b[1m" : "\x02", # bold on
+ "\x1b[22m" : "\x02" # bold off
+ }
+def translate_colors (line):
+ for color,replace in COLOR_MAP.items():
+ line = line.replace(color,replace)
+ return line
+
+if __name__ == "__main__":
+ import sys
+ print (translate_colors(sys.stdin.read()))