rework first draft

This commit is contained in:
Izalia Mae 2022-04-08 17:48:27 -04:00
parent 5c5f212d70
commit 8988717377
22 changed files with 1124 additions and 867 deletions

View file

@ -9,18 +9,25 @@ port: 8080
# Note
note: "Make a note about your instance here."
# maximum number of inbox posts to do at once
post_limit: 512
# this section is for ActivityPub
ap:
# this is used for generating activitypub messages, as well as instructions for
# linking AP identities. it should be an SSL-enabled domain reachable by https.
host: 'relay.example.com'
blocked_instances:
- 'bad-instance.example.com'
- 'another-bad-instance.example.com'
whitelist_enabled: false
whitelist:
- 'good-instance.example.com'
- 'another.good-instance.example.com'
# uncomment the lines below to prevent certain activitypub software from posting
# to the relay (all known relays by default). this uses the software name in nodeinfo
#blocked_software:
@ -28,3 +35,9 @@ ap:
#- 'aoderelay'
#- 'social.seattle.wa.us-relay'
#- 'unciarelay'
# cache limits as number of items. only change this if you know what you're doing
cache:
objects: 1024
actors: 1024
digests: 1024

View file

@ -1,58 +1,24 @@
from . import logging
__version__ = '0.2.0'
from . import logger
import asyncio
import aiohttp
import aiohttp.web
import yaml
import argparse
APP = None
parser = argparse.ArgumentParser(
description="A generic LitePub relay (works with all LitePub consumers and Mastodon).",
prog="python -m relay")
parser.add_argument("-c", "--config", type=str, default="relay.yaml",
metavar="<path>", help="the path to your config file")
args = parser.parse_args()
def get_app():
return APP
def load_config():
with open(args.config) as f:
options = {}
def set_app(app):
global APP
## Prevent a warning message for pyyaml 5.1+
if getattr(yaml, 'FullLoader', None):
options['Loader'] = yaml.FullLoader
yaml_file = yaml.load(f, **options)
config = {
'db': yaml_file.get('db', 'relay.jsonld'),
'listen': yaml_file.get('listen', '0.0.0.0'),
'port': int(yaml_file.get('port', 8080)),
'note': yaml_file.get('note', 'Make a note about your instance here.'),
'ap': {
'blocked_software': [v.lower() for v in yaml_file['ap'].get('blocked_software', [])],
'blocked_instances': yaml_file['ap'].get('blocked_instances', []),
'host': yaml_file['ap'].get('host', 'localhost'),
'whitelist': yaml_file['ap'].get('whitelist', []),
'whitelist_enabled': yaml_file['ap'].get('whitelist_enabled', False)
}
}
return config
APP = app
CONFIG = load_config()
#import argparse
from .http_signatures import http_signatures_middleware
app = aiohttp.web.Application(middlewares=[
http_signatures_middleware
])
from . import database
from . import actor
from . import webfinger
from . import default
from . import nodeinfo
from . import http_stats
#parser = argparse.ArgumentParser(
#description="A generic LitePub relay (works with all LitePub consumers and Mastodon).",
#prog="python -m relay")
#parser.add_argument("-c", "--config", type=str, default="relay.yaml",
#metavar="<path>", help="the path to your config file")
#args = parser.parse_args()

View file

@ -1,56 +1,63 @@
import Crypto
import asyncio
import aiohttp.web
import logging
import platform
import sys
import Crypto
import time
from . import app, CONFIG
from aiohttp.web import AppRunner, TCPSite
from . import views
from .application import app
def crypto_check():
vers_split = platform.python_version().split('.')
pip_command = 'pip3 uninstall pycrypto && pip3 install pycryptodome'
vers_split = platform.python_version().split('.')
pip_command = 'pip3 uninstall pycrypto && pip3 install pycryptodome'
if Crypto.__version__ != '2.6.1':
return
if Crypto.__version__ != '2.6.1':
return
if int(vers_split[1]) > 7 and Crypto.__version__ == '2.6.1':
logging.error('PyCrypto is broken on Python 3.8+. Please replace it with pycryptodome before running again. Exiting in 10 sec...')
logging.error(pip_command)
time.sleep(10)
sys.exit()
if int(vers_split[1]) > 7 and Crypto.__version__ == '2.6.1':
logging.error('PyCrypto is broken on Python 3.8+. Please replace it with pycryptodome before running again. Exiting...')
logging.error(pip_command)
sys.exit()
else:
logging.warning('PyCrypto is old and should be replaced with pycryptodome')
logging.warning(pip_command)
else:
logging.warning('PyCrypto is old and should be replaced with pycryptodome')
logging.warning(pip_command)
async def start_webserver():
runner = aiohttp.web.AppRunner(app)
await runner.setup()
try:
listen = CONFIG['listen']
except:
listen = 'localhost'
try:
port = CONFIG['port']
except:
port = 8080
config = app['config']
runner = AppRunner(app, access_log_format='%{X-Forwarded-For}i "%r" %s %b "%{Referer}i" "%{User-Agent}i"')
logging.info('Starting webserver at {listen}:{port}'.format(listen=listen,port=port))
logging.info(f'Starting webserver at {config.host} ({config.listen}:{config.port})')
await runner.setup()
site = aiohttp.web.TCPSite(runner, listen, port)
await site.start()
site = TCPSite(runner, config.listen, config.port)
await site.start()
def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
asyncio.ensure_future(start_webserver(), loop=loop)
loop.run_forever()
# web pages
app.router.add_get('/', views.home)
# endpoints
app.router.add_post('/actor', views.inbox)
app.router.add_post('/inbox', views.inbox)
app.router.add_get('/actor', views.actor)
app.router.add_get('/nodeinfo/2.0.json', views.nodeinfo_2_0)
app.router.add_get('/.well-known/nodeinfo', views.nodeinfo_wellknown)
app.router.add_get('/.well-known/webfinger', views.webfinger)
if logging.DEBUG <= logging.root.level:
app.router.add_get('/stats', views.stats)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
asyncio.ensure_future(start_webserver(), loop=loop)
loop.run_forever()
if __name__ == '__main__':
crypto_check()
main()
crypto_check()
main()

View file

