add the rest of the stuff

This commit is contained in:
William Pitcock 2018-08-10 20:36:24 -05:00
parent df62c1353e
commit 6d234563e5
7 changed files with 514 additions and 9 deletions

View file

@ -5,10 +5,10 @@ name = "pypi"
[packages] [packages]
aiohttp = "*" aiohttp = "*"
asyncio-irc = "*"
pycrypto = "*" pycrypto = "*"
simplejson = "*" simplejson = "*"
pyyaml = "*" pyyaml = "*"
blinker = "*"
[dev-packages] [dev-packages]

10
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "18bbb3d2a3de6c69514d095176966458dd6b98b1d4834cee00c67aba2af5af8f" "sha256": "7d43dd71bd5f6ab7a5e47465e091d518de798e3bc2f94b576243a38613253a4e"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -45,13 +45,6 @@
"markers": "python_version >= '3.5.3'", "markers": "python_version >= '3.5.3'",
"version": "==3.0.0" "version": "==3.0.0"
}, },
"asyncio-irc": {
"hashes": [
"sha256:65b5b74c1795ea1be61e68cfa67fd59019abafca86cd2ba5684da04573554dbc"
],
"index": "pypi",
"version": "==0.2.1"
},
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
@ -63,6 +56,7 @@
"hashes": [ "hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
], ],
"index": "pypi",
"version": "==1.4" "version": "==1.4"
}, },
"chardet": { "chardet": {

View file

@ -3,6 +3,7 @@ import aiohttp.web
import logging import logging
from . import app from . import app
from .irc import irc_bot
async def start_webserver(): async def start_webserver():
@ -18,6 +19,7 @@ async def start_webserver():
def main(): def main():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
asyncio.ensure_future(start_webserver()) asyncio.ensure_future(start_webserver())
asyncio.ensure_future(irc_bot())
loop.run_forever() loop.run_forever()

View file

@ -1,6 +1,11 @@
import aiohttp import aiohttp
import aiohttp.web import aiohttp.web
import logging import logging
import uuid
import urllib.parse
import simplejson as json
import re
import cgi
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from .database import DATABASE from .database import DATABASE
@ -18,7 +23,13 @@ if "actorKeys" not in DATABASE:
} }
PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
PUBKEY = PRIVKEY.publickey()
from . import app from . import app
from .remote_actor import fetch_actor
async def actor(request): async def actor(request):
@ -44,3 +55,79 @@ async def actor(request):
app.router.add_get('/actor', actor) app.router.add_get('/actor', actor)
from .http_signatures import sign_headers
async def push_message_to_actor(actor, message, our_key_id):
url = urllib.parse.urlsplit(actor['inbox'])
# XXX: Digest
data = json.dumps(message)
headers = {
'(request-target)': 'post {}'.format(url.path),
'Content-Length': str(len(data)),
'Content-Type': 'application/activity+json',
'User-Agent': 'Viera'
}
headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
async with aiohttp.ClientSession() as session:
async with session.post(actor['inbox'], data=data, headers=headers) as resp:
pass
tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
def strip_html(data):
no_tags = tag_re.sub('', data)
return cgi.escape(no_tags)
from .authreqs import check_reqs
async def handle_create(actor, data, request):
content = strip_html(data['object']['content']).split()
check_reqs(content, actor)
async def handle_follow(actor, data, request):
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"to": [actor["id"]],
# this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
"object": {
"type": "Follow",
"id": data["id"],
"object": "https://{}/actor".format(request.host),
"actor": actor["id"]
},
"id": "https://{}/activities/{}".format(request.host, uuid.uuid4()),
}
await push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host))
processors = {
'Create': handle_create,
'Follow': handle_follow
}
async def inbox(request):
data = await request.json()
actor = await fetch_actor(data["actor"])
actor_uri = 'https://{}/actor'.format(request.host)
processor = processors.get(data['type'], None)
if processor:
await processor(actor, data, request)
return aiohttp.web.Response(body=b'{}', content_type='application/activity+json')
app.router.add_post('/inbox', inbox)

59
viera/authreqs.py Normal file
View file

