1
0
mirror of https://github.com/Mailu/Mailu.git synced 2025-03-03 14:52:36 +02:00
Mailu/core/admin/mailu/manage.py
2021-01-14 01:11:04 +01:00

445 lines
13 KiB
Python

""" Mailu command line interface
"""
import sys
import os
import socket
import uuid
import click
from flask import current_app as app
from flask.cli import FlaskGroup, with_appcontext
from marshmallow.exceptions import ValidationError
from . import models
from .schemas import MailuSchema
db = models.db
@click.group(cls=FlaskGroup, context_settings={'help_option_names': ['-?', '-h', '--help']})
def mailu():
""" Mailu command line
"""
@mailu.command()
@with_appcontext
def advertise():
""" Advertise this server against statistic services.
"""
if os.path.isfile(app.config['INSTANCE_ID_PATH']):
with open(app.config['INSTANCE_ID_PATH'], 'r') as handle:
instance_id = handle.read()
else:
instance_id = str(uuid.uuid4())
with open(app.config['INSTANCE_ID_PATH'], 'w') as handle:
handle.write(instance_id)
if not app.config['DISABLE_STATISTICS']:
try:
socket.gethostbyname(app.config['STATS_ENDPOINT'].format(instance_id))
except OSError:
pass
@mailu.command()
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password')
@click.option('-m', '--mode')
@with_appcontext
def admin(localpart, domain_name, password, mode='create'):
""" Create an admin user
'mode' can be:
- 'create' (default) Will try to create user and will raise an exception if present
- 'ifmissing': if user exists, nothing happens, else it will be created
- 'update': user is created or, if it exists, its password gets updated
"""
domain = models.Domain.query.get(domain_name)
if not domain:
domain = models.Domain(name=domain_name)
db.session.add(domain)
user = None
if mode == 'ifmissing' or mode == 'update':
email = '{}@{}'.format(localpart, domain_name)
user = models.User.query.get(email)
if user and mode == 'ifmissing':
print('user %s exists, not updating' % email)
return
if not user:
user = models.User(
localpart=localpart,
domain=domain,
global_admin=True
)
user.set_password(password)
db.session.add(user)
db.session.commit()
print("created admin user")
elif mode == 'update':
user.set_password(password)
db.session.commit()
print("updated admin password")
@mailu.command()
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password')
@click.argument('hash_scheme', required=False)
@with_appcontext
def user(localpart, domain_name, password, hash_scheme=None):
""" Create a user
"""
if hash_scheme is None:
hash_scheme = app.config['PASSWORD_SCHEME']
domain = models.Domain.query.get(domain_name)
if not domain:
domain = models.Domain(name=domain_name)
db.session.add(domain)
user = models.User(
localpart=localpart,
domain=domain,
global_admin=False
)
user.set_password(password, hash_scheme=hash_scheme)
db.session.add(user)
db.session.commit()
@mailu.command()
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password')
@click.argument('hash_scheme', required=False)
@with_appcontext
def password(localpart, domain_name, password, hash_scheme=None):
""" Change the password of an user
"""
email = '{0}@{1}'.format(localpart, domain_name)
user = models.User.query.get(email)
if hash_scheme is None:
hash_scheme = app.config['PASSWORD_SCHEME']
if user:
user.set_password(password, hash_scheme=hash_scheme)
else:
print("User " + email + " not found.")
db.session.commit()
@mailu.command()
@click.argument('domain_name')
@click.option('-u', '--max-users')
@click.option('-a', '--max-aliases')
@click.option('-q', '--max-quota-bytes')
@with_appcontext
def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0):
""" Create a domain
"""
domain = models.Domain.query.get(domain_name)
if not domain:
domain = models.Domain(name=domain_name, max_users=max_users,
max_aliases=max_aliases, max_quota_bytes=max_quota_bytes)
db.session.add(domain)
db.session.commit()
@mailu.command()
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password_hash')
@click.argument('hash_scheme')
@with_appcontext
def user_import(localpart, domain_name, password_hash, hash_scheme = None):
""" Import a user along with password hash.
"""
if hash_scheme is None:
hash_scheme = app.config['PASSWORD_SCHEME']
domain = models.Domain.query.get(domain_name)
if not domain:
domain = models.Domain(name=domain_name)
db.session.add(domain)
user = models.User(
localpart=localpart,
domain=domain,
global_admin=False
)
user.set_password(password_hash, hash_scheme=hash_scheme, raw=True)
db.session.add(user)
db.session.commit()
# @mailu.command()
# @click.option('-v', '--verbose', is_flag=True, help='Increase verbosity')
# @click.option('-d', '--delete-objects', is_flag=True, help='Remove objects not included in yaml')
# @click.option('-n', '--dry-run', is_flag=True, help='Perform a trial run with no changes made')
# @click.argument('source', metavar='[FILENAME|-]', type=click.File(mode='r'), default=sys.stdin)
# @with_appcontext
# def config_update(verbose=False, delete_objects=False, dry_run=False, source=None):
# """ Update configuration with data from YAML-formatted input
# """
# try:
# new_config = yaml.safe_load(source)
# except (yaml.scanner.ScannerError, yaml.parser.ParserError) as exc:
# print(f'[ERROR] Invalid yaml: {exc}')
# sys.exit(1)
# else:
# if isinstance(new_config, str):
# print(f'[ERROR] Invalid yaml: {new_config!r}')
# sys.exit(1)
# elif new_config is None or not new_config:
# print('[ERROR] Empty yaml: Please pipe yaml into stdin')
# sys.exit(1)
# error = False
# tracked = {}
# for section, model in yaml_sections:
# items = new_config.get(section)
# if items is None:
# if delete_objects:
# print(f'[ERROR] Invalid yaml: Section "{section}" is missing')
# error = True
# break
# else:
# continue
# del new_config[section]
# if not isinstance(items, list):
# print(f'[ERROR] Section "{section}" must be a list, not {items.__class__.__name__}')
# error = True
# break
# elif not items:
# continue
# # create items
# for data in items:
# if verbose:
# print(f'Handling {model.__table__} data: {data!r}')
# try:
# changed = model.from_dict(data, delete_objects)
# except Exception as exc:
# print(f'[ERROR] {exc.args[0]} in data: {data}')
# error = True
# break
# for item, created in changed:
# if created is True:
# # flush newly created item
# db.session.add(item)
# db.session.flush()
# if verbose:
# print(f'Added {item!r}: {item.to_dict()}')
# else:
# print(f'Added {item!r}')
# elif created:
# # modified instance
# if verbose:
# for key, old, new in created:
# print(f'Updated {key!r} of {item!r}: {old!r} -> {new!r}')
# else:
# print(f'Updated {item!r}: {", ".join(sorted([kon[0] for kon in created]))}')
# # track primary key of all items
# tracked.setdefault(item.__class__, set()).update(set([item._dict_pval()]))
# if error:
# break
# # on error: stop early
# if error:
# print('[ERROR] An error occured. Not committing changes.')
# db.session.rollback()
# sys.exit(1)
# # are there sections left in new_config?
# if new_config:
# print(f'[ERROR] Unknown section(s) in yaml: {", ".join(sorted(new_config.keys()))}')
# error = True
# # test for conflicting domains
# domains = set()
# for model, items in tracked.items():
# if model in (models.Domain, models.Alternative, models.Relay):
# if domains & items:
# for fqdn in domains & items:
# print(f'[ERROR] Duplicate domain name used: {fqdn}')
# error = True
# domains.update(items)
# # delete items not tracked
# if delete_objects:
# for model, items in tracked.items():
# for item in model.query.all():
# if not item._dict_pval() in items:
# print(f'Deleted {item!r} {item}')
# db.session.delete(item)
# # don't commit when running dry
# if dry_run:
# print('Dry run. Not commiting changes.')
# db.session.rollback()
# else:
# db.session.commit()
SECTIONS = {'domains', 'relays', 'users', 'aliases'}
@mailu.command()
@click.option('-v', '--verbose', is_flag=True, help='Increase verbosity')
@click.option('-n', '--dry-run', is_flag=True, help='Perform a trial run with no changes made')
@click.argument('source', metavar='[FILENAME|-]', type=click.File(mode='r'), default=sys.stdin)
@with_appcontext
def config_import(verbose=False, dry_run=False, source=None):
""" Import configuration YAML
"""
context = {
'verbose': verbose, # TODO: use callback function to be verbose?
'import': True,
}
try:
config = MailuSchema(context=context).loads(source)
except ValidationError as exc:
print(f'[ERROR] {exc}')
# TODO: show nice errors
from pprint import pprint
pprint(exc.messages)
sys.exit(1)
else:
print(config)
print(MailuSchema().dumps(config))
# TODO: does not commit yet.
# TODO: delete other entries?
# don't commit when running dry
if True: #dry_run:
print('Dry run. Not commiting changes.')
db.session.rollback()
else:
db.session.commit()
@mailu.command()
@click.option('-f', '--full', is_flag=True, help='Include attributes with default value')
@click.option('-s', '--secrets', is_flag=True,
help='Include secret attributes (dkim-key, passwords)')
@click.option('-d', '--dns', is_flag=True, help='Include dns records')
@click.option('-o', '--output-file', 'output', default=sys.stdout, type=click.File(mode='w'),
help='save yaml to file')
@click.argument('sections', nargs=-1)
@with_appcontext
def config_dump(full=False, secrets=False, dns=False, output=None, sections=None):
""" Dump configuration as YAML to stdout or file
SECTIONS can be: domains, relays, users, aliases
"""
if sections:
for section in sections:
if section not in SECTIONS:
print(f'[ERROR] Unknown section: {section!r}')
sys.exit(1)
sections = set(sections)
else:
sections = SECTIONS
context={
'full': full,
'secrets': secrets,
'dns': dns,
}
MailuSchema(only=sections, context=context).dumps(models.MailuConfig(), output)
@mailu.command()
@click.argument('email')
@with_appcontext
def user_delete(email):
"""delete user"""
user = models.User.query.get(email)
if user:
db.session.delete(user)
db.session.commit()
@mailu.command()
@click.argument('email')
@with_appcontext
def alias_delete(email):
"""delete alias"""
alias = models.Alias.query.get(email)
if alias:
db.session.delete(alias)
db.session.commit()
@mailu.command()
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('destination')
@click.option('-w', '--wildcard', is_flag=True)
@with_appcontext
def alias(localpart, domain_name, destination, wildcard=False):
""" Create an alias
"""
domain = models.Domain.query.get(domain_name)
if not domain:
domain = models.Domain(name=domain_name)
db.session.add(domain)
alias = models.Alias(
localpart=localpart,
domain=domain,
wildcard=wildcard,
destination=destination.split(','),
email="%s@%s" % (localpart, domain_name)
)
db.session.add(alias)
db.session.commit()
@mailu.command()
@click.argument('domain_name')
@click.argument('max_users')
@click.argument('max_aliases')
@click.argument('max_quota_bytes')
@with_appcontext
def setlimits(domain_name, max_users, max_aliases, max_quota_bytes):
""" Set domain limits
"""
domain = models.Domain.query.get(domain_name)
domain.max_users = max_users
domain.max_aliases = max_aliases
domain.max_quota_bytes = max_quota_bytes
db.session.add(domain)
db.session.commit()
@mailu.command()
@click.argument('domain_name')
@click.argument('user_name')
@with_appcontext
def setmanager(domain_name, user_name='manager'):
""" Make a user manager of a domain
"""
domain = models.Domain.query.get(domain_name)
manageruser = models.User.query.get(user_name + '@' + domain_name)
domain.managers.append(manageruser)
db.session.add(domain)
db.session.commit()