sedi-relay/relay/manage.py
2022-12-20 07:59:58 -05:00

722 lines
17 KiB
Python

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):
'Convert an old relay.yaml and relay.jsonld to the the new formats'
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['database_type'] = click.prompt(
'What database backend would you like to use for the relay?',
default = app.config.dbtype,
type = click.Choice(['sqlite', 'postgresql', 'mysql']),
show_choices = True
)
if app.config.dbtype == 'sqlite':
app.config['sqlite_database'] = click.prompt(
'Where would you like to store your database file? Relative paths are relative to the config file location.',
default = app.config['sqlite_database']
)
else:
dbconfig = app.config.dbconfig
app.config.hostname = click.prompt(
'What address is your database listening on?',
default = dbconfig.hostname
) or None
app.config.port = click.prompt(
'What port is your database listening on?',
default = dbconfig.port
) or None
app.config.database = click.prompt(
'What would you like the name of the database be?',
default = dbconfig.database
) or None
app.config.username = click.prompt(
'Which user will be connecting to the database?',
default = dbconfig.username
) or None
app.config.password = click.prompt(
'What is the database user\'s password?',
default = dbconfig.password
) or None
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.')