diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c10d78e..bd1745a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ CHANGELOG ========= +v0.51 (November 14, 2020) +------------------------- + +Software updates: + +* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2) +* Upgraded Roundcube to version 1.4.9. + +Mail: + +* The MTA-STA max_age value was increased to the normal one week. + +Control Panel: + +* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords. +* TLS certificate expiry dates are now shown in ISO8601 format for clarity. + v0.50 (September 25, 2020) -------------------------- diff --git a/README.md b/README.md index 57bf5beb..3b5a593f 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ by him: $ curl -s https://keybase.io/joshdata/key.asc | gpg --import gpg: key C10BDD81: public key "Joshua Tauberer " imported - $ git verify-tag v0.50 + $ git verify-tag v0.51 gpg: Signature made ..... using RSA key ID C10BDD81 gpg: Good signature from "Joshua Tauberer " gpg: WARNING: This key is not certified with a trusted signature! @@ -161,7 +161,7 @@ and on his [personal homepage](https://razor.occams.info/). (Of course, if this Checkout the tag corresponding to the most recent release: - $ git checkout v0.50 + $ git checkout v0.51 Begin the installation. diff --git a/api/mailinabox.yml b/api/mailinabox.yml index a9dace26..7bdadea1 100644 --- a/api/mailinabox.yml +++ b/api/mailinabox.yml @@ -8,7 +8,7 @@ info: This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3). ([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).) - All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). + All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). If you have multi-factor authentication enabled, authentication with a `user:password` combination will fail unless a valid OTP is supplied via the `x-auth-token` header. Authentication via a `user:user_key` pair is possible without the header being present. contact: name: Mail-in-a-Box support url: https://mailinabox.email/ @@ -46,6 +46,9 @@ tags: - name: Web description: | Static web hosting operations, which include getting domain information and updating domain root directories. + - name: MFA + description: | + Manage multi-factor authentication schemes. Currently, only TOTP is supported. - name: System description: | System operations, which include system status checks, new version checks @@ -1779,6 +1782,101 @@ paths: text/html: schema: type: string + /mfa/status: + post: + tags: + - MFA + summary: Retrieve MFA status for you or another user + description: Retrieves which type of MFA is used and configuration + operationId: mfaStatus + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/status" \ + -u ":" + responses: + 200: + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/MfaStatusResponse' + 403: + description: Forbidden + content: + text/html: + schema: + type: string + /mfa/totp/enable: + post: + tags: + - MFA + summary: Enable TOTP authentication + description: Enables TOTP authentication for the currently logged-in admin user + operationId: mfaTotpEnable + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/totp/enable" \ + -d "code=123456" \ + -d "secret=" \ + -u ":" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MfaEnableRequest' + responses: + 200: + description: Successful operation + content: + text/html: + schema: + $ref: '#/components/schemas/MfaEnableSuccessResponse' + 400: + description: Bad request + content: + text/html: + schema: + type: string + 403: + description: Forbidden + content: + text/html: + schema: + type: string + /mfa/disable: + post: + tags: + - MFA + summary: Disable multi-factor authentication for you or another user + description: Disables multi-factor authentication for the currently logged-in admin user or another user if a 'user' parameter is submitted. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id`. + operationId: mfaTotpDisable + requestBody: + required: false + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MfaDisableRequest' + x-codeSamples: + - lang: curl + source: | + curl -X POST "https://{host}/admin/mfa/totp/disable" \ + -u ":" + responses: + 200: + description: Successful operation + content: + text/html: + schema: + $ref: '#/components/schemas/MfaDisableSuccessResponse' + 403: + description: Forbidden + content: + text/html: + schema: + type: string components: securitySchemes: basicAuth: @@ -2704,4 +2802,55 @@ components: key: type: string example: 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 - description: SMTP Configuration form \ No newline at end of file + description: SMTP Configuration form + MfaStatusResponse: + type: object + properties: + enabled_mfa: + type: object + properties: + id: + type: string + type: + type: string + label: + type: string + nullable: true + new_mfa: + type: object + properties: + type: + type: string + secret: + type: string + qr_code_base64: + type: string + MfaEnableRequest: + type: object + required: + - secret + - code + properties: + secret: + type: string + code: + type: string + label: + type: string + MfaEnableSuccessResponse: + type: string + MfaEnableBadRequestResponse: + type: object + required: + - error + properties: + error: + type: string + MfaDisableRequest: + type: object + properties: + mfa_id: + type: string + nullable: true + MfaDisableSuccessResponse: + type: string diff --git a/conf/mta-sts.txt b/conf/mta-sts.txt index 376102bc..26acc015 100644 --- a/conf/mta-sts.txt +++ b/conf/mta-sts.txt @@ -1,4 +1,4 @@ version: STSv1 mode: MODE mx: PRIMARY_HOSTNAME -max_age: 86400 \ No newline at end of file +max_age: 604800 diff --git a/management/auth.py b/management/auth.py index 55f59664..fd143c76 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,9 +1,10 @@ -import base64, os, os.path, hmac +import base64, os, os.path, hmac, json from flask import make_response import utils from mailconfig import get_mail_password, get_mail_user_privileges +from mfa import get_hash_mfa_state, validate_auth_mfa DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -72,17 +73,19 @@ class KeyAuthService: if username in (None, ""): raise ValueError("Authorization header invalid.") elif username == self.key: - # The user passed the API key which grants administrative privs. + # The user passed the master API key which grants administrative privs. return (None, ["admin"]) else: - # The user is trying to log in with a username and user-specific - # API key or password. Raises or returns privs. - return (username, self.get_user_credentials(username, password, env)) + # The user is trying to log in with a username and either a password + # (and possibly a MFA token) or a user-specific API key. + return (username, self.check_user_auth(username, password, request, env)) - def get_user_credentials(self, email, pw, env): - # Validate a user's credentials. On success returns a list of - # privileges (e.g. [] or ['admin']). On failure raises a ValueError - # with a login error message. + def check_user_auth(self, email, pw, request, env): + # Validate a user's login email address and password. If MFA is enabled, + # check the MFA token in the X-Auth-Token header. + # + # On success returns a list of privileges (e.g. [] or ['admin']). On login + # failure, raises a ValueError with a login error message. # Sanity check. if email == "" or pw == "": @@ -112,6 +115,12 @@ class KeyAuthService: # Login failed. raise ValueError("Invalid password.") + # If MFA is enabled, check that MFA passes. + status, hints = validate_auth_mfa(email, request, env) + if not status: + # Login valid. Hints may have more info. + raise ValueError(",".join(hints)) + # Get privileges for authorization. This call should never fail because by this # point we know the email address is a valid user. But on error the call will # return a tuple of an error message and an HTTP status code. @@ -122,16 +131,27 @@ class KeyAuthService: return privs def create_user_key(self, email, env): - # Store an HMAC with the client. The hashed message of the HMAC will be the user's - # email address & hashed password and the key will be the master API key. The user of - # course has their own email address and password. We assume they do not have the master - # API key (unless they are trusted anyway). The HMAC proves that they authenticated - # with us in some other way to get the HMAC. Including the password means that when - # a user's password is reset, the HMAC changes and they will correctly need to log - # in to the control panel again. This method raises a ValueError if the user does - # not exist, due to get_mail_password. + # Create a user API key, which is a shared secret that we can re-generate from + # static information in our database. The shared secret contains the user's + # email address, current hashed password, and current MFA state, so that the + # key becomes invalid if any of that information changes. + # + # Use an HMAC to generate the API key using our master API key as a key, + # which also means that the API key becomes invalid when our master API key + # changes --- i.e. when this process is restarted. + # + # Raises ValueError via get_mail_password if the user doesn't exist. + + # Construct the HMAC message from the user's email address and current password. msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8") - return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest() + + # Add to the message the current MFA state, which is a list of MFA information. + # Turn it into a string stably. + msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8") + + # Make the HMAC. + hash_key = self.key.encode('ascii') + return hmac.new(hash_key, msg, digestmod="sha256").hexdigest() def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/cli.py b/management/cli.py new file mode 100755 index 00000000..1b91b003 --- /dev/null +++ b/management/cli.py @@ -0,0 +1,150 @@ +#!/usr/bin/python3 +# +# This is a command-line script for calling management APIs +# on the Mail-in-a-Box control panel backend. The script +# reads /var/lib/mailinabox/api.key for the backend's +# root API key. This file is readable only by root, so this +# tool can only be used as root. + +import sys, getpass, urllib.request, urllib.error, json, re, csv + +def mgmt(cmd, data=None, is_json=False): + # The base URL for the management daemon. (Listens on IPv4 only.) + mgmt_uri = 'http://127.0.0.1:10222' + + setup_key_auth(mgmt_uri) + + req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) + try: + response = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + if e.code == 401: + try: + print(e.read().decode("utf8")) + except: + pass + print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) + elif hasattr(e, 'read'): + print(e.read().decode('utf8'), file=sys.stderr) + else: + print(e, file=sys.stderr) + sys.exit(1) + resp = response.read().decode('utf8') + if is_json: resp = json.loads(resp) + return resp + +def read_password(): + while True: + first = getpass.getpass('password: ') + if len(first) < 8: + print("Passwords must be at least eight characters.") + continue + second = getpass.getpass(' (again): ') + if first != second: + print("Passwords not the same. Try again.") + continue + break + return first + +def setup_key_auth(mgmt_uri): + key = open('/var/lib/mailinabox/api.key').read().strip() + + auth_handler = urllib.request.HTTPBasicAuthHandler() + auth_handler.add_password( + realm='Mail-in-a-Box Management Server', + uri=mgmt_uri, + user=key, + passwd='') + opener = urllib.request.build_opener(auth_handler) + urllib.request.install_opener(opener) + +if len(sys.argv) < 2: + print("""Usage: + {cli} user (lists users) + {cli} user add user@domain.com [password] + {cli} user password user@domain.com [password] + {cli} user remove user@domain.com + {cli} user make-admin user@domain.com + {cli} user remove-admin user@domain.com + {cli} user admins (lists admins) + {cli} user mfa show user@domain.com (shows MFA devices for user, if any) + {cli} user mfa disable user@domain.com [id] (disables MFA for user) + {cli} alias (lists aliases) + {cli} alias add incoming.name@domain.com sent.to@other.domain.com + {cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com' + {cli} alias remove incoming.name@domain.com + +Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login. +""".format( + cli="management/cli.py" + )) + +elif sys.argv[1] == "user" and len(sys.argv) == 2: + # Dump a list of users, one per line. Mark admins with an asterisk. + users = mgmt("/mail/users?format=json", is_json=True) + for domain in users: + for user in domain["users"]: + if user['status'] == 'inactive': continue + print(user['email'], end='') + if "admin" in user['privileges']: + print("*", end='') + print() + +elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): + if len(sys.argv) < 5: + if len(sys.argv) < 4: + email = input("email: ") + else: + email = sys.argv[3] + pw = read_password() + else: + email, pw = sys.argv[3:5] + + if sys.argv[2] == "add": + print(mgmt("/mail/users/add", { "email": email, "password": pw })) + elif sys.argv[2] == "password": + print(mgmt("/mail/users/password", { "email": email, "password": pw })) + +elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: + print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) + +elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4: + if sys.argv[2] == "make-admin": + action = "add" + else: + action = "remove" + print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) + +elif sys.argv[1] == "user" and sys.argv[2] == "admins": + # Dump a list of admin users. + users = mgmt("/mail/users?format=json", is_json=True) + for domain in users: + for user in domain["users"]: + if "admin" in user['privileges']: + print(user['email']) + +elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: + # Show MFA status for a user. + status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True) + W = csv.writer(sys.stdout) + W.writerow(["id", "type", "label"]) + for mfa in status["enabled_mfa"]: + W.writerow([mfa["id"], mfa["type"], mfa["label"]]) + +elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]: + # Disable MFA (all or a particular device) for a user. + print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) + +elif sys.argv[1] == "alias" and len(sys.argv) == 2: + print(mgmt("/mail/aliases")) + +elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: + print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) + +elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: + print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) + +else: + print("Invalid command-line arguments.") + sys.exit(1) + diff --git a/management/daemon.py b/management/daemon.py index cac8a134..73fd8f14 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -1,14 +1,15 @@ import os, os.path, re, json, time -import subprocess +import multiprocessing.pool, subprocess from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response -import auth, utils, multiprocessing.pool +import auth, utils from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege, open_database from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias +from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa env = utils.load_environment() @@ -35,23 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna def authorized_personnel_only(viewfunc): @wraps(viewfunc) def newview(*args, **kwargs): - # Authenticate the passed credentials, which is either the API key or a username:password pair. + # Authenticate the passed credentials, which is either the API key or a username:password pair + # and an optional X-Auth-Token token. error = None + privs = [] + try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Authentication failed. - privs = [] - error = "Incorrect username or password" - # Write a line in the log recording the failed login log_failed_login(request) + # Authentication failed. + error = str(e) + # Authorized to access an API view? if "admin" in privs: + # Store the email address of the logged in user so it can be accessed + # from the API methods that affect the calling user. + request.user_email = email + request.user_privs = privs + # Call view func. return viewfunc(*args, **kwargs) - elif not error: + + if not error: error = "You are not an administrator." # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. @@ -83,8 +92,8 @@ def authorized_personnel_only(viewfunc): def unauthorized(error): return auth_service.make_unauthorized_response() -def json_response(data): - return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json') +def json_response(data, status=200): + return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json') ################################### @@ -122,12 +131,17 @@ def me(): try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Log the failed login - log_failed_login(request) - - return json_response({ - "status": "invalid", - "reason": "Incorrect username or password", + if "missing-totp-token" in str(e): + return json_response({ + "status": "missing-totp-token", + "reason": str(e), + }) + else: + # Log the failed login + log_failed_login(request) + return json_response({ + "status": "invalid", + "reason": str(e), }) resp = { @@ -386,6 +400,60 @@ def ssl_provision_certs(): requests = provision_certificates(env, limit_domains=None) return json_response({ "requests": requests }) +# multi-factor auth + +@app.route('/mfa/status', methods=['POST']) +@authorized_personnel_only +def mfa_get_status(): + # Anyone accessing this route is an admin, and we permit them to + # see the MFA status for any user if they submit a 'user' form + # field. But we don't include provisioning info since a user can + # only provision for themselves. + email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request + try: + resp = { + "enabled_mfa": get_public_mfa_state(email, env) + } + if email == request.user_email: + resp.update({ + "new_mfa": { + "totp": provision_totp(email, env) + } + }) + except ValueError as e: + return (str(e), 400) + return json_response(resp) + +@app.route('/mfa/totp/enable', methods=['POST']) +@authorized_personnel_only +def totp_post_enable(): + secret = request.form.get('secret') + token = request.form.get('token') + label = request.form.get('label') + if type(token) != str: + return ("Bad Input", 400) + try: + validate_totp_secret(secret) + enable_mfa(request.user_email, "totp", secret, token, label, env) + except ValueError as e: + return (str(e), 400) + return "OK" + +@app.route('/mfa/disable', methods=['POST']) +@authorized_personnel_only +def totp_post_disable(): + # Anyone accessing this route is an admin, and we permit them to + # disable the MFA status for any user if they submit a 'user' form + # field. + email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request + try: + result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None + except ValueError as e: + return (str(e), 400) + if result: # success + return "OK" + else: # error + return ("Invalid user or MFA id.", 400) # WEB diff --git a/management/mailconfig.py b/management/mailconfig.py index b061ea7d..47faad5f 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -608,7 +608,6 @@ def validate_password(pw): if len(pw) < 8: raise ValueError("Passwords must be at least eight characters.") - if __name__ == "__main__": import sys if len(sys.argv) > 2 and sys.argv[1] == "validate-email": diff --git a/management/mfa.py b/management/mfa.py new file mode 100644 index 00000000..32eb5183 --- /dev/null +++ b/management/mfa.py @@ -0,0 +1,141 @@ +import base64 +import hmac +import io +import os +import pyotp +import qrcode + +from mailconfig import open_database + +def get_user_id(email, c): + c.execute('SELECT id FROM users WHERE email=?', (email,)) + r = c.fetchone() + if not r: raise ValueError("User does not exist.") + return r[0] + +def get_mfa_state(email, env): + c = open_database(env) + c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + return [ + { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] } + for r in c.fetchall() + ] + +def get_public_mfa_state(email, env): + mfa_state = get_mfa_state(email, env) + return [ + { "id": s["id"], "type": s["type"], "label": s["label"] } + for s in mfa_state + ] + +def get_hash_mfa_state(email, env): + mfa_state = get_mfa_state(email, env) + return [ + { "id": s["id"], "type": s["type"], "secret": s["secret"] } + for s in mfa_state + ] + +def enable_mfa(email, type, secret, token, label, env): + if type == "totp": + validate_totp_secret(secret) + # Sanity check with the provide current token. + totp = pyotp.TOTP(secret) + if not totp.verify(token, valid_window=1): + raise ValueError("Invalid token.") + else: + raise ValueError("Invalid MFA type.") + + conn, c = open_database(env, with_connection=True) + c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) + conn.commit() + +def set_mru_token(email, mfa_id, token, env): + conn, c = open_database(env, with_connection=True) + c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id)) + conn.commit() + +def disable_mfa(email, mfa_id, env): + conn, c = open_database(env, with_connection=True) + if mfa_id is None: + # Disable all MFA for a user. + c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + else: + # Disable a particular MFA mode for a user. + c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id)) + conn.commit() + return c.rowcount > 0 + +def validate_totp_secret(secret): + if type(secret) != str or secret.strip() == "": + raise ValueError("No secret provided.") + if len(secret) != 32: + raise ValueError("Secret should be a 32 characters base32 string") + +def provision_totp(email, env): + # Make a new secret. + secret = base64.b32encode(os.urandom(20)).decode('utf-8') + validate_totp_secret(secret) # sanity check + + # Make a URI that we encode within a QR code. + uri = pyotp.TOTP(secret).provisioning_uri( + name=email, + issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel" + ) + + # Generate a QR code as a base64-encode PNG image. + qr = qrcode.make(uri) + byte_arr = io.BytesIO() + qr.save(byte_arr, format='PNG') + png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8') + + return { + "type": "totp", + "secret": secret, + "qr_code_base64": png_b64 + } + +def validate_auth_mfa(email, request, env): + # Validates that a login request satisfies any MFA modes + # that have been enabled for the user's account. Returns + # a tuple (status, [hints]). status is True for a successful + # MFA login, False for a missing token. If status is False, + # hints is an array of codes that indicate what the user + # can try. Possible codes are: + # "missing-totp-token" + # "invalid-totp-token" + + mfa_state = get_mfa_state(email, env) + + # If no MFA modes are added, return True. + if len(mfa_state) == 0: + return (True, []) + + # Try the enabled MFA modes. + hints = set() + for mfa_mode in mfa_state: + if mfa_mode["type"] == "totp": + # Check that a token is present in the X-Auth-Token header. + # If not, give a hint that one can be supplied. + token = request.headers.get('x-auth-token') + if not token: + hints.add("missing-totp-token") + continue + + # Check for a replay attack. + if hmac.compare_digest(token, mfa_mode['mru_token'] or ""): + # If the token fails, skip this MFA mode. + hints.add("invalid-totp-token") + continue + + # Check the token. + totp = pyotp.TOTP(mfa_mode["secret"]) + if not totp.verify(token, valid_window=1): + hints.add("invalid-totp-token") + continue + + # On success, record the token to prevent a replay attack. + set_mru_token(email, mfa_mode['id'], token, env) + return (True, []) + + # On a failed login, indicate failure and any hints for what the user can do instead. + return (False, list(hints)) diff --git a/management/ssl_certificates.py b/management/ssl_certificates.py index 708ccc7d..96959425 100755 --- a/management/ssl_certificates.py +++ b/management/ssl_certificates.py @@ -608,10 +608,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring ndays = (cert_expiration_date-now).days if not rounded_time or ndays <= 10: # Yikes better renew soon! - expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x")) + expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat()) else: # We'll renew it with Lets Encrypt. - expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x") + expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat() if warn_if_expiring_soon and ndays <= warn_if_expiring_soon: # Warn on day 10 to give 4 days for us to automatically renew the diff --git a/management/templates/index.html b/management/templates/index.html index 82e929e0..108110d4 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -1,420 +1,411 @@ + + + + - - - - + {{hostname}} - Mail-in-a-Box Control Panel - {{hostname}} - Mail-in-a-Box Control Panel + - + + + + - .admin_panel { - display: none; - } + + - table.table { - margin: 1.5em 0; - } + - ol li { - margin-bottom: 1em; - } - - +
+
+ {% include "smtp-relays.html" %} +
- +
+ {% include "system-status.html" %} +
- - +
+ {% include "system-backup.html" %} +
- +
+ {% include "external-dns.html" %} +
-
-
- {% include "smtp-relays.html" %} -
+
+ {% include "custom-dns.html" %} +
-
- {% include "system-status.html" %} -
+
+ {% include "mfa.html" %} +
-
- {% include "system-backup.html" %} -
+
+ {% include "login.html" %} +
-
- {% include "external-dns.html" %} -
+
+ {% include "mail-guide.html" %} +
-
- {% include "custom-dns.html" %} -
+
+ {% include "users.html" %} +
-
- {% include "login.html" %} -
+
+ {% include "aliases.html" %} +
-
- {% include "mail-guide.html" %} -
+
+ {% include "sync-guide.html" %} +
-
- {% include "users.html" %} -
+
+ {% include "web.html" %} +
-
- {% include "aliases.html" %} -
+
+ {% include "ssl.html" %} +
-
- {% include "sync-guide.html" %} -
+
-
- {% include "web.html" %} -
+ +
-
- {% include "ssl.html" %} -
+ -
+ - -
+ + - + - +function show_modal_error(title, message, callback) { + $('#global_modal h4').text(title); + $('#global_modal .modal-body').html("

"); + if (typeof question == 'string') { + $('#global_modal p').text(message); + $('#global_modal .modal-dialog').addClass("modal-sm"); + } else { + $('#global_modal p').html("").append(message); + $('#global_modal .modal-dialog').removeClass("modal-sm"); + } + $('#global_modal .btn-default').show().text("OK"); + $('#global_modal .btn-danger').hide(); + global_modal_funcs = [callback, callback]; + global_modal_state = null; + $('#global_modal').modal({}); + return false; // handy when called from onclick +} - - + // Recall what the user was last looking at. + if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) { + show_panel(localStorage.getItem("miab-cp-lastpanel")); + } else { + show_panel('login'); + } +}) + + diff --git a/management/templates/login.html b/management/templates/login.html index f3ee9afa..4c146141 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -1,143 +1,186 @@ -

{{hostname}}

+ + +

{{hostname}}

{% if no_users_exist or no_admins_exist %}
-
- {% if no_users_exist %} -

There are no users on this system! To make an administrative user, - log into this machine using SSH (like when you first set it up) and run:

-
cd mailinabox
-sudo tools/mail.py user add me@{{hostname}}
-sudo tools/mail.py user make-admin me@{{hostname}}
- {% else %} -

There are no administrative users on this system! To make an administrative user, - log into this machine using SSH (like when you first set it up) and run:

-
cd mailinabox
-sudo tools/mail.py user make-admin me@{{hostname}}
- {% endif %} -
-
+
+ {% if no_users_exist %} +

There are no users on this system! To make an administrative user, + log into this machine using SSH (like when you first set it up) and run:

+
cd mailinabox
+sudo management/cli.py user add me@{{hostname}}
+sudo management/cli.py user make-admin me@{{hostname}}
+ {% else %} +

There are no administrative users on this system! To make an administrative user, + log into this machine using SSH (like when you first set it up) and run:

+
cd mailinabox
+sudo management/cli.py user make-admin me@{{hostname}}
+ {% endif %} +
+
{% endif %} -

Log in here for your Mail-in-a-Box control panel.

+

Log in here for your Mail-in-a-Box control panel.

-
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
+ - diff --git a/management/templates/mfa.html b/management/templates/mfa.html new file mode 100644 index 00000000..ba7586cc --- /dev/null +++ b/management/templates/mfa.html @@ -0,0 +1,242 @@ + + +

Two-Factor Authentication

+ +

When two-factor authentication is enabled, you will be prompted to enter a six digit code from an +authenticator app (usually on your phone) when you log into this control panel.

+ +
+
+Enabling two-factor authentication does not protect access to your email +
+
+Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to +reset your password by checking your email, so anyone with access to your email can typically take over +your other accounts. Additionally, if your email address or any alias that forwards to your email +address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@, +webmaster@, abuse@), extra care should be taken to protect the account. Always use a strong password, +and ensure every administrator account for this control panel does the same. +
+
+ +
+
Loading...
+ +
+

Setup Instructions

+ +
+

1. Install FreeOTP or any + other two-factor authentication app that supports TOTP.

+
+ +
+

2. Scan the QR code in the app or directly enter the secret into the app:

+
+
+ +
+ + +
+ +
+ + +
+ + + +
+

When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in + again, now using your two-factor authentication app.

+ +
+
+ +
+
+

Two-factor authentication is active for your account.

+

You will have to log into the admin panel again after disabling two-factor authentication.

+
+
+ +
+
+ +
+
+
+
+ + diff --git a/management/templates/system-backup.html b/management/templates/system-backup.html index 3e4fc202..d05d8c9c 100644 --- a/management/templates/system-backup.html +++ b/management/templates/system-backup.html @@ -113,10 +113,10 @@
- +
-
This is the minimum number of days backup data is kept for. The box makes an incremental backup, so backup data is often kept much longer. An incremental backup file that is less than this number of days old requires that all previous increments back to the most recent full backup, plus that full backup, remain available.
+
This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.
diff --git a/management/web_update.py b/management/web_update.py index 7e00e427..36e9e9e1 100644 --- a/management/web_update.py +++ b/management/web_update.py @@ -147,7 +147,7 @@ def make_domain_config(domain, templates, ssl_certificates, env): finally: f.close() return sha1.hexdigest() - nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"])) + nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"])) # Add in any user customizations in YAML format. hsts = "yes" @@ -189,9 +189,9 @@ def make_domain_config(domain, templates, ssl_certificates, env): # Add the HSTS header. if hsts == "yes": - nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=15768000\" always;\n" + nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n" elif hsts == "preload": - nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n" + nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n" # Add in any user customizations in the includes/ folder. nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf") diff --git a/setup/firstuser.sh b/setup/firstuser.sh index f6947695..e2d6531c 100644 --- a/setup/firstuser.sh +++ b/setup/firstuser.sh @@ -1,6 +1,6 @@ # If there aren't any mail users yet, create one. -if [ -z "`tools/mail.py user`" ]; then - # The outut of "tools/mail.py user" is a list of mail users. If there +if [ -z "`management/cli.py user`" ]; then + # The outut of "management/cli.py user" is a list of mail users. If there # aren't any yet, it'll be empty. # If we didn't ask for an email address at the start, do so now. @@ -47,11 +47,11 @@ if [ -z "`tools/mail.py user`" ]; then fi # Create the user's mail account. This will ask for a password if none was given above. - tools/mail.py user add $EMAIL_ADDR ${EMAIL_PW:-} + management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-} # Make it an admin. - hide_output tools/mail.py user make-admin $EMAIL_ADDR + hide_output management/cli.py user make-admin $EMAIL_ADDR # Create an alias to which we'll direct all automatically-created administrative aliases. - tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null + management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null fi diff --git a/setup/mail-users.sh b/setup/mail-users.sh index a73ff197..223a199f 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -22,6 +22,7 @@ if [ ! -f $db_path ]; then echo Creating new user database: $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; + echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; fi # ### User Authentication diff --git a/setup/management.sh b/setup/management.sh index c301f7f8..a03bb80d 100755 --- a/setup/management.sh +++ b/setup/management.sh @@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip hide_output $venv/bin/pip install --upgrade \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ flask dnspython python-dateutil \ + qrcode[pil] pyotp \ "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver # CONFIGURATION diff --git a/setup/migrate.py b/setup/migrate.py index b10f085f..c52ac967 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -181,6 +181,12 @@ def migration_12(env): conn.commit() conn.close() +def migration_13(env): + # Add the "mfa" table for configuring MFA for login to the control panel. + db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') + shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"]) + +########################################################### def get_current_migration(): ver = 0 diff --git a/setup/nextcloud.sh b/setup/nextcloud.sh index 5afc9585..970f1003 100755 --- a/setup/nextcloud.sh +++ b/setup/nextcloud.sh @@ -14,12 +14,18 @@ apt-get purge -qq -y owncloud* # we used to use the package manager apt_install php php-fpm \ php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \ php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \ - php-intl php-imagick + php-intl php-imagick php-gmp php-bcmath InstallNextcloud() { version=$1 hash=$2 + version_contacts=$3 + hash_contacts=$4 + version_calendar=$5 + hash_calendar=$6 + version_user_external=$7 + hash_user_external=$8 echo echo "Upgrading to Nextcloud version $version" @@ -40,18 +46,18 @@ InstallNextcloud() { # their github repositories. mkdir -p /usr/local/lib/owncloud/apps - wget_verify https://github.com/nextcloud/contacts/releases/download/v3.3.0/contacts.tar.gz e55d0357c6785d3b1f3b5f21780cb6d41d32443a /tmp/contacts.tgz + wget_verify https://github.com/nextcloud/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/ rm /tmp/contacts.tgz - wget_verify https://github.com/nextcloud/calendar/releases/download/v2.0.3/calendar.tar.gz 9d9717b29337613b72c74e9914c69b74b346c466 /tmp/calendar.tgz + wget_verify https://github.com/nextcloud/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/ rm /tmp/calendar.tgz # Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core, # we will install from their github repository. - if [[ $version =~ ^1[567] ]]; then - wget_verify https://github.com/nextcloud/user_external/releases/download/v0.10.0/user_external-0.10.0.tar.gz 133c0d65aba1b09c28b21d05477c122041a9abc2 /tmp/user_external.tgz + if [ -n "$version_user_external" ]; then + wget_verify https://github.com/nextcloud/user_external/releases/download/v$version_user_external/user_external-$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/ rm /tmp/user_external.tgz fi @@ -91,8 +97,14 @@ InstallNextcloud() { } # Nextcloud Version to install. Checks are done down below to step through intermediate versions. -nextcloud_ver=19.0.3 -nextcloud_hash=21cafcb86b04c8cdeaf4a8f737623f34b5de7f1e +nextcloud_ver=20.0.1 +nextcloud_hash=f2b3faa570c541df73f209e873a1c2852e79eab8 +contacts_ver=3.4.1 +contacts_hash=aee680a75e95f26d9285efd3c1e25cf7f3bfd27e +calendar_ver=2.1.2 +calendar_hash=930c07863bb7a65652dec34793802c8d80502336 +user_external_ver=1.0.0 +user_external_hash=3bf2609061d7214e7f0f69dd8883e55c4ec8f50a # Current Nextcloud Version, #1623 # Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB @@ -141,23 +153,39 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc return 0 elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then # If we are running Nextcloud 13, upgrade to Nextcloud 14 - InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a + InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 CURRENT_NEXTCLOUD_VER="14.0.6" fi if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then # During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail. # We will disable it here before the upgrade and install it again after the upgrade. hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external - InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 + InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437 CURRENT_NEXTCLOUD_VER="15.0.8" fi if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then - InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 - CURRENT_NEXTCLOUD_VER="16.0.6" - fi + InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437 + CURRENT_NEXTCLOUD_VER="16.0.6" + fi + if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then + InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437 + CURRENT_NEXTCLOUD_VER="17.0.6" + fi + if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then + echo "ALTER TABLE oc_flow_operations ADD COLUMN entity VARCHAR;" | sqlite3 $STORAGE_ROOT/owncloud/owncloud.db + InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a + CURRENT_NEXTCLOUD_VER="18.0.10" + fi + if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then + InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a + CURRENT_NEXTCLOUD_VER="19.0.4" + fi fi - InstallNextcloud $nextcloud_ver $nextcloud_hash + InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash + + # Nextcloud 20 needs to have some optional columns added + sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns fi # ### Configuring Nextcloud @@ -324,7 +352,7 @@ rm -f /etc/cron.hourly/mailinabox-owncloud # and there's a lot they could mess up, so we don't make any users admins of Nextcloud. # But if we wanted to, we would do this: # ``` -# for user in $(tools/mail.py user admins); do +# for user in $(management/cli.py user admins); do # sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')" # done # ``` diff --git a/setup/webmail.sh b/setup/webmail.sh index 9339023b..332e1d3c 100755 --- a/setup/webmail.sh +++ b/setup/webmail.sh @@ -28,8 +28,8 @@ apt_install \ # Install Roundcube from source if it is not already present or if it is out of date. # Combine the Roundcube version number with the commit hash of plugins to track # whether we have the latest version of everything. -VERSION=1.4.8 -HASH=3a6824fd68fef2e0d24f186cfbee5c6f9d6edbe9 +VERSION=1.4.9 +HASH=df650f4d3eae9eaae2d5a5f06d68665691daf57d PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435 HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5 CARDDAV_VERSION=3.0.3 diff --git a/tools/mail.py b/tools/mail.py index 215f39eb..f7d5b410 100755 --- a/tools/mail.py +++ b/tools/mail.py @@ -1,128 +1,3 @@ -#!/usr/bin/python3 - -import sys, getpass, urllib.request, urllib.error, json, re - -def mgmt(cmd, data=None, is_json=False): - # The base URL for the management daemon. (Listens on IPv4 only.) - mgmt_uri = 'http://127.0.0.1:10222' - - setup_key_auth(mgmt_uri) - - req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) - try: - response = urllib.request.urlopen(req) - except urllib.error.HTTPError as e: - if e.code == 401: - try: - print(e.read().decode("utf8")) - except: - pass - print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) - elif hasattr(e, 'read'): - print(e.read().decode('utf8'), file=sys.stderr) - else: - print(e, file=sys.stderr) - sys.exit(1) - resp = response.read().decode('utf8') - if is_json: resp = json.loads(resp) - return resp - -def read_password(): - while True: - first = getpass.getpass('password: ') - if len(first) < 8: - print("Passwords must be at least eight characters.") - continue - second = getpass.getpass(' (again): ') - if first != second: - print("Passwords not the same. Try again.") - continue - break - return first - -def setup_key_auth(mgmt_uri): - key = open('/var/lib/mailinabox/api.key').read().strip() - - auth_handler = urllib.request.HTTPBasicAuthHandler() - auth_handler.add_password( - realm='Mail-in-a-Box Management Server', - uri=mgmt_uri, - user=key, - passwd='') - opener = urllib.request.build_opener(auth_handler) - urllib.request.install_opener(opener) - -if len(sys.argv) < 2: - print("Usage: ") - print(" tools/mail.py user (lists users)") - print(" tools/mail.py user add user@domain.com [password]") - print(" tools/mail.py user password user@domain.com [password]") - print(" tools/mail.py user remove user@domain.com") - print(" tools/mail.py user make-admin user@domain.com") - print(" tools/mail.py user remove-admin user@domain.com") - print(" tools/mail.py user admins (lists admins)") - print(" tools/mail.py alias (lists aliases)") - print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com") - print(" tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'") - print(" tools/mail.py alias remove incoming.name@domain.com") - print() - print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.") - print() - -elif sys.argv[1] == "user" and len(sys.argv) == 2: - # Dump a list of users, one per line. Mark admins with an asterisk. - users = mgmt("/mail/users?format=json", is_json=True) - for domain in users: - for user in domain["users"]: - if user['status'] == 'inactive': continue - print(user['email'], end='') - if "admin" in user['privileges']: - print("*", end='') - print() - -elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): - if len(sys.argv) < 5: - if len(sys.argv) < 4: - email = input("email: ") - else: - email = sys.argv[3] - pw = read_password() - else: - email, pw = sys.argv[3:5] - - if sys.argv[2] == "add": - print(mgmt("/mail/users/add", { "email": email, "password": pw })) - elif sys.argv[2] == "password": - print(mgmt("/mail/users/password", { "email": email, "password": pw })) - -elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4: - print(mgmt("/mail/users/remove", { "email": sys.argv[3] })) - -elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4: - if sys.argv[2] == "make-admin": - action = "add" - else: - action = "remove" - print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) - -elif sys.argv[1] == "user" and sys.argv[2] == "admins": - # Dump a list of admin users. - users = mgmt("/mail/users?format=json", is_json=True) - for domain in users: - for user in domain["users"]: - if "admin" in user['privileges']: - print(user['email']) - -elif sys.argv[1] == "alias" and len(sys.argv) == 2: - print(mgmt("/mail/aliases")) - -elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: - print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) - -elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: - print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) - -else: - print("Invalid command-line arguments.") - sys.exit(1) - +#!/bin/bash +# This script has moved. +management/cli.py "$@"