682 lines
16 KiB
Python
682 lines
16 KiB
Python
import json, os, sys
|
|
|
|
from configparser import ConfigParser
|
|
from datetime import datetime
|
|
from envbash import load_envbash
|
|
from getpass import getuser
|
|
from grp import getgrgid
|
|
from izzylib import DotDict, Path, boolean, logging, prompt, sudo
|
|
from izzylib.sql import Database
|
|
from os import environ as env
|
|
from urllib.parse import urlparse
|
|
|
|
from . import __version__
|
|
from .config import config, dbconfig, path, write_config
|
|
from .database import db
|
|
from .functions import fetch_actor, get_inbox
|
|
from .messages import Message
|
|
from .server import retry_instance
|
|
|
|
|
|
exe = f'{sys.executable} -m uncia.manage'
|
|
forbidden_keys = ['pubkey', 'privkey', 'version', 'rules']
|
|
|
|
|
|
## todo: use argparse to simplify argument parsing
|
|
class Command:
|
|
def __init__(self, arguments):
|
|
try:
|
|
cmd = arguments[0]
|
|
|
|
except IndexError:
|
|
self.result = self.cmd_help()
|
|
return
|
|
|
|
args = arguments[1:] if len(arguments) > 1 else []
|
|
|
|
try:
|
|
self.result = self[cmd](*args)
|
|
except InvalidCommandError:
|
|
self.result = f'Not a valid command: {cmd}'
|
|
|
|
|
|
def __getitem__(self, key):
|
|
try:
|
|
return getattr(self, f'cmd_{key}')
|
|
except AttributeError:
|
|
raise InvalidCommandError(f'Not a valid command: {key}')
|
|
|
|
|
|
def cmd_help(self):
|
|
return f'''Uncia Relay Management v{__version__}
|
|
|
|
python3 -m uncia.manage help:
|
|
Show this message.
|
|
|
|
python3 -m uncia.manage setup:
|
|
A series of questions will be asked to generate the env file.
|
|
|
|
python3 -m uncia.manage install [systemd or sysv]:
|
|
Generate an init service config and put it in the right location. Uses
|
|
"systemd" by default, but "sysv" can be specified instead
|
|
|
|
python3 -m uncia.manage list:
|
|
List currently subscribed instances.
|
|
|
|
python3 -m add <actor or domain>: *
|
|
Add an instance to the database.
|
|
|
|
python3 -m remove <actor, inbox, or domain>:
|
|
Remove an instance and any retries associated with it from the database.
|
|
|
|
python3 -m uncia.manage ban <handle@domain or domain> [*reason]:
|
|
Ban a user or domain. A reason may optionally be specified. Quotes not necessary for reason.
|
|
|
|
python3 -m uncia.manage unban <handle@domain or domain>: **
|
|
Unban a user or domain
|
|
|
|
python3 -m uncia.manage bans ["users" or "domains"]:
|
|
List the currently banned domains and users. A ban type ("users"/"domains")
|
|
may be specified to only list that type.
|
|
|
|
python3 -m uncia.manage rules [list]:
|
|
List the current rules.
|
|
|
|
python3 -m uncia.manage rules add <rule>: **
|
|
Add a rule to the list.
|
|
|
|
python3 -m uncia.manage rules remove <rule number>: **
|
|
Remove a rule from the list.
|
|
|
|
python3 -m uncia.manage config [key] [value]: **
|
|
Gets or sets the config. Specify a key and value to set a config option.
|
|
Only specify a key to get the value of a specific option. Leave out the
|
|
key and value to get all the options and their values.
|
|
|
|
python3 -m uncia.manage set <key> [value]: **
|
|
Set a config option to a value. If no value is specified, the default is used.
|
|
|
|
python3 -m uncia.manage request:
|
|
List the instances currently requesting to join.
|
|
|
|
python3 -m uncia.manage accept <actor, inbox, or domain>: *
|
|
Accept a request.
|
|
|
|
python3 -m uncia.manage deny <actor, inbox, or domain>: *
|
|
Reject a request.
|
|
|
|
python3 -m uncia.manage retries [domain]:
|
|
List all of the current retires. If a domain is specified, only list
|
|
retries from that instance
|
|
|
|
python3 -m uncia.manage retry [domain]:
|
|
Re-run retries. If a domain is specified, only the retries from that
|
|
instance will be ran.
|
|
|
|
python3 -m uncia.manage convert [pleroma or uncia]:
|
|
Convert the database and config of another relay. Tries to convert a
|
|
Pleroma Relay by default.
|
|
|
|
* = The relay needs to be running for the command to work
|
|
** = Caching prevents this from taking effect until restart
|
|
'''
|
|
|
|
|
|
def cmd_setup(self):
|
|
pass
|
|
|
|
|
|
def cmd_install(self, init='systemd'):
|
|
if init == 'systemd':
|
|
srvfile = path.config.join('uncia.service')
|
|
|
|
initcfg = ConfigParser()
|
|
initcfg['Unit'] = {
|
|
'Description': 'Uncia Relay',
|
|
'After': 'network.target'
|
|
}
|
|
|
|
initcfg['Service'] = {
|
|
'User': getuser(),
|
|
'Group': getgrgid(os.getgid()),
|
|
'WorkingDirectory': Path.cwd,
|
|
'ExecStart': f'{sys.executable} -m uncia',
|
|
'Restart': 'always',
|
|
'StartLimitIntervalSec': 30,
|
|
'StartLimitBurst': 5
|
|
}
|
|
|
|
initcfg['Install'] = {
|
|
'WantedBy': 'multi-user.target'
|
|
}
|
|
|
|
with srvfile.open('w') as fd:
|
|
initcfg.write(fd)
|
|
|
|
elif init == 'sysv':
|
|
return '¯\_(ツ)_/¯'
|
|
|
|
else:
|
|
return f'Error: Invalid service type: {init}\nValid types: systemd, sysv'
|
|
|
|
return f'Service file saved to {path.config.join("uncia.service")}'
|
|
|
|
# incomplete code to copy service file
|
|
#copy_srv = None
|
|
|
|
#while type(copy_srv) != bool:
|
|
#if copy_srv != None:
|
|
#print('Please specify a boolean (yes/no, y/n, true/false, etc)')
|
|
|
|
#copy_srv = prompt('Copy service file to /etc/systemd/system/uncia.service?',
|
|
#valtype = lambda x: boolean(x, return_value=True),
|
|
#default = True
|
|
#)
|
|
|
|
|
|
|
|
|
|
def cmd_config(self, key=None, value=None):
|
|
with db.session as s:
|
|
if key and value:
|
|
if key in forbidden_keys:
|
|
return f'Refusing to set "{key}"'
|
|
|
|
if value == DefaultValue:
|
|
value = None
|
|
|
|
value = s.put.config(key, value)
|
|
return f'Set {key} = "{value}"'
|
|
|
|
elif key and not value:
|
|
value = s.get.config(key)
|
|
return f'Value for "{key}": {value}'
|
|
|
|
output = 'Current config:\n'
|
|
|
|
for key, value in s.get.config_all().items():
|
|
if key not in forbidden_keys:
|
|
output += f' {key}: {value}\n'
|
|
|
|
return output
|
|
|
|
|
|
def cmd_set(self, key, *value):
|
|
if not value:
|
|
return self.cmd_config(key, DefaultValue)
|
|
|
|
return self.cmd_config(key, ' '.join(value))
|
|
|
|
|
|
def cmd_request(self, action=None, url=None):
|
|
with db.session as s:
|
|
if not action:
|
|
instances = []
|
|
|
|
for row in s.search('inbox'):
|
|
if row.followid:
|
|
instances.append(row.domain)
|
|
|
|
if not instances:
|
|
return 'No requests'
|
|
|
|
text = 'Awaiting Requests:\n'
|
|
text += '\n'.join([f'- {domain}' for domain in instances])
|
|
return text
|
|
|
|
else:
|
|
instance = s.get.instance(url)
|
|
|
|
if instance.followid:
|
|
if action == 'accept':
|
|
s.update(row=instance, followid=None)
|
|
text = f'Accepted {url}'
|
|
|
|
elif action == 'reject':
|
|
s.remove(row=instance)
|
|
text = f'Rejected {url}'
|
|
|
|
else:
|
|
return f'Not a valid request action: {action}'
|
|
|
|
Message('accept', instance.followid, instance.actor).send(instance.inbox)
|
|
return text
|
|
|
|
|
|
def cmd_accept(self, url):
|
|
return self.cmd_request('accept', url)
|
|
|
|
|
|
def cmd_reject(self, url):
|
|
return self.cmd_request('reject', url)
|
|
|
|
|
|
def cmd_list(self):
|
|
instance_list = 'Connected Instances:\n'
|
|
|
|
with db.session as s:
|
|
instance_list += '\n'.join([f'- {domain}' for domain in s.get.instance_list('domain')])
|
|
|
|
return instance_list
|
|
|
|
|
|
def cmd_add(self, actor_url):
|
|
if not actor_url.startswith('https://'):
|
|
actor_url = f'https://{actor_url}/actor'
|
|
|
|
actor = fetch_actor(actor_url)
|
|
inbox = get_inbox(actor)
|
|
|
|
if not actor:
|
|
return f'Failed to fetch actor at {actor_url}'
|
|
|
|
with db.session as s:
|
|
if s.get.instance(actor_url):
|
|
return f'Instance already added to the relay'
|
|
|
|
instance = s.put.instance(inbox, actor_url)
|
|
|
|
if instance:
|
|
return f'Added {instance.domain} to the relay'
|
|
|
|
else:
|
|
return f'Failed to add {actor_url} to the relay'
|
|
|
|
|
|
def cmd_remove(self, data):
|
|
with db.session as s:
|
|
if s.delete.instance(data):
|
|
return f'Instance removed: {data}'
|
|
|
|
else:
|
|
return f'Instance does not exist: {data}'
|
|
|
|
|
|
def cmd_rules(self, *args):
|
|
try:
|
|
action = args[0]
|
|
except IndexError:
|
|
action = 'list'
|
|
|
|
try:
|
|
rule = ' '.join(args[1:])
|
|
except IndexError:
|
|
rule = None
|
|
|
|
if action in ['add', 'remove'] and rule == None:
|
|
return 'You forgot to specify a rule'
|
|
|
|
with db.session as s:
|
|
rules = s.get.config('rules')
|
|
|
|
if action == 'list':
|
|
if not rules:
|
|
return 'No rules specified'
|
|
|
|
text = 'Relay Rules:\n'
|
|
text += '\n'.join([f'{idx} {rule}' for idx, rule in enumerate(rules)])
|
|
|
|
return text
|
|
|
|
elif action == 'add':
|
|
rules.append(rule)
|
|
s.put.config('rules', rules)
|
|
return f'Added rule: {rule}'
|
|
|
|
elif action == 'remove':
|
|
rule = int(rule)
|
|
rule_text = rules[rule]
|
|
|
|
rules.remove(rule_text)
|
|
s.put.config('rules', rules)
|
|
return f'Removed rule: {rule_text}'
|
|
|
|
|
|
def cmd_ban(self, string, *reason):
|
|
return self.manage_ban('ban', string, *reason)
|
|
|
|
|
|
def cmd_unban(self, string):
|
|
return self.manage_ban('unban', string)
|
|
|
|
|
|
def cmd_bans(self, type=None):
|
|
if type and type not in ['domains', 'users']:
|
|
return 'Ban type needs to be "domains" or "users"'
|
|
|
|
data = ''
|
|
user_bans = []
|
|
domain_bans = []
|
|
|
|
with db.session as s:
|
|
if type == 'domains':
|
|
domain_bans = s.get.ban_list('domain')
|
|
|
|
elif type == 'users':
|
|
user_bans = s.get.ban_list('user')
|
|
|
|
else:
|
|
domain_bans = s.get.ban_list('domain')
|
|
user_bans = s.get.ban_list('user')
|
|
|
|
if domain_bans:
|
|
data += 'Domain Bans:\n'
|
|
|
|
for ban in domain_bans:
|
|
if ban.reason:
|
|
data += f'- {ban.domain}: {ban.reason}\n'
|
|
else:
|
|
data += f'- {ban.domain}\n'
|
|
|
|
if user_bans:
|
|
data += '\nUser Bans:\n'
|
|
|
|
for ban in user_bans:
|
|
if ban.reason:
|
|
data += f'- {ban.handle}@{ban.domain}: {ban.reason}\n'
|
|
else:
|
|
data += f'- {ban.handle}@{ban.domain}\n'
|
|
|
|
|
|
return data or 'No banned domains or users yet'
|
|
|
|
|
|
def cmd_retries(self, domain=None):
|
|
with db.session as s:
|
|
if domain:
|
|
instances = [s.get.instance(domain)]
|
|
|
|
else:
|
|
instances = s.search('inbox')
|
|
|
|
retries = []
|
|
|
|
for instance in instances:
|
|
for retry in instance.retries():
|
|
retries.append(f'{retry.timestamp} {retry.msgid}')
|
|
|
|
return '\n'.join(retries)
|
|
|
|
|
|
def cmd_retry(self, domain=None):
|
|
with db.session as s:
|
|
if domain:
|
|
instances = [s.get.instance(domain)]
|
|
|
|
else:
|
|
instances = s.search('inbox')
|
|
|
|
for instance in instances:
|
|
retry_instance(instance)
|
|
|
|
|
|
def cmd_convert(self, relay='uncia'):
|
|
if relay.lower() == 'uncia':
|
|
return self.convert_uncia()
|
|
|
|
if relay.lower() == 'pleroma':
|
|
return self.convert_pleroma()
|
|
|
|
|
|
def convert_uncia(self):
|
|
cfg = load_old_uncia_config()
|
|
pgdb = Database('postgresql', **cfg)
|
|
|
|
new_cfg = cfg.copy()
|
|
new_cfg.pop('name', None)
|
|
dbconfig.update(new_cfg)
|
|
|
|
with pgdb.session as s:
|
|
oldcfg = DotDict({row.key: row.value for row in s.search('config')})
|
|
inboxes = s.search('inboxes')
|
|
requests = s.search('requests')
|
|
retries = s.search('retries')
|
|
users = s.search('users')
|
|
whitelist = s.search('whitelist')
|
|
domainbans = s.search('domainbans')
|
|
userbans = s.search('userbans')
|
|
actorkey = s.fetch('keys', actor='default')
|
|
|
|
config.host = oldcfg.host
|
|
config.listen = oldcfg.address
|
|
config.port = oldcfg.port
|
|
|
|
write_config()
|
|
|
|
with db.session as s:
|
|
s.put.config('name', oldcfg.name)
|
|
s.put.config('admin', oldcfg.admin)
|
|
s.put.config('show_domain_bans', boolean(oldcfg.show_domainbans))
|
|
s.put.config('show_user_bans', boolean(oldcfg.show_userbans))
|
|
s.put.config('whitelist', boolean(oldcfg.whitelist))
|
|
s.put.config('block_relays', boolean(oldcfg.block_relays))
|
|
s.put.config('require_approval', boolean(oldcfg.require_approval))
|
|
s.put.config('privkey', actorkey.privkey)
|
|
s.put.config('pubkey', actorkey.pubkey)
|
|
|
|
for inbox in [*inboxes, *requests]:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(inbox.timestamp)
|
|
except:
|
|
timestamp = datetime.now()
|
|
|
|
s.insert('inbox',
|
|
actor = inbox.actor,
|
|
inbox = inbox.inbox,
|
|
domain = inbox.domain,
|
|
followid = inbox.get('followid'),
|
|
timestamp = timestamp
|
|
)
|
|
|
|
for retry in retries:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(retry.timestamp)
|
|
except:
|
|
timestamp = datetime.now()
|
|
|
|
instance = s.fetch('inbox', inbox=retry.inbox)
|
|
|
|
if not instance:
|
|
continue
|
|
|
|
s.insert('retry',
|
|
inboxid = instance.id,
|
|
msgid = retry.msgid,
|
|
headers = json.loads(retry.headers),
|
|
data = json.loads(retry.data),
|
|
timestamp = timestamp
|
|
)
|
|
|
|
for user in users:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(user.timestamp)
|
|
except:
|
|
timestamp = datetime.now()
|
|
|
|
s.insert('user',
|
|
handle = user.handle,
|
|
username = user.username,
|
|
hash = user.password,
|
|
level = 30,
|
|
timestamp = timestamp
|
|
)
|
|
|
|
for instance in whitelist:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(instance.timestamp)
|
|
except:
|
|
timestamp = datetime.now()
|
|
|
|
s.insert('whitelist',
|
|
domain = instance.domain,
|
|
timestamp = timestamp
|
|
)
|
|
|
|
for row in [*domainbans, *userbans]:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(row.timestamp)
|
|
except:
|
|
timestamp = datetime.now()
|
|
|
|
s.insert('ban',
|
|
handle = row.get('username'),
|
|
domain = None if row.domain.lower() == 'any' else row.domain,
|
|
reason = row.reason,
|
|
timestamp = timestamp
|
|
)
|
|
|
|
pgdb.close()
|
|
|
|
delete_old = prompt('Config and database successfully converted. Delete old files and drop the old database?', default='no', valtype=boolean, options=['yes', 'no'])
|
|
|
|
if delete_old:
|
|
path.data.join('production.env').delete()
|
|
|
|
pgdb._connect_args[0]['name'] = 'postgres'
|
|
pgdb.open()
|
|
|
|
with pgdb.session as s:
|
|
s.execute(f'DROP DATABASE {cfg.name}')
|
|
|
|
pgdb.close()
|
|
|
|
return f'Successfully dropped database: {cfg.name}'
|
|
|
|
|
|
def convert_pleroma(self):
|
|
try:
|
|
pl_cfg = load_pleroma_relay_config()
|
|
pl_db = DotDict()
|
|
pl_db.load_json(pl_cfg.db)
|
|
|
|
except FileNotFoundError as e:
|
|
return f'Error when loading config or database: {e}'
|
|
|
|
config.listen = pl_cfg.listen
|
|
config.port = pl_cfg.port
|
|
config.host = pl_cfg.host
|
|
|
|
write_config()
|
|
|
|
for instance in pl_db['relay-list']:
|
|
self.cmd_add(urlparse(instance).netloc)
|
|
|
|
with db.session as s:
|
|
s.put.config('privkey', pl_db.actorKeys.privateKey)
|
|
s.put.config('pubkey', pl_db.actorKeys.publicKey)
|
|
s.put.config('whitelist', pl_cfg.whitelist_enabled)
|
|
s.put.config('description', pl_cfg.note)
|
|
s.put.config('blocked_software', pl_cfg.blocked_software)
|
|
|
|
for domain in pl_cfg.blocked_instances:
|
|
s.put.ban(domain=domain)
|
|
|
|
for domain in pl_cfg.whitelist:
|
|
s.put.whitelist(domain)
|
|
|
|
delete_old = prompt('Config and database successfully converted. Delete old files?', default='no', valtype=boolean, options=['yes', 'no'])
|
|
|
|
if delete_old:
|
|
Path('relay.yaml').delete()
|
|
pl_cfg.db.delete()
|
|
|
|
|
|
def manage_ban(self, action, string, *args):
|
|
handle = None
|
|
domain = None
|
|
|
|
data = string.split('@')
|
|
|
|
if len(data) == 1:
|
|
domain = data[0]
|
|
|
|
elif len(data) == 2:
|
|
handle = data[0]
|
|
domain = data[1]
|
|
|
|
elif len(data) == 3:
|
|
handle = data[1]
|
|
domain = data[2]
|
|
|
|
if domain.lower() == 'none':
|
|
domain = None
|
|
|
|
reason = None if not args else ' '.join(args)
|
|
data = f'{handle}@{domain}' if handle else domain
|
|
|
|
with db.session as s:
|
|
if action == 'ban':
|
|
row = s.put.ban(handle, domain, reason)
|
|
return f'Banned {data}' if row else f'Already banned {data}'
|
|
|
|
elif action == 'unban':
|
|
row = s.delete.ban(handle, domain)
|
|
return f'Unbanned {data}' if row else f'{data} not banned'
|
|
|
|
return f'Invalid action: {action}'
|
|
|
|
|
|
def load_old_uncia_config():
|
|
try:
|
|
load_envbash(path.config.join('production.env'))
|
|
|
|
except FileNotFoundError as e:
|
|
pass
|
|
|
|
cfg = DotDict(
|
|
host = env.get('DBHOST'),
|
|
port = int(env.get('DBPORT', 5432)),
|
|
user = env.get('DBUSER', getuser()),
|
|
password = env.get('DBPASS'),
|
|
name = env.get('DBNAME', 'uncia'),
|
|
maxconnections = int(env.get('DBCONNUM', 15))
|
|
)
|
|
|
|
#for k,v in cfg.items():
|
|
#if v == None:
|
|
#del cfg[k]
|
|
|
|
#if k == 'host' and Path(v).exists():
|
|
#cfg['unix_socket'] = v
|
|
#del cfg[k]
|
|
|
|
return cfg
|
|
|
|
|
|
def load_pleroma_relay_config():
|
|
with open('relay.yaml') as f:
|
|
yaml_file = yaml.load(f, Loader=yaml.FullLoader)
|
|
|
|
if not yaml_file.get('ap'):
|
|
raise ValueError('Missing AP section in Pleroma Relay config')
|
|
|
|
config = DotDict({
|
|
'db': Path(yaml_file.get('db', 'relay.jsonld')).resolve,
|
|
'listen': yaml_file.get('listen', '127.0.0.1'),
|
|
'port': int(yaml_file.get('port', 3621)),
|
|
'note': yaml_file.get('note'),
|
|
'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
|
|
|
|
|
|
class InvalidCommandError(Exception):
|
|
pass
|
|
|
|
|
|
class DefaultValue(object):
|
|
pass
|
|
|
|
|
|
if __name__ == '__main__':
|
|
args = sys.argv[1:] if len(sys.argv) > 1 else []
|
|
cmd = Command(args)
|
|
|
|
if cmd.result:
|
|
print(cmd.result)
|