@ -1,347 +0,0 @@
import aiohttp
import aiohttp.web
import asyncio
import logging
import uuid
import re
import simplejson as json
import cgi
import datetime
from urllib.parse import urlsplit
from Crypto.PublicKey import RSA
from cachetools import LFUCache
from . import app, CONFIG
from .database import DATABASE
from .http_debug import http_debug
from .remote_actor import fetch_actor
from .http_signatures import sign_headers, generate_body_digest
# generate actor keys if not present
if "actorKeys" not in DATABASE:
logging.info("No actor keys present, generating 4096-bit RSA keypair.")
privkey = RSA.generate(4096)
pubkey = privkey.publickey()
DATABASE["actorKeys"] = {
"publicKey": pubkey.exportKey('PEM').decode('utf-8'),
"privateKey": privkey.exportKey('PEM').decode('utf-8')
}
PRIVKEY = RSA.importKey(DATABASE["actorKeys"]["privateKey"])
PUBKEY = PRIVKEY.publickey()
AP_CONFIG = CONFIG['ap']
CACHE_SIZE = CONFIG.get('cache-size', 16384)
CACHE = LFUCache(CACHE_SIZE)
sem = asyncio.Semaphore(500)
async def actor(request):
data = {
"@context": "https://www.w3.org/ns/activitystreams",
"endpoints": {
"sharedInbox": "https://{}/inbox".format(request.host)
},
"followers": "https://{}/followers".format(request.host),
"following": "https://{}/following".format(request.host),
"inbox": "https://{}/inbox".format(request.host),
"name": "ActivityRelay",
"type": "Application",
"id": "https://{}/actor".format(request.host),
"publicKey": {
"id": "https://{}/actor#main-key".format(request.host),
"owner": "https://{}/actor".format(request.host),
"publicKeyPem": DATABASE["actorKeys"]["publicKey"]
},
"summary": "ActivityRelay bot",
"preferredUsername": "relay",
"url": "https://{}/actor".format(request.host)
}
return aiohttp.web.json_response(data, content_type='application/activity+json')
app.router.add_get('/actor', actor)
get_actor_inbox = lambda actor: actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])
async def push_message_to_actor(actor, message, our_key_id):
inbox = get_actor_inbox(actor)
url = urlsplit(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': 'ActivityRelay',
'Host': url.netloc,
'Digest': 'SHA-256={}'.format(generate_body_digest(data)),
'Date': datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
}
headers['signature'] = sign_headers(headers, PRIVKEY, our_key_id)
headers.pop('(request-target)')
headers.pop('Host')
logging.debug('%r >> %r', inbox, message)
global sem
async with sem:
try:
async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
async with session.post(inbox, data=data, headers=headers) as resp:
if resp.status == 202:
return
resp_payload = await resp.text()
logging.debug('%r >> resp %r', inbox, resp_payload)
except Exception as e:
logging.info('Caught %r while pushing to %r.', e, inbox)
async def fetch_nodeinfo(domain):
headers = {'Accept': 'application/json'}
nodeinfo_url = None
wk_nodeinfo = await fetch_actor(f'https://{domain}/.well-known/nodeinfo', headers=headers)
if not wk_nodeinfo:
return
for link in wk_nodeinfo.get('links', ''):
if link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0':
nodeinfo_url = link['href']
break
if not nodeinfo_url:
return
nodeinfo_data = await fetch_actor(nodeinfo_url, headers=headers)
software = nodeinfo_data.get('software')
return software.get('name') if software else None
async def follow_remote_actor(actor_uri):
actor = await fetch_actor(actor_uri)
if not actor:
logging.info('failed to fetch actor at: %r', actor_uri)
return
if AP_CONFIG['whitelist_enabled'] is True and urlsplit(actor_uri).hostname not in AP_CONFIG['whitelist']:
logging.info('refusing to follow non-whitelisted actor: %r', actor_uri)
return
logging.info('following: %r', actor_uri)
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"to": [actor['id']],
"object": actor['id'],
"id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
"actor": "https://{}/actor".format(AP_CONFIG['host'])
}
await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
async def unfollow_remote_actor(actor_uri):
actor = await fetch_actor(actor_uri)
if not actor:
logging.info('failed to fetch actor at: %r', actor_uri)
return
logging.info('unfollowing: %r', actor_uri)
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Undo",
"to": [actor['id']],
"object": {
"type": "Follow",
"object": actor_uri,
"actor": actor['id'],
"id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4())
},
"id": "https://{}/activities/{}".format(AP_CONFIG['host'], uuid.uuid4()),
"actor": "https://{}/actor".format(AP_CONFIG['host'])
}
await push_message_to_actor(actor, message, "https://{}/actor#main-key".format(AP_CONFIG['host']))
tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
def strip_html(data):
no_tags = tag_re.sub('', data)
return cgi.escape(no_tags)
def distill_inboxes(actor, object_id):
global DATABASE
origin_hostname = urlsplit(object_id).hostname
inbox = get_actor_inbox(actor)
targets = [target for target in DATABASE.get('relay-list', []) if target != inbox]
targets = [target for target in targets if urlsplit(target).hostname != origin_hostname]
hostnames = [urlsplit(target).hostname for target in targets]
assert inbox not in targets
assert origin_hostname not in hostnames
return targets
def distill_object_id(activity):
logging.debug('>> determining object ID for %r', activity['object'])
obj = activity['object']
if isinstance(obj, str):
return obj
return obj['id']
async def handle_relay(actor, data, request):
global CACHE
object_id = distill_object_id(data)
if object_id in CACHE:
logging.debug('>> already relayed %r as %r', object_id, CACHE[object_id])
return
activity_id = "https://{}/activities/{}".format(request.host, uuid.uuid4())
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Announce",
"to": ["https://{}/followers".format(request.host)],
"actor": "https://{}/actor".format(request.host),
"object": object_id,
"id": activity_id
}
logging.debug('>> relay: %r', message)
inboxes = distill_inboxes(actor, object_id)
futures = [push_message_to_actor({'inbox': inbox}, message, 'https://{}/actor#main-key'.format(request.host)) for inbox in inboxes]
asyncio.ensure_future(asyncio.gather(*futures))
CACHE[object_id] = activity_id
async def handle_forward(actor, data, request):
object_id = distill_object_id(data)
logging.debug('>> Relay %r', data)
inboxes = distill_inboxes(actor, object_id)
futures = [
push_message_to_actor(
{'inbox': inbox},
data,
'https://{}/actor#main-key'.format(request.host))
for inbox in inboxes]
asyncio.ensure_future(asyncio.gather(*futures))
async def handle_follow(actor, data, request):
global DATABASE
following = DATABASE.get('relay-list', [])
inbox = get_actor_inbox(actor)
if urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
return
if inbox not in following:
following += [inbox]
DATABASE['relay-list'] = following
asyncio.ensure_future(follow_remote_actor(actor['id']))
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"to": [actor["id"]],
"actor": "https://{}/actor".format(request.host),
# 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()),
}
asyncio.ensure_future(push_message_to_actor(actor, message, 'https://{}/actor#main-key'.format(request.host)))
async def handle_undo(actor, data, request):
global DATABASE
child = data['object']
if child['type'] == 'Follow':
following = DATABASE.get('relay-list', [])
inbox = get_actor_inbox(actor)
if inbox in following:
following.remove(inbox)
DATABASE['relay-list'] = following
await unfollow_remote_actor(actor['id'])
processors = {
'Announce': handle_relay,
'Create': handle_relay,
'Delete': handle_forward,
'Follow': handle_follow,
'Undo': handle_undo,
'Update': handle_forward,
}
async def inbox(request):
data = await request.json()
instance = urlsplit(data['actor']).hostname
if AP_CONFIG['blocked_software']:
software = await fetch_nodeinfo(instance)
if software and software.lower() in AP_CONFIG['blocked_software']:
raise aiohttp.web.HTTPUnauthorized(body='relays have been blocked', content_type='text/plain')
if 'actor' not in data or not request['validated']:
raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
elif data['type'] != 'Follow' and 'https://{}/inbox'.format(instance) not in DATABASE['relay-list']:
raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
elif AP_CONFIG['whitelist_enabled'] is True and instance not in AP_CONFIG['whitelist']:
raise aiohttp.web.HTTPUnauthorized(body='access denied', content_type='text/plain')
actor = await fetch_actor(data["actor"])
actor_uri = 'https://{}/actor'.format(request.host)
logging.debug(">> payload %r", data)
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)

