import Crypto import asyncio import click import json import logging import os import platform from aiohttp.web import AppRunner, TCPSite from cachetools import LRUCache from . import app, misc, views from .config import DotDict, RelayConfig 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.pass_context def cli(ctx, 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() 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: if app['config'].host.endswith('example.com'): relay_setup.callback() else: 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.command('follow') @click.argument('actor') def relay_follow(actor): 'Follow an actor (Relay must be running)' loop = asyncio.new_event_loop() loop.run_until_complete(handle_follow_actor(actor)) @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)) @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 not app['is_docker'] and click.confirm('Relay all setup! Would you like to run it now?'): relay_run.callback() @cli.command('run') def relay_run(): 'Run the relay' 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('.') 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) 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) # 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): config = app['config'] if not target.startswith('http'): target = f'https://{target}/actor' if config.is_banned(target): return click.echo(f'Error: Refusing to follow banned 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(): cli(prog_name='relay') if __name__ == '__main__': click.echo('Running relay.manage is depreciated. Run `activityrelay [command]` instead.')