From 89887173778ad13e1d9789db091d1b1245b9c191 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 8 Apr 2022 17:48:27 -0400 Subject: [PATCH 01/11] rework first draft --- relay.yaml.example | 13 ++ relay/__init__.py | 66 ++------ relay/__main__.py | 77 +++++---- relay/actor.py | 347 --------------------------------------- relay/application.py | 36 ++++ relay/config.py | 181 ++++++++++++++++++++ relay/database.py | 159 +++++++++++++----- relay/default.py | 36 ---- relay/http_debug.py | 6 +- relay/http_signatures.py | 148 ----------------- relay/http_stats.py | 11 -- relay/logger.py | 34 ++++ relay/logging.py | 8 - relay/manage.py | 87 +++++----- relay/middleware.py | 43 +++++ relay/misc.py | 318 +++++++++++++++++++++++++++++++++++ relay/nodeinfo.py | 67 -------- relay/processors.py | 112 +++++++++++++ relay/remote_actor.py | 56 ------- relay/views.py | 161 ++++++++++++++++++ relay/webfinger.py | 24 --- setup.cfg | 1 + 22 files changed, 1124 insertions(+), 867 deletions(-) delete mode 100644 relay/actor.py create mode 100644 relay/application.py create mode 100644 relay/config.py delete mode 100644 relay/default.py delete mode 100644 relay/http_signatures.py delete mode 100644 relay/http_stats.py create mode 100644 relay/logger.py delete mode 100644 relay/logging.py create mode 100644 relay/middleware.py create mode 100644 relay/misc.py delete mode 100644 relay/nodeinfo.py create mode 100644 relay/processors.py delete mode 100644 relay/remote_actor.py create mode 100644 relay/views.py delete mode 100644 relay/webfinger.py diff --git a/relay.yaml.example b/relay.yaml.example index 68f0016..26749e4 100644 --- a/relay.yaml.example +++ b/relay.yaml.example @@ -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 diff --git a/relay/__init__.py b/relay/__init__.py index d4e39f8..ba80f3d 100644 --- a/relay/__init__.py +++ b/relay/__init__.py @@ -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="", 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="", help="the path to your config file") +#args = parser.parse_args() diff --git a/relay/__main__.py b/relay/__main__.py index 7d27c42..cbe5535 100644 --- a/relay/__main__.py +++ b/relay/__main__.py @@ -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() diff --git a/relay/actor.py b/relay/actor.py deleted file mode 100644 index aba72ea..0000000 --- a/relay/actor.py +++ /dev/null @@ -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) diff --git a/relay/application.py b/relay/application.py new file mode 100644 index 0000000..c4d971d --- /dev/null +++ b/relay/application.py @@ -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) diff --git a/relay/config.py b/relay/config.py new file mode 100644 index 0000000..4521ed2 --- /dev/null +++ b/relay/config.py @@ -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 diff --git a/relay/database.py b/relay/database.py index 03e2719..0b81604 100644 --- a/relay/database.py +++ b/relay/database.py @@ -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) diff --git a/relay/default.py b/relay/default.py deleted file mode 100644 index 38053b8..0000000 --- a/relay/default.py +++ /dev/null @@ -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 = '
'.join([urllib.parse.urlsplit(target).hostname for target in inboxes]) - return aiohttp.web.Response( - status=200, - content_type="text/html", - charset="utf-8", - text=""" - - ActivityPub Relay at {host} - - - -

This is an Activity Relay for fediverse instances.

-

{note}

-

For Mastodon and Misskey instances, you may subscribe to this relay with the address: https://{host}/inbox

-

For Pleroma and other instances, you may subscribe to this relay with the address: https://{host}/actor

-

To host your own relay, you may download the code at this address: https://git.pleroma.social/pleroma/relay

-

List of {count} registered instances:
{targets}

- - -""".format(host=host, note=note,targets=targets,count=len(inboxes))) - -app.router.add_get('/', default) diff --git a/relay/http_debug.py b/relay/http_debug.py index e6e5ab4..a3f7515 100644 --- a/relay/http_debug.py +++ b/relay/http_debug.py @@ -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] diff --git a/relay/http_signatures.py b/relay/http_signatures.py deleted file mode 100644 index 377f468..0000000 --- a/relay/http_signatures.py +++ /dev/null @@ -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 diff --git a/relay/http_stats.py b/relay/http_stats.py deleted file mode 100644 index 09193c9..0000000 --- a/relay/http_stats.py +++ /dev/null @@ -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) diff --git a/relay/logger.py b/relay/logger.py new file mode 100644 index 0000000..6351bfc --- /dev/null +++ b/relay/logger.py @@ -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()] +) diff --git a/relay/logging.py b/relay/logging.py deleted file mode 100644 index f7d0977..0000000 --- a/relay/logging.py +++ /dev/null @@ -1,8 +0,0 @@ -import logging - - -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s", - handlers=[logging.StreamHandler()] -) diff --git a/relay/manage.py b/relay/manage.py index 4912fb1..152bd33 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -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 ') - exit() + if len(sys.argv) < 3: + print('usage: python3 -m relay.manage follow ') + 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 ') - exit() + if len(sys.argv) < 3: + print('usage: python3 -m relay.manage unfollow ') + 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 ') - exit() + if len(sys.argv) < 3: + print('usage: python3 -m relay.manage force-remove ') + 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 [...]') - print('tasks:') - [print('-', task) for task in TASKS.keys()] - exit() + print('usage: python3 -m relay.manage [...]') + 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() diff --git a/relay/middleware.py b/relay/middleware.py new file mode 100644 index 0000000..c3840a3 --- /dev/null +++ b/relay/middleware.py @@ -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 diff --git a/relay/misc.py b/relay/misc.py new file mode 100644 index 0000000..a28bef4 --- /dev/null +++ b/relay/misc.py @@ -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 diff --git a/relay/nodeinfo.py b/relay/nodeinfo.py deleted file mode 100644 index 5556e14..0000000 --- a/relay/nodeinfo.py +++ /dev/null @@ -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) diff --git a/relay/processors.py b/relay/processors.py new file mode 100644 index 0000000..7b8802e --- /dev/null +++ b/relay/processors.py @@ -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) diff --git a/relay/remote_actor.py b/relay/remote_actor.py deleted file mode 100644 index 2e4b92e..0000000 --- a/relay/remote_actor.py +++ /dev/null @@ -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 diff --git a/relay/views.py b/relay/views.py new file mode 100644 index 0000000..1e22e60 --- /dev/null +++ b/relay/views.py @@ -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 = '
'.join(app['database'].hostnames) + text = """ + +ActivityPub Relay at {host} + + + +

This is an Activity Relay for fediverse instances.

+

{note}

+

You may subscribe to this relay with the address: https://{host}/actor

+

To host your own relay, you may download the code at this address: https://git.pleroma.social/pleroma/relay

+

List of {count} registered instances:
{targets}