36
relay/application.py Normal file
View file

@ -0,0 +1,36 @@
import asyncio
import logging
import sys
from aiohttp.web import Application
from cachetools import LRUCache
from . import set_app
from . import views
from .config import DotDict, RelayConfig
from .database import RelayDatabase
from .middleware import http_signatures_middleware
app = Application(middlewares=[
http_signatures_middleware
])
app['config'] = RelayConfig('relay.yaml')
if not app['config'].load():
app['config'].save()
logging.error('Relay is not setup. Change the host in relay.yaml at the least and try again')
sys.exit(1)
app['database'] = RelayDatabase(app['config'])
app['database'].load()
app['cache'] = DotDict()
app['semaphore'] = asyncio.Semaphore(app['config']['push_limit'])
for key in app['config'].cachekeys:
app['cache'][key] = LRUCache(app['config'][key])
set_app(app)

181
relay/config.py Normal file
View file

@ -0,0 +1,181 @@
import json
import yaml
from pathlib import Path
from urllib.parse import urlparse
class DotDict(dict):
def __getattr__(self, k):
try:
return self[k]
except KeyError:
raise AttributeError(f'{self.__class__.__name__} object has no attribute {k}') from None
def __setattr__(self, k, v):
try:
if k in self._ignore_keys:
super().__setattr__(k, v)
except AttributeError:
pass
if k.startswith('_'):
super().__setattr__(k, v)
else:
self[k] = v
def __setitem__(self, k, v):
if type(v) == dict:
v = DotDict(v)
super().__setitem__(k, v)
def __delattr__(self, k):
try:
dict.__delitem__(self, k)
except KeyError:
raise AttributeError(f'{self.__class__.__name__} object has no attribute {k}') from None
class RelayConfig(DotDict):
apkeys = {
'host',
'blocked_software',
'blocked_instances',
'whitelist',
'whitelist_enabled'
}
cachekeys = {
'json',
'objects',
'digests'
}
def __init__(self, path):
super().__init__({
'db': 'relay.jsonld',
'listen': '0.0.0.0',
'port': 8080,
'note': 'Make a note about your instance here.',
'push_limit': 512,
'host': 'example.com',
'blocked_software': [],
'blocked_instances': [],
'whitelist': [],
'whitelist_enabled': False,
'json': 1024,
'objects': 1024,
'digests': 1024
})
self._path = Path(path).expanduser().resolve()
def __setitem__(self, key, value):
if key in ['blocked_instances', 'blocked_software', 'whitelist']:
assert isinstance(value, (list, set, tuple))
elif key in ['port', 'json', 'objects', 'digests']:
assert isinstance(value, (int))
elif key == 'whitelist_enabled':
assert isinstance(value, bool)
super().__setitem__(key, value)
@property
def db(self):
return Path(self['db']).expanduser().resolve()
@property
def path(self):
return self._path
@property
def actor(self):
return f'https://{self.host}/actor'
@property
def inbox(self):
return f'https://{self.host}/inbox'
@property
def keyid(self):
return f'{self.actor}#main-key'
def is_banned(self, inbox):
return urlparse(inbox).hostname in self.blocked_instances
def is_whitelisted(self, inbox):
return urlparse(inbox).hostname in self.whitelist
def load(self):
options = {}
try:
options['Loader'] = yaml.FullLoader
except AttributeError:
pass
try:
with open(self.path) as fd:
config = yaml.load(fd, **options)
except FileNotFoundError:
return False
if not config:
return False
for key, value in config.items():
if key in ['ap', 'cache']:
for k, v in value.items():
if k not in self:
continue
self[k] = v
elif key not in self:
continue
self[key] = value
if self.host == 'example.com':
return False
return True
def save(self):
config = {
'db': self.db,
'listen': self.listen,
'port': self.port,
'note': self.note,
'push_limit': self.push_limit,
'ap': {key: self[key] for key in self.apkeys},
'cache': {key: self[key] for key in self.cachekeys}
}
with open(self._path, 'w') as fd:
yaml.dump(config, fd, sort_keys=False)
return config

View file