@ -0,0 +1,59 @@
import uuid
import logging
from collections import namedtuple
from .database import DATABASE
PendingAuth = namedtuple('PendingAuth', ['irc_nickname', 'irc_account', 'actor'])
AUTHS = DATABASE.get('auths', {})
DATABASE["auths"] = AUTHS
PENDING_AUTHS = {}
IRC_BOT = None
def check_reqs(chunks, actor):
global DATABASE
results = [x in PENDING_AUTHS for x in chunks]
logging.debug('AUTHREQ: chunks: %r, results: %r', chunks, results)
if True in results:
pending_slot = results.index(True)
pending_uuid = chunks[pending_slot]
req = PENDING_AUTHS.pop(pending_uuid)._replace(actor=actor["id"])
logging.debug("IRC BOT: %r, AUTHREQ: %r", IRC_BOT, req)
if IRC_BOT:
IRC_BOT.handle_auth_req(req)
DATABASE["auths"][req.irc_account] = req.actor
def new_auth_req(irc_nickname, irc_account):
authid = str(uuid.uuid4())
PENDING_AUTHS[authid] = PendingAuth(irc_nickname, irc_account, None)
return authid
def set_irc_bot(bot):
global IRC_BOT
IRC_BOT = bot
logging.debug("SET IRC BOT TO: %r", bot)
def check_auth(account):
return account in DATABASE["auths"]
def fetch_auth(account):
if check_auth(account):
return DATABASE["auths"][account]
return None

217
viera/irc.py Normal file
View file

