import Crypto import asyncio import click import json import logging import platform import yaml from datetime import datetime from urllib.parse import urlparse from . import __version__ from .application import Application from .database import DEFAULT_CONFIG, RELAY_SOFTWARE from .http_client import get, post, fetch_nodeinfo from .misc import Message, boolean, check_open_port app = None CONFIG_IGNORE = { 'privkey', 'version' } @click.group('cli', context_settings={'show_default': True}, invoke_without_command=True) @click.option('--config', '-c', help='path to the relay\'s config') @click.version_option(version=__version__, prog_name='ActivityRelay') @click.pass_context def cli(ctx, config): global app app = Application(config) if ctx.invoked_subcommand != 'convert': app.setup() if not ctx.invoked_subcommand: if app.config.host.endswith('example.com'): cli_setup.callback() else: cli_run.callback() @cli.command('convert') @click.option('--old-config', '-o', help='path to the old relay config') def cli_convert(old_config): with open(old_config or 'relay.yaml') as fd: config = yaml.load(fd.read(), Loader=yaml.SafeLoader) ap = config.get('ap', {}) with open(config.get('db', 'relay.jsonld')) as fd: db = json.load(fd) app.config['general_host'] = ap.get('host', '__DEFAULT__') app.config['general_listen'] = config.get('listen', '__DEFAULT__') app.config['general_port'] = config.get('port', '__DEFAULT__') with app.database.session as s: s.put_config('description', config.get('note', '__DEFAULT__')) s.put_config('push_limit', config.get('push_limit', '__DEFAULT__')) s.put_config('json_cache', config.get('json_cache', '__DEFAULT__')) s.put_config('workers', config.get('workers', '__DEFAULT__')) s.put_config('http_timeout', config.get('timeout', '__DEFAULT__')) s.put_config('privkey', db.get('private-key')) for name in ap.get('blocked_software', []): try: s.put_ban('software', name) except KeyError: print(f'Already banned software: {name}') for name in ap.get('blocked_instances', []): try: s.put_ban('domain', name) except KeyError: print(f'Already banned instance: {name}') for name in ap.get('whitelist', []): try: s.put_whitelist(name) except KeyError: print(f'Already whitelisted domain: {name}') for instance in db.get('relay-list', {}).values(): domain = instance['domain'] software = instance.get('software') actor = None if software == 'mastodon': actor = f'https://{domain}/actor' elif software in {'pleroma', 'akkoma'}: actor = f'https://{domain}/relay' s.put_instance( domain = domain, inbox = instance.get('inbox'), software = software, actor = actor, followid = instance.get('followid'), accept = True ) app.config.save() print('Config and database converted :3') @cli.command('setup') def cli_setup(): 'Generate a new config' while True: app.config['general_host'] = click.prompt('What domain will the relay be hosted on?', default=app.config.host) if not app.config.host.endswith('example.com'): break click.echo('The domain must not be example.com') if not app.config.is_docker: app.config['general_listen'] = click.prompt('Which address should the relay listen on?', default=app.config.listen) while True: app.config['general_port'] = click.prompt('What TCP port should the relay listen on?', default=app.config.port, type=int) break app.config.save() with app.database.session as s: s.put_config('name', click.prompt( 'What do you want to name your relay?', default = s.get_config('name') )) s.put_config('description', click.prompt( 'Provide a small description of your relay. This will be on the front page', default = s.get_config('description') )) s.put_config('whitelist', click.prompt( 'Enable the whitelist?', default = s.get_config('whitelist'), type = boolean )) s.put_config('require_approval', click.prompt( 'Require instances to be approved when following?', default = s.get_config('require_approval'), type = boolean )) if not app.config.is_docker and click.confirm('Relay all setup! Would you like to run it now?'): cli_run.callback() @cli.command('run') def cli_run(): 'Run the relay' with app.database.session as s: if not s.get_config('privkey') or app.config.host.endswith('example.com'): return click.echo('Relay is not set up. Please 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(app.config.listen, app.config.port): return click.echo(f'Error: A server is already running on port {app.config.port}') app.run() # todo: add config default command for resetting config key @cli.group('config') def cli_config(): 'Manage the relay config' pass @cli_config.command('list') def cli_config_list(): 'List the current relay config' click.echo('Relay Config:') with app.database.session as s: config = s.get_config_all() for key in DEFAULT_CONFIG.keys(): if key in CONFIG_IGNORE: continue keystr = f'{key}:'.ljust(20) click.echo(f'- {keystr} {config[key]}') @cli_config.command('set') @click.argument('key') @click.argument('value', nargs=-1) def cli_config_set(key, value): 'Set a config value' with app.database.session as s: s.put_config(key, ' '.join(value)) value = s.get_config(key) print(f'{key}: {value}') @cli.group('inbox') def cli_inbox(): 'Manage the inboxes in the database' pass @cli_inbox.command('list') def cli_inbox_list(): 'List the connected instances or relays' click.echo('Connected to the following instances or relays:') with app.database.session as s: for instance in s.get_instances(): click.echo(f'- {instance.inbox}') @cli_inbox.command('follow') @click.argument('actor') def cli_inbox_follow(actor): 'Follow an actor (Relay must be running)' if not actor.startswith('http'): domain = actor actor = f'https://{actor}/actor' else: domain = urlparse(actor).hostname with app.database.session as s: if s.get_ban('domain', domain): return click.echo(f'Error: Refusing to follow banned actor: {actor}') instance = s.get_instance(domain) if not instance: actor_data = asyncio.run(get(actor, sign_headers=True)) if not actor_data: return click.echo(f'Failed to fetch actor: {actor}') inbox = actor_data.shared_inbox else: inbox = instance.inbox if instance.actor: actor = instance.actor message = Message.new_follow( host = app.config.host, actor = actor ) asyncio.run(post(inbox, message)) click.echo(f'Sent follow message to actor: {actor}') @cli_inbox.command('unfollow') @click.argument('actor') def cli_inbox_unfollow(actor): 'Unfollow an actor (Relay must be running)' followid = None if not actor.startswith('http'): domain = actor actor = f'https://{actor}/actor' else: domain = urlparse(actor).hostname with app.database.session as s: instance = s.get_instance(domain) if not instance: actor_data = asyncio.run(get(actor, sign_headers=True)) if not actor_data: return click.echo(f'Failed to fetch actor: {actor}') inbox = actor_data.shared_inbox else: inbox = instance.inbox followid = instance.followid if instance.actor: actor = instance.actor if followid: message = Message.new_unfollow( host = app.config.host, actor = actor, follow = followid ) else: message = misc.Message.new_unfollow( host = app.config.host, actor = actor, follow = { 'type': 'Follow', 'object': actor, 'actor': app.config.actor } ) asyncio.run(post(inbox, message)) click.echo(f'Sent unfollow message to: {actor}') @cli_inbox.command('add') @click.argument('actor') def cli_inbox_add(actor): 'Add an instance to the database' if not actor.startswith('http'): domain = actor actor = f'https://{actor}/inbox' else: domain = urlparse(actor).hostname with app.database.session as s: data = { 'domain': domain, 'actor': actor, 'inbox': f'https://{domain}/inbox' } if s.get_instance(domain): return click.echo(f'Error: Instance already in database: {domain}') if s.get_ban('domain', domain): return click.echo(f'Error: Refusing to add banned domain: {domain}') nodeinfo = asyncio.run(fetch_nodeinfo(domain)) if nodeinfo: if s.get_ban('software', nodeinfo.sw_name): return click.echo(f'Error: Refusing to add banned software: {nodeinfo.sw_name}') data['software'] = nodeinfo.sw_name actor_data = asyncio.run(get(actor, sign_headers=True)) if actor_data: instance = s.put_instance_actor(actor, nodeinfo) else: instance = s.put_instance(**data) click.echo(f'Added instance to the database: {instance.domain}') @cli_inbox.command('remove') @click.argument('domain') def cli_inbox_remove(domain): 'Remove an inbox from the database' if domain.startswith('http'): domain = urlparse(domain).hostname with app.database.session as s: try: s.delete_instance(domain) click.echo(f'Removed inbox from the database: {domain}') except KeyError: return click.echo(f'Error: Inbox does not exist: {domain}') @cli.group('request') def cli_request(): 'Manage follow requests' @cli_request.command('list') def cli_request_list(): 'List all the current follow requests' click.echo('Follow requests:') with app.database.session as s: for row in s.get_requests(): click.echo(f'- {row.domain}') @cli_request.command('approve') @click.argument('domain') def cli_request_approve(domain): 'Approve a follow request' with app.database.session as s: try: instance = s.get_request(domain) except KeyError: return click.echo(f'No request for domain exists: {domain}') data = {'joined': datetime.now()} s.update('instances', data, id=instance.id) asyncio.run(post( instance.inbox, Message.new_response( host = app.config.host, actor = instance.actor, followid = instance.followid, accept = True ) )) return click.echo(f'Accepted follow request for domain: {domain}') @cli_request.command('deny') @click.argument('domain') def cli_request_deny(domain): 'Deny a follow request' with app.database.session as s: try: instance = s.get_request(domain) except KeyError: return click.echo(f'No request for domain exists: {domain}') s.delete_instance(domain) asyncio.run(post( instance.inbox, Message.new_response( host = app.config.host, actor = instance.actor, followid = instance.followid, accept = False ) )) return click.echo(f'Denied follow request for domain: {domain}') @cli.group('instance') def cli_instance(): 'Manage instance bans' pass @cli_instance.command('list') def cli_instance_list(): 'List all banned instances' click.echo('Banned instances or relays:') with app.database.session as s: for row in s.get_bans('domain'): click.echo(f'- {row.name}') @cli_instance.command('ban') @click.argument('domain') def cli_instance_ban(domain): 'Ban an instance and remove the associated inbox if it exists' if domain.startswith('http'): domain = urlparse(domain).hostname with app.database.session as s: try: s.put_ban('domain', domain) except KeyError: return click.echo(f'Instance already banned: {domain}') try: s.delete_instance(domain) except KeyError: pass click.echo(f'Banned instance: {domain}') @cli_instance.command('unban') @click.argument('domain') def cli_instance_unban(domain): 'Unban an instance' if domain.startswith('http'): domain = urlparse(domain).hostname with app.database.session as s: try: s.delete_ban('domain', domain) click.echo(f'Unbanned instance: {domain}') except KeyError: click.echo(f'Instance wasn\'t banned: {domain}') @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:') with app.database.session as s: for row in s.get_bans('software'): click.echo(f'- {row.name}') @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' with app.database.session as s: if name == 'RELAYS': for name in RELAY_SOFTWARE: s.put_ban('software', name) return click.echo('Banned all relay software') if fetch_nodeinfo: nodeinfo = asyncio.run(fetch_nodeinfo(name)) if not nodeinfo: return click.echo(f'Failed to fetch software name from domain: {name}') name = nodeinfo.sw_name try: s.put_ban('software', name) click.echo(f'Banned software: {name}') except KeyError: 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' with app.database.session as s: if name == 'RELAYS': for name in RELAY_SOFTWARE: s.put_ban('software', name) return click.echo('Unbanned all relay software') if fetch_nodeinfo: nodeinfo = asyncio.run(fetch_nodeinfo(name)) if not nodeinfo: return click.echo(f'Failed to fetch software name from domain: {name}') name = nodeinfo.sw_name try: s.put_ban('software', name) click.echo(f'Unbanned software: {name}') except KeyError: 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(): 'List all the instances in the whitelist' click.echo('Current whitelisted domains:') with app.database.session as s: for row in s.get_whitelist(): click.echo(f'- {row.domain}') @cli_whitelist.command('add') @click.argument('domain') def cli_whitelist_add(domain): 'Add a domain to the whitelist' with app.database.session as s: try: s.put_whitelist(domain) click.echo(f'Instance added to the whitelist: {domain}') except KeyError: return click.echo(f'Instance already in the whitelist: {domain}') @cli_whitelist.command('remove') @click.argument('domain') def cli_whitelist_remove(domain): 'Remove a domain from the whitelist' with app.database.session as s: try: s.delete_whitelist(domain) click.echo(f'Removed instance from the whitelist: {domain}') except KeyError: click.echo(f'Instance not in the whitelist: {domain}') @cli_whitelist.command('import') def cli_whitelist_import(): 'Add all current inboxes to the whitelist' with app.database.session as s: for row in s.get_instances(): try: s.put_whitelist(row.domain) click.echo(f'Instance added to the whitelist: {row.domain}') except KeyError: click.echo(f'Instance already in the whitelist: {row.domain}') @cli_whitelist.command('clear') def cli_whitelist_clear(): 'Clear all items out of the whitelist' with app.database.session as s: s.delete('whitelist') def main(): cli(prog_name='relay') if __name__ == '__main__': click.echo('Running relay.manage is depreciated. Run `activityrelay [command]` instead.')