@ -1,43 +1,126 @@
import asyncio
import json
import logging
import urllib.parse
import simplejson as json
from sys import exit
import traceback
from Crypto.PublicKey import RSA
from urllib.parse import urlparse
from . import CONFIG
AP_CONFIG = CONFIG['ap']
try:
with open(CONFIG['db']) as f:
DATABASE = json.load(f)
except FileNotFoundError:
logging.info('No database was found, making a new one.')
DATABASE = {}
except json.decoder.JSONDecodeError:
logging.info('Invalid JSON in db. Exiting...')
exit(1)
following = DATABASE.get('relay-list', [])
for inbox in following:
if urllib.parse.urlsplit(inbox).hostname in AP_CONFIG['blocked_instances']:
following.remove(inbox)
elif AP_CONFIG['whitelist_enabled'] is True and urllib.parse.urlsplit(inbox).hostname not in AP_CONFIG['whitelist']:
following.remove(inbox)
DATABASE['relay-list'] = following
if 'actors' in DATABASE:
DATABASE.pop('actors')
async def database_save():
while True:
with open(CONFIG['db'], 'w') as f:
json.dump(DATABASE, f)
await asyncio.sleep(30)
class RelayDatabase:
def __init__(self, config):
self.config = config
self.data = None
self.PRIVKEY = None
asyncio.ensure_future(database_save())
@property
def PUBKEY(self):
return self.PRIVKEY.publickey()
@property
def pubkey(self):
return self.PUBKEY.exportKey('PEM').decode('utf-8')
@property
def privkey(self):
try:
return self.data['private-key']
except KeyError:
return False
@property
def hostnames(self):
return [urlparse(inbox).hostname for inbox in self.inboxes]
@property
def inboxes(self):
return self.data.get('relay-list', [])
@property
def whitelist(self):
return self.data.get('whitelist', [])
def generate_key(self):
self.PRIVKEY = RSA.generate(4096)
self.data['private-key'] = self.PRIVKEY.exportKey('PEM').decode('utf-8')
def load(self):
new_db = True
try:
with self.config.db.open() as fd:
self.data = json.load(fd)
key = self.data.pop('actorKeys', None)
if key:
self.data['private-key'] = key.get('privateKey')
self.data.pop('actors', None)
new_db = False
except FileNotFoundError:
pass
except json.decoder.JSONDecodeError as e:
if self.config.db.stat().st_size > 0:
raise e from None
if not self.data:
logging.info('No database was found. Making a new one.')
self.data = {}
for inbox in self.inboxes:
if self.config.is_banned(inbox) or (self.config.whitelist_enabled and not self.config.is_whitelisted(inbox)):
self.del_inbox(inbox)
if not self.privkey:
logging.info("No actor keys present, generating 4096-bit RSA keypair.")
self.generate_key()
else:
self.PRIVKEY = RSA.importKey(self.privkey)
self.save()
return not new_db
def save(self):
with self.config.db.open('w') as fd:
data = {
'relay-list': self.inboxes,
'private-key': self.privkey
}
json.dump(data, fd, indent=4)
def get_inbox(self, domain):
if domain.startswith('http'):
domain = urlparse(domain).hostname
for inbox in self.inboxes:
if domain == urlparse(inbox).hostname:
return inbox
def add_inbox(self, inbox):
assert inbox.startswith('https')
assert inbox not in self.inboxes
self.data['relay-list'].append(inbox)
def del_inbox(self, inbox):
if inbox not in self.inboxes:
raise KeyError(inbox)
self.data['relay-list'].remove(inbox)

View file

@ -1,36 +0,0 @@
import aiohttp.web
import urllib.parse
from . import app, CONFIG
from .database import DATABASE
host = CONFIG['ap']['host']
note = CONFIG['note']
inboxes = DATABASE.get('relay-list', [])
async def default(request):
targets = '<br>'.join([urllib.parse.urlsplit(target).hostname for target in inboxes])
return aiohttp.web.Response(
status=200,
content_type="text/html",
charset="utf-8",
text="""
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>For Mastodon and Misskey instances, you may subscribe to this relay with the address: <a href="https://{host}/inbox">https://{host}/inbox</a></p>
<p>For Pleroma and other instances, you may subscribe to this relay with the address: <a href="https://{host}/actor">https://{host}/actor</a></p>
<p>To host your own relay, you may download the code at this address: <a href="https://git.pleroma.social/pleroma/relay">https://git.pleroma.social/pleroma/relay</a></p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>
""".format(host=host, note=note,targets=targets,count=len(inboxes)))
app.router.add_get('/', default)

View file

@ -1,6 +1,5 @@
import logging
import aiohttp
import aiohttp.web
from collections import defaultdict
@ -59,8 +58,11 @@ async def on_request_exception(session, trace_config_ctx, params):
def http_debug():
if logging.DEBUG <= logging.root.level:
return
trace_config = aiohttp.TraceConfig()
trace_config.on_request_start.append(on_request_start)
trace_config.on_request_end.append(on_request_end)
trace_config.on_request_exception.append(on_request_exception)
return trace_config
return [trace_config]

View file