@ -0,0 +1,217 @@
import asyncio
import logging
import base64
from blinker import signal
from . import CONFIG
from .irc_envelope import RFC1459Message
from .authreqs import new_auth_req, set_irc_bot, check_auth, fetch_auth
IRC_CONFIG = CONFIG.get('irc', {})
# SASL_PAYLOAD = base64.b64encode(b'\x00'.join([IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password']]))
class IRCProtocol(asyncio.Protocol):
def connection_made(self, transport):
self.pending_actions = {}
self.caps_available = []
self.caps_requested = []
self.caps_acknowledged = []
self.transport = transport
self.recv_buffer = b''
self.send_message(verb='CAP', params=['LS'])
self.send_message(verb='NICK', params=[IRC_CONFIG['nickname']])
self.send_message(verb='USER', params=[IRC_CONFIG['username'], '*', '*', IRC_CONFIG['realname']])
def data_received(self, data):
self.recv_buffer += data
recvd = self.recv_buffer.replace(b'\r', b'').split(b'\n')
self.recv_buffer = recvd.pop(-1)
[self.line_received(m) for m in recvd]
def line_received(self, data):
data = data.decode('UTF-8', 'replace').strip('\r\n')
m = RFC1459Message.from_message(data)
self.message_received(m)
def request_caps(self, caps):
caps = [cap for cap in caps if cap in self.caps_available]
caps = [cap for cap in caps if cap not in self.caps_requested]
logging.debug('IRC: requesting caps: %r', caps)
self.caps_requested += caps
self.send_message(verb='CAP', params=['REQ', ' '.join(caps)])
def end_caps(self):
self.send_message(verb='CAP', params=['END'])
def do_blind_authenticate(self, username, password):
username = username.encode('ascii')
password = password.encode('ascii')
payload = b'\x00'.join([username, username, password])
payload = base64.b64encode(payload).decode('ascii')
self.send_message(verb='AUTHENTICATE', params=['PLAIN'])
self.send_message(verb='AUTHENTICATE', params=[payload])
def handle_cap_message(self, message):
self.cap_requested = True
if message.params[1] == 'LS':
caps = message.params[2].split()
logging.debug('IRC: available caps: %r', caps)
self.caps_available += message.params[2].split()
self.request_caps(['sasl', 'extended-join', 'account-tag', 'account-notify'])
elif message.params[1] == 'ACK':
caps = message.params[2].split()
logging.debug('IRC: acknowledged caps: %r', caps)
self.caps_acknowledged += caps
if 'sasl' in self.caps_acknowledged:
self.do_blind_authenticate(IRC_CONFIG['sasl_username'], IRC_CONFIG['sasl_password'])
else:
self.end_caps()
def join_channels(self):
self.send_message(verb='JOIN', params=[','.join(IRC_CONFIG['channels'])])
def say(self, target, message, verb='NOTICE'):
self.send_message(verb=verb, params=[target, message])
def invite(self, nickname):
[self.send_message(verb="INVITE", params=[nickname, chan]) for chan in IRC_CONFIG['channels']]
def voice(self, nickname):
[self.send_message(verb="MODE", params=[chan, "+v", nickname]) for chan in IRC_CONFIG['channels']]
def whois(self, nickname, chan, account):
data = fetch_auth(account)
if not data:
return
self.say(chan, '\x02{0}\x02: \x02{1}\x02'.format(nickname, data), verb='PRIVMSG')
def set_pending_action(self, nickname, action):
if nickname not in self.pending_actions:
self.pending_actions[nickname] = action
def process_pending_action(self, nickname, account=None):
if nickname not in self.pending_actions:
return
action = self.pending_actions.pop(nickname)
if action == 'voice':
self.voice(nickname)
elif action == 'invite':
self.invite(nickname)
elif 'whois' in action:
self.whois(nickname, action['whois'], account)
def handle_auth_req(self, req):
self.say(req.irc_nickname, "The actor \x02{0}\x02 is now linked to the IRC account \x02{1}\x02.".format(req.actor, req.irc_account))
self.set_pending_action(req.irc_nickname, 'voice')
self.process_pending_action(req.irc_nickname)
def pending_whois(self, nickname, pop=False):
if nickname not in self.pending_actions:
return False
data = self.pending_actions[nickname]
if isinstance(data, dict) and 'whois' in data:
return True
if pop:
self.pending_actions.pop(nickname)
def handle_whox(self, message):
nickname = message.params[1]
account = message.params[2]
if not check_auth(account) and not self.pending_whois(nickname, True):
auth = new_auth_req(nickname, account)
self.say(nickname, "Authentication is required for this action. In order to prove your identity, you need to send me a token via the fediverse.")
self.say(nickname, "On most platforms, posting like this will work: \x02@viera@viera.dereferenced.org {}\x02".format(auth))
self.say(nickname, "This token is ephemeral, so you can send it to me publicly if your platform does not support direct messages.")
else:
self.process_pending_action(nickname, account)
def fetch_account_whox(self, message):
source_nick = message.source.split('!')[0]
self.send_message(verb='WHO', params=[source_nick, "%na"])
def handle_private_message(self, message):
source_nick = message.source.split('!')[0]
if message.params[1] == 'auth':
self.fetch_account_whox(message)
elif message.params[1] in ('voice', 'invite'):
self.set_pending_action(source_nick, message.params[1])
self.fetch_account_whox(message)
def handle_public_message(self, message):
if not message.params[1].startswith(IRC_CONFIG['nickname']):
return
chunks = message.params[1].split()
if chunks[1] == 'whois':
self.set_pending_action(chunks[2], {'whois': message.params[0]})
self.send_message(verb='WHO', params=[chunks[2], "%na"])
def handle_chat_message(self, message):
if message.params[0] == IRC_CONFIG['nickname']:
self.handle_private_message(message)
else:
self.handle_public_message(message)
def handle_join(self, message):
source_nick = message.source.split('!')[0]
if check_auth(message.params[1]):
self.set_pending_action(source_nick, 'voice')
self.process_pending_action(source_nick, message.params[1])
def message_received(self, message):
if message.verb in ('PRIVMSG', 'NOTICE'):
self.handle_chat_message(message)
elif message.verb == '001':
self.join_channels()
elif message.verb == 'JOIN':
self.handle_join(message)
elif message.verb == 'CAP':
self.handle_cap_message(message)
elif message.verb == '354':
self.handle_whox(message)
elif message.verb == '433':
self.send_message(verb='NICK', params=[message.params[0] + '_'])
elif message.verb in ('900', '901', '902', '903', '904', '905', '906', '907'):
self.end_caps()
elif message.verb == 'PING':
self.send_message(verb='PONG', params=message.params)
elif message.verb in ('AUTHENTICATE',):
pass
else:
logging.debug('IRC: unhandled inbound message: %r', message)
def send_message(self, **kwargs):
m = RFC1459Message.from_data(**kwargs)
logging.debug('> %r', m)
self.transport.write(m.to_message().encode('utf-8') + b'\r\n')
async def irc_bot():
loop = asyncio.get_event_loop()
if 'host' not in IRC_CONFIG:
return
server = IRC_CONFIG['host']
port = IRC_CONFIG['port']
ssl = IRC_CONFIG['ssl']
transport, protocol = await loop.create_connection(IRCProtocol, host=server, port=port, ssl=ssl)
set_irc_bot(protocol)
logging.info('IRC bot ready.')

