uncia/uncia/manage.py

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)