@ -1,148 +0,0 @@
import aiohttp
import aiohttp.web
import base64
import logging
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA, SHA256, SHA512
from Crypto.Signature import PKCS1_v1_5
from cachetools import LFUCache
from async_lru import alru_cache
from .remote_actor import fetch_actor
HASHES = {
'sha1': SHA,
'sha256': SHA256,
'sha512': SHA512
}
def split_signature(sig):
default = {"headers": "date"}
sig = sig.strip().split(',')
for chunk in sig:
k, _, v = chunk.partition('=')
v = v.strip('\"')
default[k] = v
default['headers'] = default['headers'].split()
return default
def build_signing_string(headers, used_headers):
return '\n'.join(map(lambda x: ': '.join([x.lower(), headers[x]]), used_headers))
SIGSTRING_CACHE = LFUCache(1024)
def sign_signing_string(sigstring, key):
if sigstring in SIGSTRING_CACHE:
return SIGSTRING_CACHE[sigstring]
pkcs = PKCS1_v1_5.new(key)
h = SHA256.new()
h.update(sigstring.encode('ascii'))
sigdata = pkcs.sign(h)
sigdata = base64.b64encode(sigdata)
SIGSTRING_CACHE[sigstring] = sigdata.decode('ascii')
return SIGSTRING_CACHE[sigstring]
def generate_body_digest(body):
bodyhash = SIGSTRING_CACHE.get(body)
if not bodyhash:
h = SHA256.new(body.encode('utf-8'))
bodyhash = base64.b64encode(h.digest()).decode('utf-8')
SIGSTRING_CACHE[body] = bodyhash
return bodyhash
def sign_headers(headers, key, key_id):
headers = {x.lower(): y for x, y in headers.items()}
used_headers = headers.keys()
sig = {
'keyId': key_id,
'algorithm': 'rsa-sha256',
'headers': ' '.join(used_headers)
}
sigstring = build_signing_string(headers, used_headers)
sig['signature'] = sign_signing_string(sigstring, key)
chunks = ['{}="{}"'.format(k, v) for k, v in sig.items()]
return ','.join(chunks)
@alru_cache(maxsize=16384)
async def fetch_actor_key(actor):
actor_data = await fetch_actor(actor)
if not actor_data:
return None
try:
return RSA.importKey(actor_data['publicKey']['publicKeyPem'])
except Exception as e:
logging.debug(f'Exception occured while fetching actor key: {e}')
async def validate(actor, request):
pubkey = await fetch_actor_key(actor)
if not pubkey:
return False
logging.debug('actor key: %r', pubkey)
headers = request.headers.copy()
headers['(request-target)'] = ' '.join([request.method.lower(), request.path])
sig = split_signature(headers['signature'])
logging.debug('sigdata: %r', sig)
sigstring = build_signing_string(headers, sig['headers'])
logging.debug('sigstring: %r', sigstring)
sign_alg, _, hash_alg = sig['algorithm'].partition('-')
logging.debug('sign alg: %r, hash alg: %r', sign_alg, hash_alg)
sigdata = base64.b64decode(sig['signature'])
pkcs = PKCS1_v1_5.new(pubkey)
h = HASHES[hash_alg].new()
h.update(sigstring.encode('ascii'))
result = pkcs.verify(h, sigdata)
request['validated'] = result
logging.debug('validates? %r', result)
return result
async def http_signatures_middleware(app, handler):
async def http_signatures_handler(request):
request['validated'] = False
if 'signature' in request.headers and request.method == 'POST':
data = await request.json()
if 'actor' not in data:
raise aiohttp.web.HTTPUnauthorized(body='signature check failed, no actor in message')
actor = data["actor"]
if not (await validate(actor, request)):
logging.info('Signature validation failed for: %r', actor)
raise aiohttp.web.HTTPUnauthorized(body='signature check failed, signature did not match key')
return (await handler(request))
return (await handler(request))
return http_signatures_handler

View file

@ -1,11 +0,0 @@
import aiohttp.web
from . import app
from .http_debug import STATS
async def stats(request):
return aiohttp.web.json_response(STATS)
app.router.add_get('/stats', stats)

34
relay/logger.py Normal file
View file

@ -0,0 +1,34 @@
import logging
import os
## Add the verbose logging level
def verbose(message, *args, **kwargs):
if not logging.root.isEnabledFor(logging.VERBOSE):
return
logging.log(logging.VERBOSE, message, *args, **kwargs)
setattr(logging, 'verbose', verbose)
setattr(logging, 'VERBOSE', 15)
logging.addLevelName(15, 'VERBOSE')
## Get log level from environment if possible
env_log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
## Make sure the level from the environment is valid
try:
log_level = getattr(logging, env_log_level)
except AttributeError:
log_level = logging.INFO
## Set logging config
logging.basicConfig(
level = log_level,
format = "[%(asctime)s] %(levelname)s: %(message)s",
handlers = [logging.StreamHandler()]
)

View file

@ -1,8 +0,0 @@
import logging
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s: %(message)s",
handlers=[logging.StreamHandler()]
)

View file

@ -2,82 +2,79 @@ import asyncio
import sys
import simplejson as json
from .actor import follow_remote_actor, unfollow_remote_actor
from . import CONFIG
from .database import DATABASE
from .application import app
from .misc import follow_remote_actor, unfollow_remote_actor
def relay_list():
print('Connected to the following instances or relays:')
[print('-', relay) for relay in DATABASE['relay-list']]
print('Connected to the following instances or relays:')
[print('-', inbox) for inbox in app['database'].inboxes]
def relay_follow():
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage follow <target>')
exit()
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage follow <target>')
exit()
target = sys.argv[2]
target = sys.argv[2]
loop = asyncio.get_event_loop()
loop.run_until_complete(follow_remote_actor(target))
loop = asyncio.get_event_loop()
loop.run_until_complete(follow_remote_actor(target))
print('Sent follow message to:', target)
print('Sent follow message to:', target)
def relay_unfollow():
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage unfollow <target>')
exit()
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage unfollow <target>')
exit()
target = sys.argv[2]
target = sys.argv[2]
loop = asyncio.get_event_loop()
loop.run_until_complete(unfollow_remote_actor(target))
loop = asyncio.get_event_loop()
loop.run_until_complete(unfollow_remote_actor(target))
print('Sent unfollow message to:', target)
print('Sent unfollow message to:', target)
def relay_forceremove():
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage force-remove <target>')
exit()
if len(sys.argv) < 3:
print('usage: python3 -m relay.manage force-remove <target>')
exit()
target = sys.argv[2]
target = sys.argv[2]
following = DATABASE.get('relay-list', [])
try:
app['database'].del_inbox(target)
print('Removed inbox from DB:', target)
if target in following:
following.remove(target)
DATABASE['relay-list'] = following
with open('relay.jsonld', 'w') as f:
json.dump(DATABASE, f)
print('Removed target from DB:', target)
except KeyError:
print('Failed to remove inbox from DB:', target)
TASKS = {
'list': relay_list,
'follow': relay_follow,
'unfollow': relay_unfollow,
'force-remove': relay_forceremove
'list': relay_list,
'follow': relay_follow,
'unfollow': relay_unfollow,
'force-remove': relay_forceremove
}
def usage():
print('usage: python3 -m relay.manage <task> [...]')
print('tasks:')
[print('-', task) for task in TASKS.keys()]
exit()
print('usage: python3 -m relay.manage <task> [...]')
print('tasks:')
[print('-', task) for task in TASKS.keys()]
exit()
def main():
if len(sys.argv) < 2:
usage()
if len(sys.argv) < 2:
usage()
if sys.argv[1] in TASKS:
TASKS[sys.argv[1]]()
else:
usage()
if sys.argv[1] in TASKS:
TASKS[sys.argv[1]]()
else:
usage()
if __name__ == '__main__':
main()
main()

43
relay/middleware.py Normal file
View file