146
viera/irc_envelope.py Normal file
View file

@ -0,0 +1,146 @@
# irc_envelope.py
# Purpose: Conversion of RFC1459 messages to/from native objects.
#
# Copyright (c) 2014, William Pitcock <nenolod@dereferenced.org>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
from pprint import pprint
class RFC1459Message(object):
@classmethod
def from_data(cls, verb, params=None, source=None, tags=None):
o = cls()
o.verb = verb
o.tags = dict()
o.source = None
o.params = list()
if params:
o.params = params
if source:
o.source = source
if tags:
o.tags.update(**tags)
return o
@classmethod
def from_message(cls, message):
if isinstance(message, bytes):
message = message.decode('UTF-8', 'replace')
s = message.split(' ')
tags = None
if s[0].startswith('@'):
tag_str = s[0][1:].split(';')
s = s[1:]
tags = {}
for tag in tag_str:
if '=' in tag:
k, v = tag.split('=', 1)
tags[k] = v
else:
tags[tag] = True
source = None
if s[0].startswith(':'):
source = s[0][1:]
s = s[1:]
verb = s[0].upper()
original_params = s[1:]
params = []
while len(original_params):
# skip multiple spaces in middle of message, as per 1459
if original_params[0] == '' and len(original_params) > 1:
original_params.pop(0)
continue
elif original_params[0].startswith(':'):
arg = ' '.join(original_params)[1:]
params.append(arg)
break
else:
params.append(original_params.pop(0))
return cls.from_data(verb, params, source, tags)
def args_to_message(self):
base = []
for arg in self.params:
casted = str(arg)
if casted and ' ' not in casted and casted[0] != ':':
base.append(casted)
else:
base.append(':' + casted)
break
return ' '.join(base)
def to_message(self):
components = []
if self.tags:
components.append('@' + ';'.join([k + '=' + v for k, v in self.tags.items()]))
if self.source:
components.append(':' + self.source)
components.append(self.verb)
if self.params:
components.append(self.args_to_message())
return ' '.join(components)
def to_event(self):
return "rfc1459 message " + self.verb, self.__dict__
def serialize(self):
return self.__dict__
def __repr__(self):
return '[RFC1459Message: "{0}"]'.format(self.to_message())
def test_rfc1459message():
print('====== PARSER TESTS ======')
print(RFC1459Message.from_message('@foo=bar PRIVMSG kaniini :this is a test message!'))
print(RFC1459Message.from_message('@foo=bar :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!'))
print(RFC1459Message.from_message('PRIVMSG kaniini :this is a test message!'))
print(RFC1459Message.from_message(':irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!'))
print(RFC1459Message.from_message('CAPAB '))
print('====== STRUCTURE TESTS ======')
m = RFC1459Message.from_message('@foo=bar;bar=baz :irc.tortois.es 001 kaniini :Welcome to IRC, kaniini!')
pprint(m.serialize())
print('====== BUILDER TESTS ======')
data = {
'verb': 'PRIVMSG',
'params': ['kaniini', 'hello world!'],
'source': 'kaniini!~kaniini@localhost',
'tags': {'account-name': 'kaniini'},
}
m = RFC1459Message.from_data(**data)
print(m.to_message())
pprint(m.serialize())
print('====== ALL TESTS: PASSED ======')
if __name__ == '__main__':
test_rfc1459message()