597 lines
14 KiB
Python
597 lines
14 KiB
Python
import Crypto
|
|
import asyncio
|
|
import click
|
|
import json
|
|
import logging
|
|
import platform
|
|
import yaml
|
|
|
|
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 not ctx.invoked_subcommand:
|
|
if app.config.host.endswith('example.com'):
|
|
cli_setup.callback()
|
|
|
|
else:
|
|
cli_run.callback()
|
|
|
|
if ctx.invoked_subcommand != 'convert':
|
|
app.setup()
|
|
|
|
|
|
@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('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.')
|