@ -0,0 +1,43 @@
import logging
from aiohttp.web import HTTPUnauthorized
from json.decoder import JSONDecodeError
from . import misc
async def http_signatures_middleware(app, handler):
async def http_signatures_handler(request):
request['validated'] = False
request['actor'] = None
try:
request['data'] = await request.json()
if app['config'].is_banned(request['data']['actor']):
raise HTTPUnauthorized(body='banned')
except JSONDecodeError:
request['data'] = None
if 'signature' in request.headers and request.method == 'POST':
if 'actor' not in request['data']:
raise HTTPUnauthorized(body='signature check failed, no actor in message')
request['actor'] = await misc.request(request['data']['actor'])
if not request['actor']:
logging.warning('Failed to fetch actor:', request['data']['actor'])
raise HTTPUnauthorized('failed to fetch actor')
actor_id = request['actor']['id']
if not (await misc.validate_signature(actor_id, request)):
logging.warning(f'signature validation failed for: {actor_id}')
raise HTTPUnauthorized(body='signature check failed, signature did not match key')
return (await handler(request))
return (await handler(request))
return http_signatures_handler

318
relay/misc.py Normal file
View file

@ -0,0 +1,318 @@
import asyncio
import base64
import json
import logging
import traceback
from Crypto.Hash import SHA, SHA256, SHA512
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from aiohttp import ClientSession
from datetime import datetime
from urllib.parse import urlparse
from uuid import uuid4
from . import get_app
from .http_debug import http_debug
HASHES = {
'sha1': SHA,
'sha256': SHA256,
'sha512': SHA512
}
def build_signing_string(headers, used_headers):
return '\n'.join(map(lambda x: ': '.join([x.lower(), headers[x]]), used_headers))
def create_signature_header(headers):
headers = {k.lower(): v for k, v in headers.items()}
used_headers = headers.keys()
sigstring = build_signing_string(headers, used_headers)
sig = {
'keyId': get_app()['config'].keyid,
'algorithm': 'rsa-sha256',
'headers': ' '.join(used_headers),
'signature': sign_signing_string(sigstring, get_app()['database'].PRIVKEY)
}
chunks = ['{}="{}"'.format(k, v) for k, v in sig.items()]
return ','.join(chunks)
def distill_object_id(activity):
logging.debug('>> determining object ID for', activity['object'])
try:
return activity['object']['id']
except TypeError:
return activity['object']
def distill_inboxes(actor, object_id):
database = get_app()['database']
origin_hostname = urlparse(object_id).hostname
actor_inbox = get_actor_inbox(actor)
targets = []
for inbox in database.inboxes:
if inbox != actor_inbox or urlparse(inbox).hostname != original_hostname:
targets.append(inbox)
return targets
def generate_body_digest(body):
bodyhash = get_app()['cache'].digests.get(body)
if bodyhash:
return bodyhash
h = SHA256.new(body.encode('utf-8'))
bodyhash = base64.b64encode(h.digest()).decode('utf-8')
get_app()['cache'].digests[body] = bodyhash
return bodyhash
def get_actor_inbox(actor):
return actor.get('endpoints', {}).get('sharedInbox', actor['inbox'])
def sign_signing_string(sigstring, key):
pkcs = PKCS1_v1_5.new(key)
h = SHA256.new()
h.update(sigstring.encode('ascii'))
sigdata = pkcs.sign(h)
return base64.b64encode(sigdata).decode('utf-8')
def split_signature(sig):
default = {"headers": "date"}
sig = sig.strip().split(',')
for chunk in sig:
k, _, v = chunk.partition('=')
v = v.strip('\"')
default[k] = v
default['headers'] = default['headers'].split()
return default
async def fetch_actor_key(actor):
actor_data = await request(actor)
if not actor_data:
return None
try:
return RSA.importKey(actor_data['publicKey']['publicKeyPem'])
except Exception as e:
logging.debug(f'Exception occured while fetching actor key: {e}')
async def fetch_nodeinfo(domain):
nodeinfo_url = None
wk_nodeinfo = await request(f'https://{domain}/.well-known/nodeinfo', sign=False)
if not wk_nodeinfo:
return
for link in wk_nodeinfo.get('links', ''):
if link['rel'] == 'http://nodeinfo.diaspora.software/ns/schema/2.0':
nodeinfo_url = link['href']
break
if not nodeinfo_url:
return
nodeinfo_data = await request(nodeinfo_url, sign=False)
try:
return nodeinfo_data['software']['name']
except KeyError:
return False
async def follow_remote_actor(actor_uri):
config = get_app()['config']
database = get_app()['database']
if database.get_inbox(urlparse(actor_uri).hostname):
logging.warning('already following actor:', actor_uri)
return
actor = await request(actor_uri)
inbox = get_actor_inbox(actor)
if not actor:
logging.error(f'failed to fetch actor at: {actor_uri}')
return
if config.whitelist_enabled and config.is_whitelisted(actor_uri):
logging.error(f'refusing to follow non-whitelisted actor: {actor_uri}')
return
logging.info(f'sending follow request: {actor_uri}')
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"to": [actor['id']],
"object": actor['id'],
"id": f"https://{config.host}/activities/{uuid4()}",
"actor": f"https://{config.host}/actor"
}
await request(actor, message)
async def unfollow_remote_actor(actor_uri):
config = get_app()['config']
database = get_app()['database']
if not database.get_inbox(urlparse(actor_uri).hostname):
logging.warning('not following actor:', actor_uri)
return
actor = await fetch_actor(actor_uri)
if not actor:
logging.info(f'failed to fetch actor at: {actor_uri}')
return
inbox = get_actor_inbox(actor)
logging.info(f'sending unfollow request: {actor_uri}')
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Undo",
"to": [actor_uri],
"object": {
"type": "Follow",
"object": actor_uri,
"actor": actor_uri,
"id": f"https://{config.host}/activities/{uuid4()}"
},
"id": f"https://{config.host}/activities/{uuid4()}",
"actor": f"https://{config.host}/actor"
}
await request(inbox, message)
async def request(uri, data=None, force=False, sign_headers=True):
## If a get request and not force, try to use the cache first
if not data and not force:
try:
return get_app()['cache'].json[uri]
except KeyError:
pass
url = urlparse(uri)
method = 'POST' if data else 'GET'
headers = {'User-Agent': 'ActivityRelay'}
## Set the content type for a POST
if data and 'Content-Type' not in headers:
headers['Content-Type'] = 'application/activity+json'
## Set the accepted content type for a GET
elif not data and 'Accept' not in headers:
headers['Accept'] = 'application/activity+json'
if sign_headers:
signing_headers = {
'(request-target)': f'{method.lower()} {url.path}',
'Date': datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'),
'Host': url.netloc
}
if data:
assert isinstance(data, dict)
action = data.get('type')
data = json.dumps(data)
signing_headers.update({
'Digest': f'SHA-256={generate_body_digest(data)}',
'Content-Length': str(len(data.encode('utf-8')))
})
signing_headers['Signature'] = create_signature_header(signing_headers)
del signing_headers['(request-target)']
del signing_headers['Host']
headers.update(signing_headers)
try:
async with ClientSession(trace_configs=http_debug()) as session, get_app()['semaphore']:
async with session.request(method, uri, headers=headers, data=data) as resp:
if resp.status not in [200, 202]:
resp_payload = await resp.text(encoding='utf-8')
if not data:
logging.verbose(f'Received error when requesting {uri}: {resp.status} {resp_payload}')
return
logging.verbose(f'Received error when sending {action} to {uri}: {resp.status} {resp_payload}')
return
try:
resp_payload = await resp.json(encoding='utf-8', content_type=None)
except:
return
logging.debug(f'{uri} >> resp {resp_payload}')
get_app()['cache'].json[uri] = resp_payload
return resp_payload
except Exception as e:
traceback.print_exc()
return None
async def validate_signature(actor, http_request):
pubkey = await fetch_actor_key(actor)
if not pubkey:
return False
logging.debug(f'actor key: {pubkey}')
headers = {key.lower(): value for key, value in http_request.headers.items()}
headers['(request-target)'] = ' '.join([http_request.method.lower(), http_request.path])
sig = split_signature(headers['signature'])
logging.debug(f'sigdata: {sig}')
sigstring = build_signing_string(headers, sig['headers'])
logging.debug(f'sigstring: {sigstring}')
sign_alg, _, hash_alg = sig['algorithm'].partition('-')
logging.debug(f'sign alg: {sign_alg}, hash alg: {hash_alg}')
sigdata = base64.b64decode(sig['signature'])
pkcs = PKCS1_v1_5.new(pubkey)
h = HASHES[hash_alg].new()
h.update(sigstring.encode('ascii'))
result = pkcs.verify(h, sigdata)
http_request['validated'] = result
logging.debug(f'validates? {result}')
return result