+""".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) diff --git a/relay/webfinger.py b/relay/webfinger.py deleted file mode 100644 index e6145a0..0000000 --- a/relay/webfinger.py +++ /dev/null @@ -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) diff --git a/setup.cfg b/setup.cfg index ee4acad..43c041c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 From 0808414cb70815c5338c5e97f24d7d746e67aebb Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 8 Apr 2022 22:05:02 -0400 Subject: [PATCH 02/11] rework second draft --- relay/database.py | 5 ----- relay/middleware.py | 16 +++++++++++----- relay/misc.py | 22 +++++----------------- relay/processors.py | 10 +++++++--- relay/views.py | 17 ++++++++++++----- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/relay/database.py b/relay/database.py index 0b81604..3582360 100644 --- a/relay/database.py +++ b/relay/database.py @@ -42,11 +42,6 @@ class RelayDatabase: 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') diff --git a/relay/middleware.py b/relay/middleware.py index c3840a3..d1f458c 100644 --- a/relay/middleware.py +++ b/relay/middleware.py @@ -2,38 +2,44 @@ import logging from aiohttp.web import HTTPUnauthorized from json.decoder import JSONDecodeError +from urllib.parse import urlparse from . import misc +# This will probably get merged into views.inbox async def http_signatures_middleware(app, handler): async def http_signatures_handler(request): request['validated'] = False request['actor'] = None + request['actor_domain'] = 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']: + logging.verbose('Actor not in data') raise HTTPUnauthorized(body='signature check failed, no actor in message') + if app['config'].is_banned(request['data']['actor']): + logging.verbose(f'Ignored request from banned actor: {request["actor"]["id"]}') + raise HTTPUnauthorized(body='banned') + request['actor'] = await misc.request(request['data']['actor']) if not request['actor']: - logging.warning('Failed to fetch actor:', request['data']['actor']) + logging.verbose(f'Failed to fetch actor: {request["actor"]["id"]}') raise HTTPUnauthorized('failed to fetch actor') actor_id = request['actor']['id'] + request['actor_domain'] = urlparse(actor_id).hostname if not (await misc.validate_signature(actor_id, request)): - logging.warning(f'signature validation failed for: {actor_id}') + logging.verbose(f'signature validation failed for: {actor_id}') raise HTTPUnauthorized(body='signature check failed, signature did not match key') return (await handler(request)) diff --git a/relay/misc.py b/relay/misc.py index a28bef4..e19bff1 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -148,10 +148,6 @@ 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) @@ -159,11 +155,7 @@ async def follow_remote_actor(actor_uri): 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}') + logging.verbose(f'sending follow request: {actor_uri}') message = { "@context": "https://www.w3.org/ns/activitystreams", @@ -174,25 +166,21 @@ async def follow_remote_actor(actor_uri): "actor": f"https://{config.host}/actor" } - await request(actor, message) + await request(inbox, 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) + actor = await request(actor_uri) if not actor: - logging.info(f'failed to fetch actor at: {actor_uri}') + logging.error(f'failed to fetch actor: {actor_uri}') return inbox = get_actor_inbox(actor) - logging.info(f'sending unfollow request: {actor_uri}') + logging.verbose(f'sending unfollow request to inbox: {inbox}') message = { "@context": "https://www.w3.org/ns/activitystreams", diff --git a/relay/processors.py b/relay/processors.py index 7b8802e..4ce2684 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -63,7 +63,8 @@ async def handle_follow(actor, data, request): if inbox not in database.inboxes: database.add_inbox(inbox) - asyncio.ensure_future(follow_remote_actor(actor['id'])) + database.save() + asyncio.ensure_future(misc.follow_remote_actor(actor['id'])) message = { "@context": "https://www.w3.org/ns/activitystreams", @@ -89,12 +90,15 @@ async def handle_undo(actor, data, request): if data['object']['type'] != 'Follow': return - inbox = app['database'].get_inbox(actor['id']) + database = app['database'] + inbox = database.get_inbox(actor['id']) if not inbox: return - app['database'].del_inbox(inbox) + database.del_inbox(inbox) + database.save() + await misc.unfollow_remote_actor(actor['id']) diff --git a/relay/views.py b/relay/views.py index 1e22e60..8ddf478 100644 --- a/relay/views.py +++ b/relay/views.py @@ -1,7 +1,7 @@ import logging import subprocess -from aiohttp.web import HTTPUnauthorized, Response, json_response +from aiohttp.web import HTTPForbidden, HTTPUnauthorized, Response, json_response from . import __version__ from .application import app @@ -78,19 +78,26 @@ async def inbox(request): database = app['database'] if len(config.blocked_software): - software = await fetch_nodeinfo(instance) + software = await fetch_nodeinfo(request['actor_domain']) if software and software.lower() in config.blocked_software: - raise HTTPUnauthorized(body='relays have been blocked', content_type='text/plain') + logging.verbose(f'Rejected actor for using specific software: {software}') + raise HTTPForbidden(body='access denied', content_type='text/plain') + ## reject if no post data or signature failed validation if not request['data'] or not request['validated']: + logging.verbose('Rejected actor for missing post data') raise HTTPUnauthorized(body='access denied', content_type='text/plain') - if request['data']['type'] != 'Follow' and request['actor']['id'] not in database.inboxes: + ## reject if activity type isn't 'Follow' and the actor isn't following + if request['data']['type'] != 'Follow' and not database.get_inbox(request['actor_domain']): + logging.verbose(f'Rejected actor for trying to post while not following: {request["actor"]["id"]}') raise HTTPUnauthorized(body='access denied', content_type='text/plain') + ## reject if the actor isn't whitelisted while the whiltelist is enabled elif config.whitelist_enabled and not request['data'].is_whitelisted(request['actor']['id']): - raise HTTPUnauthorized(body='access denied', content_type='text/plain') + logging.verbose(f'Rejected actor for not being in the whitelist: {request["actor"]["id"]}') + raise HTTPForbidden(body='access denied', content_type='text/plain') logging.debug(f">> payload {request['data']}") From 0d77614bdbfd7de686d0c7d68c459b27cc671c8c Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 9 Apr 2022 03:07:16 -0400 Subject: [PATCH 03/11] rework final draft --- relay/__init__.py | 10 -- relay/__main__.py | 60 +------ relay/application.py | 20 --- relay/config.py | 95 +++++++++-- relay/http_debug.py | 2 +- relay/manage.py | 369 ++++++++++++++++++++++++++++++++++++------- relay/misc.py | 23 +-- relay/processors.py | 22 ++- setup.cfg | 25 ++- 9 files changed, 434 insertions(+), 192 deletions(-) diff --git a/relay/__init__.py b/relay/__init__.py index ba80f3d..8fda65c 100644 --- a/relay/__init__.py +++ b/relay/__init__.py @@ -12,13 +12,3 @@ def set_app(app): global APP APP = app - - -#import argparse - -#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="", help="the path to your config file") -#args = parser.parse_args() diff --git a/relay/__main__.py b/relay/__main__.py index cbe5535..3280f12 100644 --- a/relay/__main__.py +++ b/relay/__main__.py @@ -1,63 +1,5 @@ -import Crypto -import asyncio -import logging -import platform -import sys - -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' - - 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...') - logging.error(pip_command) - sys.exit() - - else: - logging.warning('PyCrypto is old and should be replaced with pycryptodome') - logging.warning(pip_command) - - -async def start_webserver(): - config = app['config'] - runner = AppRunner(app, access_log_format='%{X-Forwarded-For}i "%r" %s %b "%{Referer}i" "%{User-Agent}i"') - - logging.info(f'Starting webserver at {config.host} ({config.listen}:{config.port})') - await runner.setup() - - site = TCPSite(runner, config.listen, config.port) - await site.start() - -def main(): - # 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() +from .manage import main if __name__ == '__main__': - crypto_check() main() diff --git a/relay/application.py b/relay/application.py index c4d971d..39f7bd0 100644 --- a/relay/application.py +++ b/relay/application.py @@ -3,12 +3,9 @@ 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 @@ -16,21 +13,4 @@ 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) diff --git a/relay/config.py b/relay/config.py index 4521ed2..df70f2d 100644 --- a/relay/config.py +++ b/relay/config.py @@ -47,10 +47,10 @@ class DotDict(dict): class RelayConfig(DotDict): apkeys = { 'host', + 'whitelist_enabled', 'blocked_software', 'blocked_instances', - 'whitelist', - 'whitelist_enabled' + 'whitelist' } cachekeys = { @@ -61,13 +61,15 @@ class RelayConfig(DotDict): def __init__(self, path): + self._path = Path(path).expanduser().resolve() + super().__init__({ - 'db': 'relay.jsonld', + 'db': f'{self._path.stem}.jsonld', 'listen': '0.0.0.0', 'port': 8080, 'note': 'Make a note about your instance here.', 'push_limit': 512, - 'host': 'example.com', + 'host': 'relay.example.com', 'blocked_software': [], 'blocked_instances': [], 'whitelist': [], @@ -77,8 +79,6 @@ class RelayConfig(DotDict): 'digests': 1024 }) - self._path = Path(path).expanduser().resolve() - def __setitem__(self, key, value): if key in ['blocked_instances', 'blocked_software', 'whitelist']: @@ -118,12 +118,85 @@ class RelayConfig(DotDict): return f'{self.actor}#main-key' - def is_banned(self, inbox): - return urlparse(inbox).hostname in self.blocked_instances + def ban_instance(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + if self.is_banned(instance): + return False + + self.blocked_instances.append(instance) + return True - def is_whitelisted(self, inbox): - return urlparse(inbox).hostname in self.whitelist + def unban_instance(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + try: + self.blocked_instances.remove(instance) + return True + + except: + return False + + + def ban_software(self, software): + if self.is_banned_software(software): + return False + + self.blocked_software.append(software) + return True + + + def unban_software(self, software): + try: + self.blocked_software.remove(software) + return True + + except: + return False + + + def add_whitelist(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + if self.is_whitelisted(instance): + return False + + self.whitelist.append(instance) + return True + + + def del_whitelist(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + try: + self.whitelist.remove(instance) + return True + + except: + return False + + + def is_banned(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + return instance in self.blocked_instances + + + def is_banned_software(self, software): + return software in self.blocked_software + + + def is_whitelisted(self, instance): + if instance.startswith('http'): + instance = urlparse(instance).hostname + + return instance in self.whitelist def load(self): @@ -166,7 +239,7 @@ class RelayConfig(DotDict): def save(self): config = { - 'db': self.db, + 'db': self['db'], 'listen': self.listen, 'port': self.port, 'note': self.note, diff --git a/relay/http_debug.py b/relay/http_debug.py index a3f7515..2a2ae67 100644 --- a/relay/http_debug.py +++ b/relay/http_debug.py @@ -58,7 +58,7 @@ async def on_request_exception(session, trace_config_ctx, params): def http_debug(): - if logging.DEBUG <= logging.root.level: + if logging.DEBUG >= logging.root.level: return trace_config = aiohttp.TraceConfig() diff --git a/relay/manage.py b/relay/manage.py index 152bd33..44bca8e 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -1,80 +1,331 @@ +import Crypto import asyncio -import sys -import simplejson as json +import click +import json +import logging +import platform +from aiohttp.web import AppRunner, TCPSite +from cachetools import LRUCache + +from . import misc, views from .application import app +from .config import DotDict, RelayConfig +from .database import RelayDatabase from .misc import follow_remote_actor, unfollow_remote_actor -def relay_list(): - print('Connected to the following instances or relays:') - [print('-', inbox) for inbox in app['database'].inboxes] +@click.group('cli', context_settings={'show_default': True}, invoke_without_command=True) +@click.option('--config', '-c', default='relay.yaml', help='path to the relay\'s config') +@click.pass_context +def cli(ctx, config): + app['config'] = RelayConfig(config) + + if not app['config'].load(): + app['config'].save() + + 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]) + + if not ctx.invoked_subcommand: + relay_run.callback() -def relay_follow(): - if len(sys.argv) < 3: - print('usage: python3 -m relay.manage follow ') - exit() +@cli.command('list') +@click.argument('type', required=False, default='inbox') +def relay_list(type): + 'List all following instances' - target = sys.argv[2] + assert type in [None, 'inbox', 'ban', 'whitelist'] - loop = asyncio.get_event_loop() - loop.run_until_complete(follow_remote_actor(target)) + config = app['config'] + database = app['database'] - print('Sent follow message to:', target) + if not type or type == 'inbox': + click.echo('Connected to the following instances or relays:') + + for inbox in database.inboxes: + click.echo(f'- {inbox}') + + elif type == 'ban': + click.echo('Banned instances:') + + for instance in config.blocked_instances: + click.echo(f'- {instance}') + + click.echo('\nBanned software:') + + for software in config.blocked_software: + click.echo(f'- {software}') + + elif type == 'whitelist': + click.echo('Whitelisted instances:') + + for instance in config.whitelist: + click.echo(f'- {instance}') -def relay_unfollow(): - if len(sys.argv) < 3: - print('usage: python3 -m relay.manage unfollow ') - exit() +@cli.command('follow') +@click.argument('actor') +def relay_follow(actor): + 'Follow an actor (Relay must be running)' - target = sys.argv[2] - - loop = asyncio.get_event_loop() - loop.run_until_complete(unfollow_remote_actor(target)) - - print('Sent unfollow message to:', target) - -def relay_forceremove(): - if len(sys.argv) < 3: - print('usage: python3 -m relay.manage force-remove ') - exit() - - target = sys.argv[2] - - try: - app['database'].del_inbox(target) - print('Removed inbox from DB:', target) - - except KeyError: - print('Failed to remove inbox from DB:', target) + loop = asyncio.new_event_loop() + loop.run_until_complete(handle_follow_actor(actor)) -TASKS = { - 'list': relay_list, - 'follow': relay_follow, - 'unfollow': relay_unfollow, - 'force-remove': relay_forceremove -} +@cli.command('unfollow') +@click.argument('actor') +def relay_follow(actor): + 'Unfollow an actor (Relay must be running)' + + loop = asyncio.new_event_loop() + loop.run_until_complete(handle_unfollow_actor(actor)) -def usage(): - print('usage: python3 -m relay.manage [...]') - print('tasks:') - [print('-', task) for task in TASKS.keys()] - exit() +@cli.command('add') +@click.argument('inbox') +def relay_add(inbox): + 'Add an inbox to the database' + + database = app['database'] + config = app['config'] + + if not inbox.startswith('http'): + inbox = f'https://{inbox}/inbox' + + if database.get_inbox(inbox): + click.echo(f'Error: Inbox already in database: {inbox}') + return + + if database.get_inbox(inbox): + click.echo(f'Error: Already added inbox: {inbox}') + return + + if config.is_banned(inbox): + click.echo(f'Error: Refusing to add banned inbox: {inbox}') + return + + database.add_inbox(inbox) + database.save() + click.echo(f'Added inbox to the database: {inbox}') + + +@cli.command('remove') +@click.argument('inbox') +def relay_remove(inbox): + 'Remove an inbox from the database' + + database = app['database'] + dbinbox = database.get_inbox(inbox) + + if not dbinbox: + click.echo(f'Error: Inbox does not exist: {inbox}') + return + + database.del_inbox(dbinbox) + database.save() + click.echo(f'Removed inbox from the database: {inbox}') + + +# todo: add nested groups +@cli.command('ban') +@click.argument('type') +@click.argument('target') +def relay_ban(type, target): + 'Ban an instance or software' + + assert type in ['instance', 'software'] + + config = app['config'] + database = app['database'] + inbox = database.get_inbox(target) + + bancmd = getattr(config, f'ban_{type}') + + if bancmd(target): + config.save() + + if inbox: + database.del_inbox(inbox) + database.save() + + click.echo(f'Banned {type}: {target}') + return + + click.echo(f'{type.title()} already banned: {target}') + + +@cli.command('unban') +@click.argument('type') +@click.argument('target') +def relay_unban(type, target): + 'Unban an instance or software' + + assert type in ['instance', 'software'] + + config = app['config'] + database = app['database'] + + unbancmd = getattr(config, f'unban_{type}') + + if unbancmd(target): + config.save() + + return click.echo(f'Unbanned {type}: {target}') + + return click.echo(f'{type.title()} is not banned: {target}') + + +@cli.command('allow') +@click.argument('instance') +def relay_allow(instance): + 'Add an instance to the whitelist' + + config = app['config'] + + if not config.add_whitelist(instance): + return click.echo(f'Instance already in the whitelist: {instance}') + + config.save() + click.echo(f'Instance added to the whitelist: {instance}') + + +@cli.command('deny') +@click.argument('instance') +def relay_deny(instance): + 'Remove an instance from the whitelist' + + config = app['config'] + database = app['database'] + inbox = database.get_inbox(instance) + + if not config.del_whitelist(instance): + return click.echo(f'Instance not in the whitelist: {instance}') + + config.save() + + if inbox and config.whitelist_enabled: + database.del_inbox(inbox) + database.save() + + click.echo(f'Removed instance from the whitelist: {instance}') + + +@cli.command('setup') +def relay_setup(): + 'Generate a new config' + + config = app['config'] + + while True: + config.host = click.prompt('What domain will the relay be hosted on?', default=config.host) + + if not config.host.endswith('example.com'): + break + + click.echo('The domain must not be example.com') + + config.listen = click.prompt('Which address should the relay listen on?', default=config.listen) + + while True: + config.port = click.prompt('What TCP port should the relay listen on?', default=config.port, type=int) + break + + config.save() + + if click.confirm('Relay all setup! Would you like to run it now?'): + relay_run.callback() + + +@cli.command('run') +def relay_run(): + 'Run the relay' + + if app['config'].host.endswith('example.com'): + return click.echo('Relay is not set up. Please edit your relay config or run "activityrelay setup".') + + vers_split = platform.python_version().split('.') + pip_command = 'pip3 uninstall pycrypto && pip3 install pycryptodome' + + if Crypto.__version__ == '2.6.1': + if int(vers_split[1]) > 7: + click.echo('Error: PyCrypto is broken on Python 3.8+. Please replace it with pycryptodome before running again. Exiting...') + return click.echo(pip_command) + + else: + click.echo('Warning: PyCrypto is old and should be replaced with pycryptodome') + return click.echo(pip_command) + + # 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(handle_start_webserver(), loop=loop) + loop.run_forever() + + +async def handle_follow_actor(app, target): + database = app['database'] + config = app['config'] + + if not target.startswith('http'): + target = f'https://{target}/actor' + + if database.get_inbox(target): + return click.echo(f'Error: Already following actor: {target}') + + if config.is_banned(target): + return click.echo(f'Error: Refusing to follow banned actor: {target}') + + if config.whitelist_enabled and not config.is_whitelisted(target): + return click.echo(f'Error: Refusing to follow non-whitelisted actor: {target}') + + await misc.follow_remote_actor(target) + click.echo(f'Sent follow message to: {target}') + + +async def handle_unfollow_actor(app, target): + database = app['database'] + + if not target.startswith('http'): + target = f'https://{target}/actor' + + if not database.get_inbox(target): + return click.echo(f'Error: Not following actor: {target}') + + await misc.unfollow_remote_actor(target) + click.echo(f'Sent unfollow message to: {target}') + + +async def handle_start_webserver(): + config = app['config'] + runner = AppRunner(app, access_log_format='%{X-Forwarded-For}i "%r" %s %b "%{Referer}i" "%{User-Agent}i"') + + logging.info(f'Starting webserver at {config.host} ({config.listen}:{config.port})') + await runner.setup() + + site = TCPSite(runner, config.listen, config.port) + await site.start() def main(): - if len(sys.argv) < 2: - usage() - - if sys.argv[1] in TASKS: - TASKS[sys.argv[1]]() - else: - usage() - - -if __name__ == '__main__': - main() + cli(prog_name='relay') diff --git a/relay/misc.py b/relay/misc.py index e19bff1..0ca03e4 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -9,6 +9,7 @@ from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from aiohttp import ClientSession from datetime import datetime +from json.decoder import JSONDecodeError from urllib.parse import urlparse from uuid import uuid4 @@ -60,7 +61,7 @@ def distill_inboxes(actor, object_id): targets = [] for inbox in database.inboxes: - if inbox != actor_inbox or urlparse(inbox).hostname != original_hostname: + if inbox != actor_inbox or urlparse(inbox).hostname != origin_hostname: targets.append(inbox) return targets @@ -245,11 +246,15 @@ async def request(uri, data=None, force=False, sign_headers=True): headers.update(signing_headers) try: + # json_serializer=DotDict maybe? 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') + ## aiohttp has been known to leak if the response hasn't been read, + ## so we're just gonna read the request no matter what + resp_data = await resp.read() + resp_payload = json.loads(resp_data.decode('utf-8')) + if resp.status not in [200, 202]: if not data: logging.verbose(f'Received error when requesting {uri}: {resp.status} {resp_payload}') return @@ -257,20 +262,16 @@ async def request(uri, data=None, force=False, sign_headers=True): 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: + except JSONDecodeError: + return + + except Exception: traceback.print_exc() - return None async def validate_signature(actor, http_request): diff --git a/relay/processors.py b/relay/processors.py index 4ce2684..7a7e3ad 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -13,10 +13,12 @@ async def handle_relay(actor, data, request): object_id = misc.distill_object_id(data) if object_id in cache: - logging.debug(f'>> already relayed {object_id} as {cache[object_id]}') + logging.verbose(f'already relayed {object_id} as {cache[object_id]}') return - activity_id = f"https://{request.host}/activities/{uuid.uuid4()}" + logging.verbose(f'Relaying post from {actor["id"]}') + + activity_id = f"https://{request.host}/activities/{uuid4()}" message = { "@context": "https://www.w3.org/ns/activitystreams", @@ -29,11 +31,10 @@ async def handle_relay(actor, data, request): logging.debug(f'>> relay: {message}') - inboxes = misc.distill_inboxes(actor['id'], object_id) - + inboxes = misc.distill_inboxes(actor, object_id) futures = [misc.request(inbox, data=message) for inbox in inboxes] - asyncio.ensure_future(asyncio.gather(*futures)) + asyncio.ensure_future(asyncio.gather(*futures)) cache[object_id] = activity_id @@ -42,9 +43,10 @@ async def handle_forward(actor, data, request): object_id = misc.distill_object_id(data) if object_id in cache: - logging.debug(f'>> already forwarded {object_id}.') + logging.verbose(f'already forwarded {object_id}') return + logging.verbose(f'Forwarding post from {actor["id"]}') logging.debug(f'>> Relay {data}') inboxes = misc.distill_inboxes(actor['id'], object_id) @@ -87,7 +89,12 @@ async def handle_follow(actor, data, request): async def handle_undo(actor, data, request): - if data['object']['type'] != 'Follow': + ## If the activity being undone is an Announce, forward it insteead + if data['object']['type'] == 'Announce': + await handle_forward(actor, data, request) + return + + elif data['object']['type'] != 'Follow': return database = app['database'] @@ -113,4 +120,5 @@ processors = { async def run_processor(request, data, actor): + logging.verbose(f'New activity from actor: {actor["id"]} {data["type"]}') return await processors[data['type']](actor, data, request) diff --git a/setup.cfg b/setup.cfg index 43c041c..592bf04 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = relay -version = 0.1.0 +version = 0.2.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 @@ -23,17 +23,14 @@ project_urls = zip_safe = False packages = find: install_requires = - aiohttp>=3.5.4 - async-timeout>=3.0.0 - attrs>=18.1.0 - chardet>=3.0.4 - idna>=2.7 - idna-ssl>=1.1.0; python_version < "3.7" - multidict>=4.3.1 - pycryptodome>=3.9.4 - PyYAML>=5.1 - simplejson>=3.16.0 - yarl>=1.2.6 - cachetools - async_lru + aiohttp >= 3.8.0 + cachetools >= 5.0.0 + click >= 8.1.2 + pycryptodome >= 3.14.1 + PyYAML >= 5.0.0 python_requires = >=3.6 + +[options.entry_points] +console_scripts = + activityrelay = relay.manage:main + From d9fbb8ddd4b6a9d2aef9e8620324cd72a231cab2 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 9 Apr 2022 03:36:40 -0400 Subject: [PATCH 04/11] update readme, deprecation warning on relay.manage, and existing inboxes don't prevent following --- README.md | 15 +++++---------- relay/manage.py | 11 ++++------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index da3fcfd..8264009 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,15 @@ It simply will not run on older Python versions. Download the project and install with pip (`pip3 install .`). -Copy `relay.yaml.example` to `relay.yaml` and edit it as appropriate: +Run `activityrelay setup` and answer the prompts or copy `relay.yaml.example` to `relay.yaml` +and edit it as appropriate: $ cp relay.yaml.example relay.yaml $ $EDITOR relay.yaml Finally, you can launch the relay: - $ python3 -m relay + $ activityrelay run It is suggested to run this under some sort of supervisor, such as runit, daemontools, s6 or systemd. Configuration of the supervisor is not covered here, as it is different @@ -44,11 +45,6 @@ found on the relay. In Pleroma this would be something like: $ MIX_ENV=prod mix relay_follow https://your.relay.hostname/actor -Mastodon uses an entirely different relay protocol but supports LitePub relay protocol -as well when the Mastodon relay handshake is used. In these cases, Mastodon relay -clients should follow `http://your.relay.hostname/inbox` as they would with Mastodon's -own relay software. - ## Performance @@ -60,12 +56,11 @@ superior to, the Mastodon relay software, with improved memory efficiency. ## Management -You can perform a few management tasks such as peering or depeering other relays by -invoking the `relay.manage` module. +You can perform a few management tasks such as peering or depeering other relays. This will show the available management tasks: - $ python3 -m relay.manage + $ activityrelay --help When following remote relays, you should use the `/actor` endpoint as you would in Pleroma and other LitePub-compliant software. diff --git a/relay/manage.py b/relay/manage.py index 44bca8e..7cdcb02 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -284,21 +284,14 @@ def relay_run(): async def handle_follow_actor(app, target): - database = app['database'] config = app['config'] if not target.startswith('http'): target = f'https://{target}/actor' - if database.get_inbox(target): - return click.echo(f'Error: Already following actor: {target}') - if config.is_banned(target): return click.echo(f'Error: Refusing to follow banned actor: {target}') - if config.whitelist_enabled and not config.is_whitelisted(target): - return click.echo(f'Error: Refusing to follow non-whitelisted actor: {target}') - await misc.follow_remote_actor(target) click.echo(f'Sent follow message to: {target}') @@ -329,3 +322,7 @@ async def handle_start_webserver(): def main(): cli(prog_name='relay') + + +if __name__ == '__main__': + click.echo('Running relay.manage is depreciated. Run `activityrelay [command]` instead.') From a5dd6f1abd8661c76e61c700a2a8277aae9c3974 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 9 Apr 2022 22:50:20 -0400 Subject: [PATCH 05/11] merge middleware into views.inbox --- relay/application.py | 10 +----- relay/config.py | 5 ++- relay/middleware.py | 49 ----------------------------- relay/views.py | 75 ++++++++++++++++++++++++++++++++------------ 4 files changed, 60 insertions(+), 79 deletions(-) delete mode 100644 relay/middleware.py diff --git a/relay/application.py b/relay/application.py index 39f7bd0..e3d7b5c 100644 --- a/relay/application.py +++ b/relay/application.py @@ -1,16 +1,8 @@ -import asyncio -import logging -import sys - from aiohttp.web import Application from . import set_app -from . import views -from .middleware import http_signatures_middleware -app = Application(middlewares=[ - http_signatures_middleware -]) +app = Application() set_app(app) diff --git a/relay/config.py b/relay/config.py index df70f2d..fd4ee34 100644 --- a/relay/config.py +++ b/relay/config.py @@ -189,7 +189,10 @@ class RelayConfig(DotDict): def is_banned_software(self, software): - return software in self.blocked_software + if not software: + return False + + return software.lower() in self.blocked_software def is_whitelisted(self, instance): diff --git a/relay/middleware.py b/relay/middleware.py deleted file mode 100644 index d1f458c..0000000 --- a/relay/middleware.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging - -from aiohttp.web import HTTPUnauthorized -from json.decoder import JSONDecodeError -from urllib.parse import urlparse - -from . import misc - - -# This will probably get merged into views.inbox -async def http_signatures_middleware(app, handler): - async def http_signatures_handler(request): - request['validated'] = False - request['actor'] = None - request['actor_domain'] = None - - try: - request['data'] = await request.json() - - except JSONDecodeError: - request['data'] = None - - if 'signature' in request.headers and request.method == 'POST': - if 'actor' not in request['data']: - logging.verbose('Actor not in data') - raise HTTPUnauthorized(body='signature check failed, no actor in message') - - if app['config'].is_banned(request['data']['actor']): - logging.verbose(f'Ignored request from banned actor: {request["actor"]["id"]}') - raise HTTPUnauthorized(body='banned') - - request['actor'] = await misc.request(request['data']['actor']) - - if not request['actor']: - logging.verbose(f'Failed to fetch actor: {request["actor"]["id"]}') - raise HTTPUnauthorized('failed to fetch actor') - - actor_id = request['actor']['id'] - request['actor_domain'] = urlparse(actor_id).hostname - - if not (await misc.validate_signature(actor_id, request)): - logging.verbose(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 diff --git a/relay/views.py b/relay/views.py index 8ddf478..f46587a 100644 --- a/relay/views.py +++ b/relay/views.py @@ -1,12 +1,13 @@ import logging import subprocess +import traceback from aiohttp.web import HTTPForbidden, HTTPUnauthorized, Response, json_response +from urllib.parse import urlparse -from . import __version__ +from . import __version__, misc from .application import app from .http_debug import STATS -from .misc import request from .processors import run_processor @@ -77,31 +78,65 @@ async def inbox(request): config = app['config'] database = app['database'] - if len(config.blocked_software): - software = await fetch_nodeinfo(request['actor_domain']) + ## reject if missing signature header + if 'signature' not in request.headers: + logging.verbose('Actor missing signature header') + raise HTTPUnauthorized(body='missing signature') - if software and software.lower() in config.blocked_software: - logging.verbose(f'Rejected actor for using specific software: {software}') - raise HTTPForbidden(body='access denied', content_type='text/plain') + ## read message and get actor id and domain + try: + data = await request.json() + actor_id = data['actor'] + actor_domain = urlparse(actor_id).hostname - ## reject if no post data or signature failed validation - if not request['data'] or not request['validated']: - logging.verbose('Rejected actor for missing post data') - raise HTTPUnauthorized(body='access denied', content_type='text/plain') + except KeyError: + logging.verbose('actor not in data') + raise HTTPUnauthorized(body='no actor in message') - ## reject if activity type isn't 'Follow' and the actor isn't following - if request['data']['type'] != 'Follow' and not database.get_inbox(request['actor_domain']): - logging.verbose(f'Rejected actor for trying to post while not following: {request["actor"]["id"]}') - raise HTTPUnauthorized(body='access denied', content_type='text/plain') + ## reject if there is no actor in the message + except: + traceback.print_exc() + logging.verbose('Failed to parse inbox message') + raise HTTPUnauthorized(body='failed to parse message') + + actor = await misc.request(actor_id) + + ## reject if actor is empty + if not actor: + logging.verbose(f'Failed to fetch actor: {actor_id}') + raise HTTPUnauthorized('failed to fetch actor') ## reject if the actor isn't whitelisted while the whiltelist is enabled - elif config.whitelist_enabled and not request['data'].is_whitelisted(request['actor']['id']): - logging.verbose(f'Rejected actor for not being in the whitelist: {request["actor"]["id"]}') - raise HTTPForbidden(body='access denied', content_type='text/plain') + elif config.whitelist_enabled and not config.is_whitelisted(actor_id): + logging.verbose(f'Rejected actor for not being in the whitelist: {actor_id}') + raise HTTPForbidden(body='access denied') - logging.debug(f">> payload {request['data']}") + ## reject if actor is banned + if app['config'].is_banned(actor_id): + logging.verbose(f'Ignored request from banned actor: {actor_id}') + raise HTTPForbidden(body='access denied') - await run_processor(request, request['data'], request['actor']) + ## reject if software used by actor is banned + if len(config.blocked_software): + software = await fetch_nodeinfo(actor_domain) + + if config.is_banned_software(software): + logging.verbose(f'Rejected actor for using specific software: {software}') + raise HTTPForbidden(body='access denied') + + ## reject if the signature is invalid + if not (await misc.validate_signature(actor_id, request)): + logging.verbose(f'signature validation failed for: {actor_id}') + raise HTTPUnauthorized(body='signature check failed, signature did not match key') + + ## reject if activity type isn't 'Follow' and the actor isn't following + if data['type'] != 'Follow' and not database.get_inbox(actor_domain): + logging.verbose(f'Rejected actor for trying to post while not following: {actor_id}') + raise HTTPUnauthorized(body='access denied') + + logging.debug(f">> payload {data}") + + await run_processor(request, data, actor) return Response(body=b'{}', content_type='application/activity+json') From d322d41f07b61f4f1a0fd33a650980cfa8798a1b Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Sat, 9 Apr 2022 23:15:43 -0400 Subject: [PATCH 06/11] move app to __init__.py --- relay/__init__.py | 12 +++--------- relay/application.py | 8 -------- relay/manage.py | 3 +-- relay/misc.py | 26 +++++++++++++------------- relay/processors.py | 3 +-- relay/views.py | 3 +-- 6 files changed, 19 insertions(+), 36 deletions(-) delete mode 100644 relay/application.py diff --git a/relay/__init__.py b/relay/__init__.py index 8fda65c..bd51b89 100644 --- a/relay/__init__.py +++ b/relay/__init__.py @@ -1,14 +1,8 @@ __version__ = '0.2.0' +from aiohttp.web import Application + from . import logger -APP = None - -def get_app(): - return APP - -def set_app(app): - global APP - - APP = app +app = Application() diff --git a/relay/application.py b/relay/application.py deleted file mode 100644 index e3d7b5c..0000000 --- a/relay/application.py +++ /dev/null @@ -1,8 +0,0 @@ -from aiohttp.web import Application - -from . import set_app - - -app = Application() - -set_app(app) diff --git a/relay/manage.py b/relay/manage.py index 7cdcb02..25227e4 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -8,8 +8,7 @@ import platform from aiohttp.web import AppRunner, TCPSite from cachetools import LRUCache -from . import misc, views -from .application import app +from . import app, misc, views from .config import DotDict, RelayConfig from .database import RelayDatabase from .misc import follow_remote_actor, unfollow_remote_actor diff --git a/relay/misc.py b/relay/misc.py index 0ca03e4..6a51e07 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -13,7 +13,7 @@ from json.decoder import JSONDecodeError from urllib.parse import urlparse from uuid import uuid4 -from . import get_app +from . import app from .http_debug import http_debug @@ -34,10 +34,10 @@ def create_signature_header(headers): sigstring = build_signing_string(headers, used_headers) sig = { - 'keyId': get_app()['config'].keyid, + 'keyId': app['config'].keyid, 'algorithm': 'rsa-sha256', 'headers': ' '.join(used_headers), - 'signature': sign_signing_string(sigstring, get_app()['database'].PRIVKEY) + 'signature': sign_signing_string(sigstring, app['database'].PRIVKEY) } chunks = ['{}="{}"'.format(k, v) for k, v in sig.items()] @@ -55,7 +55,7 @@ def distill_object_id(activity): def distill_inboxes(actor, object_id): - database = get_app()['database'] + database = app['database'] origin_hostname = urlparse(object_id).hostname actor_inbox = get_actor_inbox(actor) targets = [] @@ -68,14 +68,14 @@ def distill_inboxes(actor, object_id): def generate_body_digest(body): - bodyhash = get_app()['cache'].digests.get(body) + bodyhash = 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 + app['cache'].digests[body] = bodyhash return bodyhash @@ -146,8 +146,8 @@ async def fetch_nodeinfo(domain): async def follow_remote_actor(actor_uri): - config = get_app()['config'] - database = get_app()['database'] + config = app['config'] + database = app['database'] actor = await request(actor_uri) inbox = get_actor_inbox(actor) @@ -171,8 +171,8 @@ async def follow_remote_actor(actor_uri): async def unfollow_remote_actor(actor_uri): - config = get_app()['config'] - database = get_app()['database'] + config = app['config'] + database = app['database'] actor = await request(actor_uri) @@ -204,7 +204,7 @@ 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] + return app['cache'].json[uri] except KeyError: pass @@ -247,7 +247,7 @@ async def request(uri, data=None, force=False, sign_headers=True): try: # json_serializer=DotDict maybe? - async with ClientSession(trace_configs=http_debug()) as session, get_app()['semaphore']: + async with ClientSession(trace_configs=http_debug()) as session, app['semaphore']: async with session.request(method, uri, headers=headers, data=data) as resp: ## aiohttp has been known to leak if the response hasn't been read, ## so we're just gonna read the request no matter what @@ -264,7 +264,7 @@ async def request(uri, data=None, force=False, sign_headers=True): logging.debug(f'{uri} >> resp {resp_payload}') - get_app()['cache'].json[uri] = resp_payload + app['cache'].json[uri] = resp_payload return resp_payload except JSONDecodeError: diff --git a/relay/processors.py b/relay/processors.py index 7a7e3ad..130077e 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -3,8 +3,7 @@ import logging from uuid import uuid4 -from . import misc -from .application import app +from . import app, misc from .misc import distill_inboxes, distill_object_id, request diff --git a/relay/views.py b/relay/views.py index f46587a..279204f 100644 --- a/relay/views.py +++ b/relay/views.py @@ -5,8 +5,7 @@ import traceback from aiohttp.web import HTTPForbidden, HTTPUnauthorized, Response, json_response from urllib.parse import urlparse -from . import __version__, misc -from .application import app +from . import __version__, app, misc from .http_debug import STATS from .processors import run_processor From 51984573daf1e9bc13ca4680e24df20087c14072 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Tue, 12 Apr 2022 04:13:14 -0400 Subject: [PATCH 07/11] fix Dockerfile and create a docker management script --- Dockerfile | 27 ++++++++++++++++---- README.md | 17 +++++-------- docker.sh | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ relay/config.py | 15 ++++++++--- relay/manage.py | 21 +++++++++++---- relay/misc.py | 13 ++++++++++ requirements.txt | 1 + 7 files changed, 135 insertions(+), 25 deletions(-) create mode 100755 docker.sh create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile index 55db022..34f9fbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,28 @@ FROM python:3-alpine -WORKDIR /workdir + +# install build deps for pycryptodome and other c-based python modules RUN apk add alpine-sdk autoconf automake libtool gcc -ADD requirements.txt /workdir/ -RUN pip3 install -r requirements.txt +# add env var to let the relay know it's in a container +ENV DOCKER_RUNNING=true -ADD . /workdir/ +# setup various container properties +VOLUME ["/data"] CMD ["python", "-m", "relay"] +EXPOSE 8080/tcp +WORKDIR /opt/activityrelay -VOLUME ["/workdir/data"] +# install and update important python modules +RUN pip3 install -U setuptools wheel pip + +# only copy necessary files +COPY relay ./relay +COPY LICENSE . +COPY README.md . +COPY requirements.txt . +COPY setup.cfg . +COPY setup.py . +COPY .git ./.git + +# install relay deps +RUN pip3 install -r requirements.txt diff --git a/README.md b/README.md index 8264009..cbe4b90 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ in this package as the `LICENSE` file. You need at least Python 3.6 (latest version of 3.x recommended) to make use of this software. It simply will not run on older Python versions. -Download the project and install with pip (`pip3 install .`). +Download the project and install with pip (`pip3 install -r requirements.txt`). -Run `activityrelay setup` and answer the prompts or copy `relay.yaml.example` to `relay.yaml` +Run `python3 -m relay setup` and answer the prompts or copy `relay.yaml.example` to `relay.yaml` and edit it as appropriate: $ cp relay.yaml.example relay.yaml @@ -25,7 +25,7 @@ and edit it as appropriate: Finally, you can launch the relay: - $ activityrelay run + $ python3 -m relay run It is suggested to run this under some sort of supervisor, such as runit, daemontools, s6 or systemd. Configuration of the supervisor is not covered here, as it is different @@ -60,17 +60,12 @@ You can perform a few management tasks such as peering or depeering other relays This will show the available management tasks: - $ activityrelay --help + $ python3 -m relay --help When following remote relays, you should use the `/actor` endpoint as you would in Pleroma and other LitePub-compliant software. ## Docker -You can run ActivityRelay with docker. Edit `relay.yaml` so that the database -location is set to `./data/relay.jsonld` and then build and run the docker -image : - - $ docker volume create activityrelay-data - $ docker build -t activityrelay . - $ docker run -d -p 8080:8080 -v activityrelay-data:/workdir/data activityrelay +You can run ActivityRelay with docker via the docker management script: `docker.sh`. +Run it without arguments to see the list of commands. diff --git a/docker.sh b/docker.sh new file mode 100755 index 0000000..d372dbf --- /dev/null +++ b/docker.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +case $1 in + install) + docker build -f Dockerfile -t activityrelay . && \ + docker volume create activityrelay-data && \ + docker run -it -p 8080:8080 -v activityrelay-data:/data --name activityrelay activityrelay + ;; + + uninstall) + docker stop activityrelay && \ + docker container rm activityrelay && \ + docker volume rm activityrelay-data && \ + docker image rm activityrelay + ;; + + start) + docker start activityrelay + ;; + + stop) + docker stop activityrelay + ;; + + manage) + shift + docker exec -it activityrelay python3 -m relay "$@" + ;; + + shell) + docker exec -it activityrelay bash + ;; + + rescue) + docker run -it --rm --entrypoint bash -v activityrelay-data:/data activityrelay + ;; + + edit) + if [ -z ${EDITOR} ]; then + echo "EDITOR environmental variable not set" + exit + fi + + CONFIG="/tmp/relay-$(date +"%T").yaml" + + docker cp activityrelay:/data/relay.yaml $CONFIG && \ + $EDITOR $CONFIG && \ + + docker cp $CONFIG activityrelay:/data/relay.yaml && \ + rm $CONFIG + ;; + + *) + COLS="%-22s %s\n" + + echo "Valid commands:" + printf "$COLS" "- start" "Run the relay in the background" + printf "$COLS" "- stop" "Stop the relay" + printf "$COLS" "- manage [args]" "Run a relay management command" + printf "$COLS" "- edit" "Edit the relay's config in \$EDITOR" + printf "$COLS" "- shell" "Drop into a bash shell on the running container" + printf "$COLS" "- rescue" "Drop into a bash shell on a temp container with the data volume mounted" + printf "$COLS" "- install" "Build the image, create a new container and volume, and run relay setup" + printf "$COLS" "- uninstall" "Delete the relay image, container, and volume" + ;; +esac diff --git a/relay/config.py b/relay/config.py index fd4ee34..45fcf40 100644 --- a/relay/config.py +++ b/relay/config.py @@ -60,11 +60,15 @@ class RelayConfig(DotDict): } - def __init__(self, path): - self._path = Path(path).expanduser().resolve() + def __init__(self, path, is_docker): + if is_docker: + path = '/data/relay.yaml' + + self._isdocker = is_docker + self._path = Path(path).expanduser() super().__init__({ - 'db': f'{self._path.stem}.jsonld', + 'db': str(self._path.parent.joinpath(f'{self._path.stem}.jsonld')), 'listen': '0.0.0.0', 'port': 8080, 'note': 'Make a note about your instance here.', @@ -81,6 +85,9 @@ class RelayConfig(DotDict): def __setitem__(self, key, value): + if self._isdocker and key in ['db', 'listen', 'port']: + return + if key in ['blocked_instances', 'blocked_software', 'whitelist']: assert isinstance(value, (list, set, tuple)) @@ -234,7 +241,7 @@ class RelayConfig(DotDict): self[key] = value - if self.host == 'example.com': + if self.host.endswith('example.com'): return False return True diff --git a/relay/manage.py b/relay/manage.py index 25227e4..213ecdb 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -3,6 +3,7 @@ import asyncio import click import json import logging +import os import platform from aiohttp.web import AppRunner, TCPSite @@ -11,14 +12,15 @@ from cachetools import LRUCache from . import app, misc, views from .config import DotDict, RelayConfig from .database import RelayDatabase -from .misc import follow_remote_actor, unfollow_remote_actor +from .misc import check_open_port, follow_remote_actor, unfollow_remote_actor @click.group('cli', context_settings={'show_default': True}, invoke_without_command=True) @click.option('--config', '-c', default='relay.yaml', help='path to the relay\'s config') @click.pass_context def cli(ctx, config): - app['config'] = RelayConfig(config) + app['is_docker'] = bool(os.environ.get('DOCKER_RUNNING')) + app['config'] = RelayConfig(config, app['is_docker']) if not app['config'].load(): app['config'].save() @@ -33,7 +35,11 @@ def cli(ctx, config): app['cache'][key] = LRUCache(app['config'][key]) if not ctx.invoked_subcommand: - relay_run.callback() + if app['config'].host.endswith('example.com'): + relay_setup.callback() + + else: + relay_run.callback() @cli.command('list') @@ -239,7 +245,7 @@ def relay_setup(): config.save() - if click.confirm('Relay all setup! Would you like to run it now?'): + if not app['is_docker'] and click.confirm('Relay all setup! Would you like to run it now?'): relay_run.callback() @@ -247,7 +253,9 @@ def relay_setup(): def relay_run(): 'Run the relay' - if app['config'].host.endswith('example.com'): + config = app['config'] + + if config.host.endswith('example.com'): return click.echo('Relay is not set up. Please edit your relay config or run "activityrelay setup".') vers_split = platform.python_version().split('.') @@ -262,6 +270,9 @@ def relay_run(): click.echo('Warning: PyCrypto is old and should be replaced with pycryptodome') return click.echo(pip_command) + if not check_open_port(config.listen, config.port): + return click.echo(f'Error: A server is already running on port {config.port}') + # web pages app.router.add_get('/', views.home) diff --git a/relay/misc.py b/relay/misc.py index 6a51e07..90c30e0 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -2,6 +2,7 @@ import asyncio import base64 import json import logging +import socket import traceback from Crypto.Hash import SHA, SHA256, SHA512 @@ -28,6 +29,18 @@ def build_signing_string(headers, used_headers): return '\n'.join(map(lambda x: ': '.join([x.lower(), headers[x]]), used_headers)) +def check_open_port(host, port): + if host == '0.0.0.0': + host = '127.0.0.1' + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + return s.connect_ex((host , port)) != 0 + + except socket.error as e: + return False + + def create_signature_header(headers): headers = {k.lower(): v for k, v in headers.items()} used_headers = headers.keys() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +. From d9e5b9b6d3ca7c63774ff6c8f1f2331484716a8e Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Tue, 12 Apr 2022 19:57:49 -0400 Subject: [PATCH 08/11] use click subgroups for cli --- relay/config.py | 8 ++ relay/manage.py | 235 +++++++++++++++++++++++++++++++++--------------- relay/misc.py | 4 +- 3 files changed, 171 insertions(+), 76 deletions(-) diff --git a/relay/config.py b/relay/config.py index 45fcf40..951b6f3 100644 --- a/relay/config.py +++ b/relay/config.py @@ -5,6 +5,14 @@ from pathlib import Path from urllib.parse import urlparse +relay_software_names = [ + 'activityrelay', + 'aoderelay', + 'social.seattle.wa.us-relay', + 'unciarelay' +] + + class DotDict(dict): def __getattr__(self, k): try: diff --git a/relay/manage.py b/relay/manage.py index 213ecdb..336be3c 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -9,14 +9,15 @@ import platform from aiohttp.web import AppRunner, TCPSite from cachetools import LRUCache -from . import app, misc, views -from .config import DotDict, RelayConfig +from . import app, misc, views, __version__ +from .config import DotDict, RelayConfig, relay_software_names from .database import RelayDatabase from .misc import check_open_port, follow_remote_actor, unfollow_remote_actor @click.group('cli', context_settings={'show_default': True}, invoke_without_command=True) @click.option('--config', '-c', default='relay.yaml', help='path to the relay\'s config') +@click.version_option(version=__version__, prog_name='ActivityRelay') @click.pass_context def cli(ctx, config): app['is_docker'] = bool(os.environ.get('DOCKER_RUNNING')) @@ -42,61 +43,42 @@ def cli(ctx, config): relay_run.callback() -@cli.command('list') -@click.argument('type', required=False, default='inbox') -def relay_list(type): - 'List all following instances' - - assert type in [None, 'inbox', 'ban', 'whitelist'] - - config = app['config'] - database = app['database'] - - if not type or type == 'inbox': - click.echo('Connected to the following instances or relays:') - - for inbox in database.inboxes: - click.echo(f'- {inbox}') - - elif type == 'ban': - click.echo('Banned instances:') - - for instance in config.blocked_instances: - click.echo(f'- {instance}') - - click.echo('\nBanned software:') - - for software in config.blocked_software: - click.echo(f'- {software}') - - elif type == 'whitelist': - click.echo('Whitelisted instances:') - - for instance in config.whitelist: - click.echo(f'- {instance}') +@cli.group('inbox') +@click.pass_context +def cli_inbox(ctx): + 'Manage the inboxes in the database' + pass -@cli.command('follow') +@cli_inbox.command('list') +def cli_inbox_list(): + 'List the connected instances or relays' + + click.echo('Connected to the following instances or relays:') + + for inbox in app['database'].inboxes: + click.echo(f'- {inbox}') + + +@cli_inbox.command('follow') @click.argument('actor') -def relay_follow(actor): +def cli_inbox_follow(actor): 'Follow an actor (Relay must be running)' - loop = asyncio.new_event_loop() - loop.run_until_complete(handle_follow_actor(actor)) + run_in_loop(handle_follow_actor, actor) -@cli.command('unfollow') +@cli_inbox.command('unfollow') @click.argument('actor') -def relay_follow(actor): +def cli_inbox_unfollow(actor): 'Unfollow an actor (Relay must be running)' - loop = asyncio.new_event_loop() - loop.run_until_complete(handle_unfollow_actor(actor)) + run_in_loop(handle_unfollow_actor(actor)) -@cli.command('add') +@cli_inbox.command('add') @click.argument('inbox') -def relay_add(inbox): +def cli_inbox_add(inbox): 'Add an inbox to the database' database = app['database'] @@ -122,9 +104,9 @@ def relay_add(inbox): click.echo(f'Added inbox to the database: {inbox}') -@cli.command('remove') +@cli_inbox.command('remove') @click.argument('inbox') -def relay_remove(inbox): +def cli_inbox_remove(inbox): 'Remove an inbox from the database' database = app['database'] @@ -139,58 +121,158 @@ def relay_remove(inbox): click.echo(f'Removed inbox from the database: {inbox}') -# todo: add nested groups -@cli.command('ban') -@click.argument('type') -@click.argument('target') -def relay_ban(type, target): - 'Ban an instance or software' +@cli.group('instance') +def cli_instance(): + 'Manage instance bans' + pass - assert type in ['instance', 'software'] + +@cli_instance.command('list') +def cli_instance_list(): + 'List all banned instances' + + click.echo('Banned instances or relays:') + + for domain in app['config'].blocked_instances: + click.echo(f'- {domain}') + + +@cli_instance.command('ban') +@click.argument('target') +def cli_instance_ban(target): + 'Ban an instance and remove the associated inbox if it exists' config = app['config'] database = app['database'] inbox = database.get_inbox(target) - bancmd = getattr(config, f'ban_{type}') - - if bancmd(target): + if config.ban_instance(target): config.save() if inbox: database.del_inbox(inbox) database.save() - click.echo(f'Banned {type}: {target}') + click.echo(f'Banned instance: {target}') return - click.echo(f'{type.title()} already banned: {target}') + click.echo(f'Instance already banned: {target}') -@cli.command('unban') -@click.argument('type') +@cli_instance.command('unban') @click.argument('target') -def relay_unban(type, target): - 'Unban an instance or software' - - assert type in ['instance', 'software'] +def cli_instance_unban(target): + 'Unban an instance' config = app['config'] - database = app['database'] - unbancmd = getattr(config, f'unban_{type}') - - if unbancmd(target): + if config.unban_instance(target): config.save() - return click.echo(f'Unbanned {type}: {target}') + click.echo(f'Unbanned instance: {target}') + return - return click.echo(f'{type.title()} is not banned: {target}') + click.echo(f'Instance wasn\'t banned: {target}') -@cli.command('allow') +@cli.group('software') +def cli_software(): + 'Manage banned software' + pass + + +@cli_software.command('list') +def cli_software_list(): + 'List all banned software' + + click.echo('Banned software:') + + for software in app['config'].blocked_software: + click.echo(f'- {software}') + + +@cli_software.command('ban') +@click.option('--fetch-nodeinfo/--ignore-nodeinfo', '-f', 'fetch_nodeinfo', default=False, + help='Treat NAME like a domain and try to fet the software name from nodeinfo' +) +@click.argument('name') +def cli_software_ban(name, fetch_nodeinfo): + 'Ban software. Use RELAYS for NAME to ban relays' + + config = app['config'] + + if name == 'RELAYS': + for name in relay_software_names: + config.ban_software(name) + + config.save() + return click.echo('Banned all relay software') + + if fetch_nodeinfo: + software = run_in_loop(fetch_nodeinfo, name) + + if not software: + click.echo(f'Failed to fetch software name from domain: {name}') + + name = software + + if config.ban_software(name): + config.save() + return click.echo(f'Banned software: {name}') + + click.echo(f'Software already banned: {name}') + + +@cli_software.command('unban') +@click.option('--fetch-nodeinfo/--ignore-nodeinfo', '-f', 'fetch_nodeinfo', default=False, + help='Treat NAME like a domain and try to fet the software name from nodeinfo' +) +@click.argument('name') +def cli_software_unban(name, fetch_nodeinfo): + 'Ban software. Use RELAYS for NAME to unban relays' + + config = app['config'] + + if name == 'RELAYS': + for name in relay_software_names: + config.unban_software(name) + + config.save() + return click.echo('Unbanned all relay software') + + if fetch_nodeinfo: + software = run_in_loop(fetch_nodeinfo, name) + + if not software: + click.echo(f'Failed to fetch software name from domain: {name}') + + name = software + + if config.unban_software(name): + config.save() + return click.echo(f'Unbanned software: {name}') + + click.echo(f'Software wasn\'t banned: {name}') + + + +@cli.group('whitelist') +def cli_whitelist(): + 'Manage the instance whitelist' + pass + + +@cli_whitelist.command('list') +def cli_whitelist_list(): + click.echo('Current whitelisted domains') + + for domain in app['config'].whitelist: + click.echo(f'- {domain}') + + +@cli_whitelist.command('add') @click.argument('instance') -def relay_allow(instance): +def cli_whitelist_add(instance): 'Add an instance to the whitelist' config = app['config'] @@ -202,9 +284,9 @@ def relay_allow(instance): click.echo(f'Instance added to the whitelist: {instance}') -@cli.command('deny') +@cli_whitelist.command('remove') @click.argument('instance') -def relay_deny(instance): +def cli_whitelist_remove(instance): 'Remove an instance from the whitelist' config = app['config'] @@ -293,6 +375,11 @@ def relay_run(): loop.run_forever() +def run_in_loop(func, *args, **kwargs): + loop = asyncio.new_event_loop() + return loop.run_until_complete(func(*args, **kwargs)) + + async def handle_follow_actor(app, target): config = app['config'] diff --git a/relay/misc.py b/relay/misc.py index 90c30e0..184f577 100644 --- a/relay/misc.py +++ b/relay/misc.py @@ -136,7 +136,7 @@ async def fetch_actor_key(actor): async def fetch_nodeinfo(domain): nodeinfo_url = None - wk_nodeinfo = await request(f'https://{domain}/.well-known/nodeinfo', sign=False) + wk_nodeinfo = await request(f'https://{domain}/.well-known/nodeinfo', sign_headers=False) if not wk_nodeinfo: return @@ -149,7 +149,7 @@ async def fetch_nodeinfo(domain): if not nodeinfo_url: return - nodeinfo_data = await request(nodeinfo_url, sign=False) + nodeinfo_data = await request(nodeinfo_url, sign_headers=False) try: return nodeinfo_data['software']['name'] From 1928bb145953c466418b9a6d0b20f653245aecba Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 28 Apr 2022 12:26:24 -0400 Subject: [PATCH 09/11] Add basic documentation --- README.md | 54 +-------------- docs/commands.md | 157 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 110 +++++++++++++++++++++++++++++ docs/index.md | 7 ++ docs/installation.md | 67 ++++++++++++++++++ relay/manage.py | 4 -- 6 files changed, 344 insertions(+), 55 deletions(-) create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/index.md create mode 100644 docs/installation.md diff --git a/README.md b/README.md index cbe4b90..233c5cd 100644 --- a/README.md +++ b/README.md @@ -10,62 +10,14 @@ Affero General Public License version 3 (AGPLv3) license. You can find a copy o in this package as the `LICENSE` file. -## Setup - -You need at least Python 3.6 (latest version of 3.x recommended) to make use of this software. -It simply will not run on older Python versions. - -Download the project and install with pip (`pip3 install -r requirements.txt`). - -Run `python3 -m relay setup` and answer the prompts or copy `relay.yaml.example` to `relay.yaml` -and edit it as appropriate: - - $ cp relay.yaml.example relay.yaml - $ $EDITOR relay.yaml - -Finally, you can launch the relay: - - $ python3 -m relay run - -It is suggested to run this under some sort of supervisor, such as runit, daemontools, -s6 or systemd. Configuration of the supervisor is not covered here, as it is different -depending on which system you have available. - -The bot runs a webserver, internally, on localhost at port 8080. This needs to be -forwarded by nginx or similar. The webserver is used to receive ActivityPub messages, -and needs to be secured with an SSL certificate inside nginx or similar. Configuration -of your webserver is not discussed here, but any guide explaining how to configure a -modern non-PHP web application should cover it. - - ## Getting Started Normally, you would direct your LitePub instance software to follow the LitePub actor found on the relay. In Pleroma this would be something like: - $ MIX_ENV=prod mix relay_follow https://your.relay.hostname/actor + $ MIX_ENV=prod mix relay_follow https://your.relay.hostname/actor -## Performance +## Documentation -Performance is very good, with all data being stored in memory and serialized to a -JSON-LD object graph. Worker coroutines are spawned in the background to distribute -the messages in a scatter-gather pattern. Performance is comparable to, if not -superior to, the Mastodon relay software, with improved memory efficiency. - - -## Management - -You can perform a few management tasks such as peering or depeering other relays. - -This will show the available management tasks: - - $ python3 -m relay --help - -When following remote relays, you should use the `/actor` endpoint as you would in -Pleroma and other LitePub-compliant software. - -## Docker - -You can run ActivityRelay with docker via the docker management script: `docker.sh`. -Run it without arguments to see the list of commands. +To install or manage your relay, check the [documentation](https://git.pleroma.social/pleroma/relay/-/blob/master/docs/index.md) diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..a92e784 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,157 @@ +# Commands + +There are a number of commands to manage your relay's database and config. You can add `--help` to +any category or command to get help on that specific option (ex. `activityrelay inbox --help`). + +Note: Unless specified, it is recommended to run any commands while the relay is shutdown. + + +## Run + +Run the relay. + + activityrelay run + + +## Setup + +Run the setup wizard to configure your relay. + + activityrelay setup + + +## Inbox + +Manage the list of subscribed instances. + + +### List + +List the currently subscribed instances or relays. + + activityrelay inbox list + + +### Add + +Add an inbox to the database. If a domain is specified, it will default to `https://{domain}/inbox`. +If the added instance is not following the relay, expect errors when pushing messages. + + activityrelay inbox add + + +### Remove + +Remove an inbox from the database. An inbox or domain can be specified. + + activityrelay inbox remove + + +### Follow + +Follow an instance or relay actor and add it to the database. If a domain is specified, it will +default to `https://{domain}/actor`. + + activityrelay inbox follow + +Note: The relay must be running for this command to work. + + +### Unfollow + +Unfollow an instance or relay actor and remove it from the database. If the instance or relay does +not exist anymore, use the `inbox remove` command instead. + + activityrelay inbox unfollow + +Note: The relay must be running for this command to work. + + +## Whitelist + +Manage the whitelisted domains. + + +### List + +List the current whitelist. + + activityrelay whitelist list + + +### Add + +Add a domain to the whitelist. + + activityrelay whitelist add + + +### Remove + +Remove a domain from the whitelist. + + activityrelay whitelist remove + + +## Instance + +Manage the instance ban list. + + +### List + +List the currently banned instances + + activityrelay instance list + + +### Ban + +Add an instance to the ban list. If the instance is currently subscribed, remove it from the +database. + + activityrelay instance ban + + +### Unban + +Remove an instance from the ban list. + + activityrelay instance unban + + +## Software + +Manage the software ban list. To get the correct name, check the software's nodeinfo endpoint. +You can find it at nodeinfo\['software']\['name']. + + +### List + +List the currently banned software. + + activityrelay software list + + +### Ban + +Add a software name to the ban list. + +If `-f` or `--fetch-nodeinfo` is set, treat the name as a domain and try to fetch the software +name via nodeinfo. + +If the name is `RELAYS` (case-sensitive), add all known relay software names to the list. + + activityrelay software ban [-f/--fetch-nodeinfo] + + +### Unban + +Remove a software name from the ban list. + +If `-f` or `--fetch-nodeinfo` is set, treat the name as a domain and try to fetch the software +name via nodeinfo. + +If the name is `RELAYS` (case-sensitive), remove all known relay software names from the list. + + activityrelay unban [-f/--fetch-nodeinfo] diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b76b931 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,110 @@ +# Configuration + +## DB + +The path to the database. It contains the relay actor private key and all subscribed +instances. If the path is not absolute, it is relative to the working directory. + + db: relay.jsonld + + +## Listener + +The address and port the relay will listen on. If the reverse proxy (nginx, apache, caddy, etc) +is running on the same host, it is recommended to change `listen` to `localhost` + + listen: 0.0.0.0 + port: 8080 + + +## Note + +A small blurb to describe your relay instance. This will show up on the relay's home page. + + note: "Make a note about your instance here." + + +## Post Limit + +The maximum number of messages to send out at once. For each incoming message, a message will be +sent out to every subscribed instance minus the instance which sent the message. This limit +is to prevent too many outgoing connections from being made, so adjust if necessary. + + push_limit: 512 + + +## AP + +Various ActivityPub-related settings + + +### Host + +The domain your relay will use to identify itself. + + host: relay.example.com + + +### Whitelist Enabled + +If set to `true`, only instances in the whitelist can follow the relay. Any subscribed instances +not in the whitelist will be removed from the inbox list on startup. + + whitelist_enabled: false + + +### Whitelist + +A list of domains of instances which are allowed to subscribe to your relay. + + whitelist: + - bad-instance.example.com + - another-bad-instance.example.com + + +### Blocked Instances + +A list of instances which are unable to follow the instance. If a subscribed instance is added to +the block list, it will be removed from the inbox list on startup. + + blocked_instances: + - bad-instance.example.com + - another-bad-instance.example.com + + +### Blocked Software + +A list of ActivityPub software which cannot follow your relay. This list is empty by default, but +setting this to the above list will block all other relays and prevent relay chains + + blocked_software: + - activityrelay + - aoderelay + - social.seattle.wa.us-relay + - unciarelay + + +## Cache + +These are object limits for various caches. Only change if you know what you're doing. + + +### Objects + +The urls of messages which have been processed by the relay. + + objects: 1024 + + +### Actors + +The ActivityPub actors of incoming messages. + + actors: 1024 + + +### Actors + +The base64 encoded hashes of messages. + + digests: 1024 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..43882ca --- /dev/null +++ b/docs/index.md @@ -0,0 +1,7 @@ +# ActivityRelay Documentation + +ActivityRelay is a small ActivityPub server that relays messages to subscribed instances. + +[Installation](installation.md) +[Configuration](configuration.md) +[Commands](commands.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..7445f58 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,67 @@ +# Installation + +There are a few ways to install ActivityRelay. Follow one of the methods below, setup a reverse +proxy, and setup the relay to run via a supervisor. Example configs for caddy, nginx, and systemd +in `installation/` + + +## Pipx + +Pipx uses pip and a custom venv implementation to automatically install modules into a Python +environment and is the recommended method. Install pipx if it isn't installed already. Check out +the [official pipx docs](https://pypa.github.io/pipx/installation/) for more in-depth instructions. + + python3 -m pip install pipx + +Now simply install ActivityRelay directly from git + + pipx install git+https://git.pleroma.social/pleroma/relay@0.2.0 + +Or from a cloned git repo. + + pipx install . + +Once finished, you can set up the relay via the setup command. It will ask a few questions to fill +out config options for your relay + + activityrelay setup + +Finally start it up with the run command. + + activityrelay run + +Note: Pipx requires python 3.7+. If your distro doesn't have a compatible version of python, it can +be installed via + + +## Pip + +The instructions for installation via pip are very similar to pipx. Installation can be done from +git + + python3 -m pip install git+https://git.pleroma.social/pleroma/relay@0.2.0 + +or a cloned git repo. + + python3 -m pip install . + +Now run the configuration wizard + + activityrelay setup + +And start the relay when finished + + activityrelay run + + +## Docker + +Installation and management via Docker can be handled with the `docker.sh` script. To install +ActivityRelay, run the install command. Once the image is built and the container is created, +your will be asked to fill out some config options for your relay. + + ./docker.sh install + +Finally start it up. It will be listening on TCP port 8080. + + ./docker.sh start diff --git a/relay/manage.py b/relay/manage.py index 336be3c..3765ff5 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -91,10 +91,6 @@ def cli_inbox_add(inbox): click.echo(f'Error: Inbox already in database: {inbox}') return - if database.get_inbox(inbox): - click.echo(f'Error: Already added inbox: {inbox}') - return - if config.is_banned(inbox): click.echo(f'Error: Refusing to add banned inbox: {inbox}') return From 850b468ded5986db57149dd9641efbebefc57291 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Thu, 28 Apr 2022 12:29:00 -0400 Subject: [PATCH 10/11] try different link for docs --- README.md | 2 +- docs/index.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 233c5cd..9d2bce4 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,4 @@ found on the relay. In Pleroma this would be something like: ## Documentation -To install or manage your relay, check the [documentation](https://git.pleroma.social/pleroma/relay/-/blob/master/docs/index.md) +To install or manage your relay, check the [documentation](docs/index.md) diff --git a/docs/index.md b/docs/index.md index 43882ca..98e115e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,5 +3,7 @@ ActivityRelay is a small ActivityPub server that relays messages to subscribed instances. [Installation](installation.md) + [Configuration](configuration.md) + [Commands](commands.md) From 46f0b34899468903d120ede81f72518e0133bdc3 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Fri, 6 May 2022 02:56:10 -0400 Subject: [PATCH 11/11] cleanup imports --- relay/manage.py | 6 +++--- relay/processors.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/relay/manage.py b/relay/manage.py index 3765ff5..b07652c 100644 --- a/relay/manage.py +++ b/relay/manage.py @@ -9,7 +9,7 @@ import platform from aiohttp.web import AppRunner, TCPSite from cachetools import LRUCache -from . import app, misc, views, __version__ +from . import app, views, __version__ from .config import DotDict, RelayConfig, relay_software_names from .database import RelayDatabase from .misc import check_open_port, follow_remote_actor, unfollow_remote_actor @@ -385,7 +385,7 @@ async def handle_follow_actor(app, target): if config.is_banned(target): return click.echo(f'Error: Refusing to follow banned actor: {target}') - await misc.follow_remote_actor(target) + await follow_remote_actor(target) click.echo(f'Sent follow message to: {target}') @@ -398,7 +398,7 @@ async def handle_unfollow_actor(app, target): if not database.get_inbox(target): return click.echo(f'Error: Not following actor: {target}') - await misc.unfollow_remote_actor(target) + await unfollow_remote_actor(target) click.echo(f'Sent unfollow message to: {target}') diff --git a/relay/processors.py b/relay/processors.py index 130077e..f082ff5 100644 --- a/relay/processors.py +++ b/relay/processors.py @@ -4,7 +4,6 @@ import logging from uuid import uuid4 from . import app, misc -from .misc import distill_inboxes, distill_object_id, request async def handle_relay(actor, data, request):