View file

@ -1,67 +0,0 @@
import subprocess
import urllib.parse
import aiohttp.web
from . import app
from .database import DATABASE
try:
commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii')
except:
commit_label = '???'
nodeinfo_template = {
# XXX - is this valid for a relay?
'openRegistrations': True,
'protocols': ['activitypub'],
'services': {
'inbound': [],
'outbound': []
},
'software': {
'name': 'activityrelay',
'version': '0.1 {}'.format(commit_label)
},
'usage': {
'localPosts': 0,
'users': {
'total': 1
}
},
'version': '2.0'
}
def get_peers():
global DATABASE
return [urllib.parse.urlsplit(inbox).hostname for inbox in DATABASE.get('relay-list', [])]
async def nodeinfo_2_0(request):
data = nodeinfo_template.copy()
data['metadata'] = {
'peers': get_peers()
}
return aiohttp.web.json_response(data)
app.router.add_get('/nodeinfo/2.0.json', nodeinfo_2_0)
async def nodeinfo_wellknown(request):
data = {
'links': [
{
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href': 'https://{}/nodeinfo/2.0.json'.format(request.host)
}
]
}
return aiohttp.web.json_response(data)
app.router.add_get('/.well-known/nodeinfo', nodeinfo_wellknown)

112
relay/processors.py Normal file
View file

@ -0,0 +1,112 @@
import asyncio
import logging
from uuid import uuid4
from . import misc
from .application import app
from .misc import distill_inboxes, distill_object_id, request
async def handle_relay(actor, data, request):
cache = app['cache'].objects
object_id = misc.distill_object_id(data)
if object_id in cache:
logging.debug(f'>> already relayed {object_id} as {cache[object_id]}')
return
activity_id = f"https://{request.host}/activities/{uuid.uuid4()}"
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Announce",
"to": [f"https://{request.host}/followers"],
"actor": f"https://{request.host}/actor",
"object": object_id,
"id": activity_id
}
logging.debug(f'>> relay: {message}')
inboxes = misc.distill_inboxes(actor['id'], object_id)
futures = [misc.request(inbox, data=message) for inbox in inboxes]
asyncio.ensure_future(asyncio.gather(*futures))
cache[object_id] = activity_id
async def handle_forward(actor, data, request):
cache = app['cache'].objects
object_id = misc.distill_object_id(data)
if object_id in cache:
logging.debug(f'>> already forwarded {object_id}.')
return
logging.debug(f'>> Relay {data}')
inboxes = misc.distill_inboxes(actor['id'], object_id)
futures = [misc.request(inbox, data=data) for inbox in inboxes]
asyncio.ensure_future(asyncio.gather(*futures))
cache[object_id] = object_id
async def handle_follow(actor, data, request):
config = app['config']
database = app['database']
inbox = misc.get_actor_inbox(actor)
if inbox not in database.inboxes:
database.add_inbox(inbox)
asyncio.ensure_future(follow_remote_actor(actor['id']))
message = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"to": [actor["id"]],
"actor": config.actor,
# this is wrong per litepub, but mastodon < 2.4 is not compliant with that profile.
"object": {
"type": "Follow",
"id": data["id"],
"object": config.actor,
"actor": actor["id"]
},
"id": f"https://{request.host}/activities/{uuid4()}",
}
asyncio.ensure_future(misc.request(inbox, message))
async def handle_undo(actor, data, request):
if data['object']['type'] != 'Follow':
return
inbox = app['database'].get_inbox(actor['id'])
if not inbox:
return
app['database'].del_inbox(inbox)
await misc.unfollow_remote_actor(actor['id'])
processors = {
'Announce': handle_relay,
'Create': handle_relay,
'Delete': handle_forward,
'Follow': handle_follow,
'Undo': handle_undo,
'Update': handle_forward,
}
async def run_processor(request, data, actor):
return await processors[data['type']](actor, data, request)

View file

@ -1,56 +0,0 @@
import logging
import aiohttp
from cachetools import TTLCache
from datetime import datetime
from urllib.parse import urlsplit
from . import CONFIG
from .http_debug import http_debug
CACHE_SIZE = CONFIG.get('cache-size', 16384)
CACHE_TTL = CONFIG.get('cache-ttl', 3600)
ACTORS = TTLCache(CACHE_SIZE, CACHE_TTL)
async def fetch_actor(uri, headers={}, force=False, sign_headers=True):
if uri in ACTORS and not force:
return ACTORS[uri]
from .actor import PRIVKEY
from .http_signatures import sign_headers
url = urlsplit(uri)
key_id = 'https://{}/actor#main-key'.format(CONFIG['ap']['host'])
headers.update({
'Accept': 'application/activity+json',
'User-Agent': 'ActivityRelay'
})
if sign_headers:
headers.update({
'(request-target)': 'get {}'.format(url.path),
'Date': datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'),
'Host': url.netloc
})
headers['signature'] = sign_headers(headers, PRIVKEY, key_id)
headers.pop('(request-target)')
headers.pop('Host')
try:
async with aiohttp.ClientSession(trace_configs=[http_debug()]) as session:
async with session.get(uri, headers=headers) as resp:
if resp.status != 200:
return None
ACTORS[uri] = (await resp.json(encoding='utf-8', content_type=None))
return ACTORS[uri]
except Exception as e:
logging.info('Caught %r while fetching actor %r.', e, uri)
return None

161
relay/views.py Normal file
View file

@ -0,0 +1,161 @@
import logging
import subprocess
from aiohttp.web import HTTPUnauthorized, Response, json_response
from . import __version__
from .application import app
from .http_debug import STATS
from .misc import request
from .processors import run_processor
try:
commit_label = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode('ascii')
except:
commit_label = '???'
async def home(request):
targets = '<br>'.join(app['database'].hostnames)
text = """
<html><head>
<title>ActivityPub Relay at {host}</title>
<style>
p {{ color: #FFFFFF; font-family: monospace, arial; font-size: 100%; }}
body {{ background-color: #000000; }}
a {{ color: #26F; }}
a:visited {{ color: #46C; }}
a:hover {{ color: #8AF; }}
</style>
</head>
<body>
<p>This is an Activity Relay for fediverse instances.</p>
<p>{note}</p>
<p>You may subscribe to this relay with the address: <a href="https://{host}/actor">https://{host}/actor</a></p>
<p>To host your own relay, you may download the code at this address: <a href="https://git.pleroma.social/pleroma/relay">https://git.pleroma.social/pleroma/relay</a></p>
<br><p>List of {count} registered instances:<br>{targets}</p>
</body></html>""".format(host=request.host, note=app['config'].note, targets=targets, count=len(app['database'].inboxes))
return Response(
status = 200,
content_type = 'text/html',
charset = 'utf-8',
text = text
)
async def actor(request):
database = app['database']
data = {
"@context": "https://www.w3.org/ns/activitystreams",
"endpoints": {
"sharedInbox": f"https://{request.host}/inbox"
},
"followers": f"https://{request.host}/followers",
"following": f"https://{request.host}/following",
"inbox": f"https://{request.host}/inbox",
"name": "ActivityRelay",
"type": "Application",
"id": f"https://{request.host}/actor",
"publicKey": {
"id": f"https://{request.host}/actor#main-key",
"owner": f"https://{request.host}/actor",
"publicKeyPem": database.pubkey
},
"summary": "ActivityRelay bot",
"preferredUsername": "relay",
"url": f"https://{request.host}/actor"
}
return json_response(data, content_type='application/activity+json')
async def inbox(request):
config = app['config']
database = app['database']
if len(config.blocked_software):
software = await fetch_nodeinfo(instance)
if software and software.lower() in config.blocked_software:
raise HTTPUnauthorized(body='relays have been blocked', content_type='text/plain')
if not request['data'] or not request['validated']:
raise HTTPUnauthorized(body='access denied', content_type='text/plain')
if request['data']['type'] != 'Follow' and request['actor']['id'] not in database.inboxes:
raise HTTPUnauthorized(body='access denied', content_type='text/plain')
elif config.whitelist_enabled and not request['data'].is_whitelisted(request['actor']['id']):
raise HTTPUnauthorized(body='access denied', content_type='text/plain')
logging.debug(f">> payload {request['data']}")
await run_processor(request, request['data'], request['actor'])
return Response(body=b'{}', content_type='application/activity+json')
async def webfinger(request):
config = app['config']
subject = request.query['resource']
if subject != f'acct:relay@{request.host}':
return json_response({'error': 'user not found'}, status=404)
data = {
'subject': subject,
'aliases': [config.actor],
'links': [
{'href': config.actor, 'rel': 'self', 'type': 'application/activity+json'},
{'href': config.actor, 'rel': 'self', 'type': 'application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"'}
]
}
return json_response(data)
async def nodeinfo_2_0(request):
data = {
# XXX - is this valid for a relay?
'openRegistrations': True,
'protocols': ['activitypub'],
'services': {
'inbound': [],
'outbound': []
},
'software': {
'name': 'activityrelay',
'version': f'{__version__} {commit_label}'
},
'usage': {
'localPosts': 0,
'users': {
'total': 1
}
},
'metadata': {
'peers': app['database'].hostnames
},
'version': '2.0'
}
return json_response(data)
async def nodeinfo_wellknown(request):
data = {
'links': [
{
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href': f'https://{request.host}/nodeinfo/2.0.json'
}
]
}
return json_response(data)
async def stats(request):
return json_response(STATS)

View file

@ -1,24 +0,0 @@
import aiohttp.web
from . import app
async def webfinger(request):
subject = request.query['resource']
if subject != 'acct:relay@{}'.format(request.host):
return aiohttp.web.json_response({'error': 'user not found'}, status=404)
actor_uri = "https://{}/actor".format(request.host)
data = {
"aliases": [actor_uri],
"links": [
{"href": actor_uri, "rel": "self", "type": "application/activity+json"},
{"href": actor_uri, "rel": "self", "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}
],
"subject": subject
}
return aiohttp.web.json_response(data)
app.router.add_get('/.well-known/webfinger', webfinger)

View file

@ -1,5 +1,6 @@
[metadata]
name = relay
version = 0.1.0
description = Generic LitePub relay (works with all LitePub consumers and Mastodon)
long_description = file: README.md
long_description_content_type = text/markdown; charset=UTF-8