diff --git a/.gitignore b/.gitignore index 65fcca3b..97f06682 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ externals/ .vagrant .idea/ api/docs/api-docs.html +*.code-workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c20eb7d..b048d219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,149 @@ CHANGELOG ========= +Version 68 (April 1, 2024) +-------------------------- + +Package updates: + +* Roundcube updated to version 1.6.6. +* Nextcloud is updated to version 26.0.12. + +Mail: + +* Updated postfix's configuration to guard against SMTP smuggling to the long-term fix (https://www.postfix.org/smtp-smuggling.html). + +Control Panel: + +* Improved reporting of Spamhaus response codes. +* Improved detection of SSH port. +* Fixed an error if last saved status check results were corrupted. +* Other minor fixes. + +Other: + +* fail2ban is updated to see "HTTP/2.0" requests to munin also. +* Internal improvements to the code to make it more reliable and readable. + + +Version 67 (December 22, 2023) +------------------------------ + +* Guard against a newly published vulnerability called SMTP Smuggling. See https://sec-consult.com/blog/detail/smtp-smuggling-spoofing-e-mails-worldwide/. + +Version 66 (December 17, 2023) +------------------------------ + +* Some users reported an error installing Mail-in-a-Box related to the virtualenv command. This is hopefully fixed. +* Roundcube is updated to 1.6.5 fixing a security vulnerability. +* For Mail-in-a-Box developers, a new setup variable is added to pull the source code from a different repository. + +Version 65 (October 27, 2023) +----------------------------- + +* Roundcube updated to 1.6.4 fixing a security vulnerability. +* zpush.sh updated to version 2.7.1. +* Fixed a typo in the control panel. + +Version 64 (September 2, 2023) +------------------------------ + +* Fixed broken installation when upgrading from Mail-in-a-Box version 56 (Nextcloud 22) and earlier because of an upstream packaging issue. +* Fixed backups to work with the latest duplicity package which was not backwards compatible. +* Fixed setting B2 as a backup target with a slash in the application key. +* Turned off OpenDMARC diagnostic reports sent in response to incoming mail. +* Fixed some crashes when using an unrelased version of Mail-in-a-Box. +* Added z-push administration scripts. + +Version 63 (July 27, 2023) +-------------------------- + +* Nextcloud updated to 25.0.7. + +Version 62 (May 20, 2023) +------------------------- + +Package updates: + +* Nextcloud updated to 23.0.12 (and its apps also updated). +* Roundcube updated to 1.6.1. +* Z-Push to 2.7.0, which has compatibility for Ubuntu 22.04, so it works again. + +Mail: + +* Roundcube's password change page is now working again. + +Control panel: + +* Allow setting the backup location's S3 region name for non-AWS S3-compatible backup hosts. +* Control panel pages can be opened in a new tab/window and bookmarked and browser history navigation now works. +* Add a Copy button to put the rsync backup public key on clipboard. +* Allow secondary DNS xfr: items added in the control panel to be hostnames too. +* Fixed issue where sshkeygen fails when IPv6 is disabled. +* Fixed issue opening munin reports. +* Fixed report formatting in status emails sent to the administrator. + +Version 61.1 (January 28, 2023) +------------------------------- + +* Fixed rsync backups not working with the default port. +* Reverted "Improve error messages in the management tools when external command-line tools are run." because of the possibility of user secrets being included in error messages. +* Fix for TLS certificate SHA fingerprint not being displayed during setup. + +Version 61 (January 21, 2023) +----------------------------- + +System: + +* fail2ban didn't start after setup. + +Mail: + +* Disable Roundcube password plugin since it was corrupting the user database. + +Control panel: + +* Fix changing existing backup settings when the rsync type is used. +* Allow setting a custom port for rsync backups. +* Fixes to DNS lookups during status checks when there are timeouts, enforce timeouts better. +* A new check is added to ensure fail2ban is running. +* Fixed a color. +* Improve error messages in the management tools when external command-line tools are run. + +Version 60.1 (October 30, 2022) +------------------------------- + +* A setup issue where the DNS server nsd isn't running at the end of setup is (hopefully) fixed. +* Nextcloud is updated to 23.0.10 (contacts to 4.2.2, calendar to 3.5.1). + +Version 60 (October 11, 2022) +----------------------------- + +This is the first release for Ubuntu 22.04. + +**Before upgrading**, you must **first upgrade your existing Ubuntu 18.04 box to Mail-in-a-Box v0.51 or later**, if you haven't already done so. That may not be possible after Ubuntu 18.04 reaches its end of life in April 2023, so please complete the upgrade well before then. (If you are not using Nextcloud's contacts or calendar, you can migrate to the latest version of Mail-in-a-Box from any previous version.) + +For complete upgrade instructions, see: + +https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558 + +No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made. + +With the newer version of Ubuntu the following software packages we use are updated: + +* dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug). +* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0). +* Roundcube is upgraded to 1.6.0. +* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA). +* fail2ban is upgraded to 0.11.2. +* nginx is upgraded to 1.18. +* PHP is upgraded from 7.2 to 8.0. + +Also: + +* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout. +* Moved Postgrey's database under $STORAGE_ROOT. + Version 57a (June 19, 2022) --------------------------- diff --git a/README.md b/README.md index b7a76cab..acb4b0d3 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which su In The Box ---------- -Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a working mail server by installing and configuring various components. +Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components. It is a one-click email appliance. There are no user-configurable setup options. It "just works." @@ -264,13 +264,13 @@ Installation See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions. -For experts, start with a completely fresh (really, I mean it) Ubuntu 18.04 LTS 64-bit machine. On the machine... +For experts, start with a completely fresh (really, I mean it) Ubuntu 22.04 LTS 64-bit machine. On the machine... Clone this repository and checkout the tag corresponding to the most recent release: $ git clone https://github.com/mail-in-a-box/mailinabox $ cd mailinabox - $ git checkout v57a + $ git checkout v68 Begin the installation. diff --git a/Vagrantfile b/Vagrantfile index 04788292..757c2ec9 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -2,7 +2,7 @@ # vi: set ft=ruby : Vagrant.configure("2") do |config| - config.vm.box = "ubuntu/bionic64" + config.vm.box = "ubuntu/jammy64" # Network config: Since it's a mail server, the machine must be connected # to the public web. However, we currently don't want to expose SSH since diff --git a/conf/fail2ban/filter.d/miab-munin.conf b/conf/fail2ban/filter.d/miab-munin.conf index b254cc62..da257b3e 100644 --- a/conf/fail2ban/filter.d/miab-munin.conf +++ b/conf/fail2ban/filter.d/miab-munin.conf @@ -3,5 +3,5 @@ before = common.conf [Definition] -failregex= - .*GET /admin/munin/.* HTTP/1.1\" 401.* +failregex= - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.* ignoreregex = diff --git a/conf/mailinabox.service b/conf/mailinabox.service index b4cfa6cf..c1d98a03 100644 --- a/conf/mailinabox.service +++ b/conf/mailinabox.service @@ -4,6 +4,7 @@ After=multi-user.target [Service] Type=idle +IgnoreSIGPIPE=False ExecStart=/usr/local/lib/mailinabox/start [Install] diff --git a/conf/nginx-primaryonly.conf b/conf/nginx-primaryonly.conf index 31bf0095..9ed37043 100644 --- a/conf/nginx-primaryonly.conf +++ b/conf/nginx-primaryonly.conf @@ -73,4 +73,9 @@ rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect; rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect; + # This addresses those service discovery issues mentioned in: + # https://docs.nextcloud.com/server/23/admin_manual/issues/general_troubleshooting.html#service-discovery + rewrite ^/.well-known/webfinger /cloud/index.php/.well-known/webfinger redirect; + rewrite ^/.well-known/nodeinfo /cloud/index.php/.well-known/nodeinfo redirect; + # ADDITIONAL DIRECTIVES HERE diff --git a/conf/nginx-top.conf b/conf/nginx-top.conf index 4d888366..c3f4c0d6 100644 --- a/conf/nginx-top.conf +++ b/conf/nginx-top.conf @@ -7,6 +7,6 @@ ## your own --- please do not ask for help from us. upstream php-fpm { - server unix:/var/run/php/php7.2-fpm.sock; + server unix:/var/run/php/php8.0-fpm.sock; } diff --git a/management/auth.py b/management/auth.py index 0a88c457..873047d5 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,4 +1,4 @@ -import base64, os, os.path, hmac, json, secrets +import base64, hmac, json, secrets from datetime import timedelta from expiringdict import ExpiringDict @@ -22,20 +22,8 @@ class AuthService: def init_system_api_key(self): """Write an API key to a local file so local processes can use the API""" - def create_file_with_mode(path, mode): - # Based on answer by A-B-B: http://stackoverflow.com/a/15015748 - old_umask = os.umask(0) - try: - return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w') - finally: - os.umask(old_umask) - - self.key = secrets.token_hex(32) - - os.makedirs(os.path.dirname(self.key_path), exist_ok=True) - - with create_file_with_mode(self.key_path, 0o640) as key_file: - key_file.write(self.key + '\n') + with open(self.key_path, encoding='utf-8') as file: + self.key = file.read() def authenticate(self, request, env, login_only=False, logout=False): """Test if the HTTP Authorization header's username matches the system key, a session key, @@ -60,11 +48,13 @@ class AuthService: return username, password username, password = parse_http_authorization_basic(request.headers.get('Authorization', '')) - if username in (None, ""): - raise ValueError("Authorization header invalid.") + if username in {None, ""}: + msg = "Authorization header invalid." + raise ValueError(msg) if username.strip() == "" and password.strip() == "": - raise ValueError("No email address, password, session key, or API key provided.") + msg = "No email address, password, session key, or API key provided." + raise ValueError(msg) # If user passed the system API key, grant administrative privs. This key # is not associated with a user. @@ -84,7 +74,8 @@ class AuthService: # If no password was given, but a username was given, we're missing some information. elif password.strip() == "": - raise ValueError("Enter a password.") + msg = "Enter a password." + raise ValueError(msg) else: # The user is trying to log in with a username and a password @@ -126,7 +117,8 @@ class AuthService: ]) except: # Login failed. - raise ValueError("Incorrect email address or password.") + msg = "Incorrect email address or password." + raise ValueError(msg) # If MFA is enabled, check that MFA passes. status, hints = validate_auth_mfa(email, request, env) diff --git a/management/backup.py b/management/backup.py index 26a61a0e..f352fb41 100755 --- a/management/backup.py +++ b/management/backup.py @@ -7,12 +7,12 @@ # 4) The stopped services are restarted. # 5) STORAGE_ROOT/backup/after-backup is executed if it exists. -import os, os.path, shutil, glob, re, datetime, sys +import os, os.path, re, datetime, sys import dateutil.parser, dateutil.relativedelta, dateutil.tz import rtyaml from exclusiveprocess import Lock -from utils import load_environment, shell, wait_for_service, fix_boto +from utils import load_environment, shell, wait_for_service def backup_status(env): # If backups are dissbled, return no status. @@ -57,10 +57,11 @@ def backup_status(env): "/usr/bin/duplicity", "collection-status", "--archive-dir", backup_cache_dir, - "--gpg-options", "--cipher-algo=AES256", + "--gpg-options", "'--cipher-algo=AES256'", "--log-fd", "1", - get_duplicity_target_url(config), - ] + get_duplicity_additional_args(env), + *get_duplicity_additional_args(env), + get_duplicity_target_url(config) + ], get_duplicity_env_vars(env), trap=True) if code != 0: @@ -68,7 +69,7 @@ def backup_status(env): # destination for the backups or the last backup job terminated unexpectedly. raise Exception("Something is wrong with the backup: " + collection_status) for line in collection_status.split('\n'): - if line.startswith(" full") or line.startswith(" inc"): + if line.startswith((" full", " inc")): backup = parse_line(line) backups[backup["date"]] = backup @@ -184,7 +185,7 @@ def get_passphrase(env): # only needs to be 43 base64-characters to match AES256's key # length of 32 bytes. backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') - with open(os.path.join(backup_root, 'secret_key.txt')) as f: + with open(os.path.join(backup_root, 'secret_key.txt'), encoding="utf-8") as f: passphrase = f.readline().strip() if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!") @@ -197,17 +198,14 @@ def get_duplicity_target_url(config): from urllib.parse import urlsplit, urlunsplit target = list(urlsplit(target)) - # Duplicity now defaults to boto3 as the backend for S3, but we have - # legacy boto installed (boto3 doesn't support Ubuntu 18.04) so - # we retarget for classic boto. - target[0] = "boto+" + target[0] - - # In addition, although we store the S3 hostname in the target URL, + # Although we store the S3 hostname in the target URL, # duplicity no longer accepts it in the target URL. The hostname in # the target URL must be the bucket name. The hostname is passed # via get_duplicity_additional_args. Move the first part of the # path (the bucket name) into the hostname URL component, and leave - # the rest for the path. + # the rest for the path. (The S3 region name is also stored in the + # hostname part of the URL, in the username portion, which we also + # have to drop here). target[1], target[2] = target[2].lstrip('/').split('/', 1) target = urlunsplit(target) @@ -218,16 +216,32 @@ def get_duplicity_additional_args(env): config = get_backup_config(env) if get_target_type(config) == 'rsync': + # Extract a port number for the ssh transport. Duplicity accepts the + # optional port number syntax in the target, but it doesn't appear to act + # on it, so we set the ssh port explicitly via the duplicity options. + from urllib.parse import urlsplit + try: + port = urlsplit(config["target"]).port + except ValueError: + port = 22 + if port is None: + port = 22 + return [ - "--ssh-options= -i /root/.ssh/id_rsa_miab", - "--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"", + f"--ssh-options='-i /root/.ssh/id_rsa_miab -p {port}'", + f"--rsync-options='-e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"'", ] elif get_target_type(config) == 's3': # See note about hostname in get_duplicity_target_url. + # The region name, which is required by some non-AWS endpoints, + # is saved inside the username portion of the URL. from urllib.parse import urlsplit, urlunsplit target = urlsplit(config["target"]) - endpoint_url = urlunsplit(("https", target.netloc, '', '', '')) - return ["--s3-endpoint-url", endpoint_url] + endpoint_url = urlunsplit(("https", target.hostname, '', '', '')) + args = ["--s3-endpoint-url", endpoint_url] + if target.username: # region name is stuffed here + args += ["--s3-region-name", target.username] + return args return [] @@ -243,8 +257,7 @@ def get_duplicity_env_vars(env): return env def get_target_type(config): - protocol = config["target"].split(":")[0] - return protocol + return config["target"].split(":")[0] def perform_backup(full_backup): env = load_environment() @@ -283,9 +296,10 @@ def perform_backup(full_backup): if quit: sys.exit(code) - service_command("php7.2-fpm", "stop", quit=True) + service_command("php8.0-fpm", "stop", quit=True) service_command("postfix", "stop", quit=True) service_command("dovecot", "stop", quit=True) + service_command("postgrey", "stop", quit=True) # Execute a pre-backup script that copies files outside the homedir. # Run as the STORAGE_USER user, not as root. Pass our settings in @@ -307,17 +321,19 @@ def perform_backup(full_backup): "--archive-dir", backup_cache_dir, "--exclude", backup_root, "--volsize", "250", - "--gpg-options", "--cipher-algo=AES256", + "--gpg-options", "'--cipher-algo=AES256'", + "--allow-source-mismatch", + *get_duplicity_additional_args(env), env["STORAGE_ROOT"], get_duplicity_target_url(config), - "--allow-source-mismatch" - ] + get_duplicity_additional_args(env), + ], get_duplicity_env_vars(env)) finally: # Start services again. + service_command("postgrey", "start", quit=False) service_command("dovecot", "start", quit=False) service_command("postfix", "start", quit=False) - service_command("php7.2-fpm", "start", quit=False) + service_command("php8.0-fpm", "start", quit=False) # Remove old backups. This deletes all backup data no longer needed # from more than 3 days ago. @@ -328,8 +344,9 @@ def perform_backup(full_backup): "--verbosity", "error", "--archive-dir", backup_cache_dir, "--force", + *get_duplicity_additional_args(env), get_duplicity_target_url(config) - ] + get_duplicity_additional_args(env), + ], get_duplicity_env_vars(env)) # From duplicity's manual: @@ -343,8 +360,9 @@ def perform_backup(full_backup): "--verbosity", "error", "--archive-dir", backup_cache_dir, "--force", + *get_duplicity_additional_args(env), get_duplicity_target_url(config) - ] + get_duplicity_additional_args(env), + ], get_duplicity_env_vars(env)) # Change ownership of backups to the user-data user, so that the after-bcakup @@ -381,9 +399,10 @@ def run_duplicity_verification(): "--compare-data", "--archive-dir", backup_cache_dir, "--exclude", backup_root, + *get_duplicity_additional_args(env), get_duplicity_target_url(config), env["STORAGE_ROOT"], - ] + get_duplicity_additional_args(env), get_duplicity_env_vars(env)) + ], get_duplicity_env_vars(env)) def run_duplicity_restore(args): env = load_environment() @@ -393,9 +412,23 @@ def run_duplicity_restore(args): "/usr/bin/duplicity", "restore", "--archive-dir", backup_cache_dir, + *get_duplicity_additional_args(env), get_duplicity_target_url(config), - ] + get_duplicity_additional_args(env) + args, - get_duplicity_env_vars(env)) + *args], + get_duplicity_env_vars(env)) + +def print_duplicity_command(): + import shlex + env = load_environment() + config = get_backup_config(env) + backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache') + for k, v in get_duplicity_env_vars(env).items(): + print(f"export {k}={shlex.quote(v)}") + print("duplicity", "{command}", shlex.join([ + "--archive-dir", backup_cache_dir, + *get_duplicity_additional_args(env), + get_duplicity_target_url(config) + ])) def list_target_files(config): import urllib.parse @@ -411,6 +444,16 @@ def list_target_files(config): rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)') rsync_target = '{host}:{path}' + # Strip off any trailing port specifier because it's not valid in rsync's + # DEST syntax. Explicitly set the port number for the ssh transport. + user_host, *_ = target.netloc.rsplit(':', 1) + try: + port = target.port + except ValueError: + port = 22 + if port is None: + port = 22 + target_path = target.path if not target_path.endswith('/'): target_path = target_path + '/' @@ -419,11 +462,11 @@ def list_target_files(config): rsync_command = [ 'rsync', '-e', - '/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes', + f'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes -p {port}', '--list-only', '-r', rsync_target.format( - host=target.netloc, + host=user_host, path=target_path) ] @@ -439,75 +482,62 @@ def list_target_files(config): if 'Permission denied (publickey).' in listing: reason = "Invalid user or check you correctly copied the SSH key." elif 'No such file or directory' in listing: - reason = "Provided path {} is invalid.".format(target_path) + reason = f"Provided path {target_path} is invalid." elif 'Network is unreachable' in listing: - reason = "The IP address {} is unreachable.".format(target.hostname) + reason = f"The IP address {target.hostname} is unreachable." elif 'Could not resolve hostname' in listing: - reason = "The hostname {} cannot be resolved.".format(target.hostname) + reason = f"The hostname {target.hostname} cannot be resolved." else: - reason = "Unknown error." \ - "Please check running 'management/backup.py --verify'" \ - "from mailinabox sources to debug the issue." - raise ValueError("Connection to rsync host failed: {}".format(reason)) + reason = ("Unknown error." + "Please check running 'management/backup.py --verify'" + "from mailinabox sources to debug the issue.") + msg = f"Connection to rsync host failed: {reason}" + raise ValueError(msg) elif target.scheme == "s3": - # match to a Region - fix_boto() # must call prior to importing boto - import boto.s3 - from boto.exception import BotoServerError - custom_region = False - for region in boto.s3.regions(): - if region.endpoint == target.hostname: - break - else: - # If region is not found this is a custom region - custom_region = True + import boto3.s3 + from botocore.exceptions import ClientError + # separate bucket from path in target bucket = target.path[1:].split('/')[0] path = '/'.join(target.path[1:].split('/')[1:]) + '/' - # Create a custom region with custom endpoint - if custom_region: - from boto.s3.connection import S3Connection - region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection) - # If no prefix is specified, set the path to '', otherwise boto won't list the files if path == '/': path = '' if bucket == "": - raise ValueError("Enter an S3 bucket name.") + msg = "Enter an S3 bucket name." + raise ValueError(msg) # connect to the region & bucket try: - conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"]) - bucket = conn.get_bucket(bucket) - except BotoServerError as e: - if e.status == 403: - raise ValueError("Invalid S3 access key or secret access key.") - elif e.status == 404: - raise ValueError("Invalid S3 bucket name.") - elif e.status == 301: - raise ValueError("Incorrect region for this bucket.") - raise ValueError(e.reason) - - return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)] + s3 = boto3.client('s3', \ + endpoint_url=f'https://{target.hostname}', \ + aws_access_key_id=config['target_user'], \ + aws_secret_access_key=config['target_pass']) + bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents'] + backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects] + except ClientError as e: + raise ValueError(e) + return backup_list elif target.scheme == 'b2': from b2sdk.v1 import InMemoryAccountInfo, B2Api from b2sdk.v1.exception import NonExistentBucket info = InMemoryAccountInfo() b2_api = B2Api(info) - + # Extract information from target b2_application_keyid = target.netloc[:target.netloc.index(':')] - b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')] + b2_application_key = urllib.parse.unquote(target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]) b2_bucket = target.netloc[target.netloc.index('@')+1:] try: b2_api.authorize_account("production", b2_application_keyid, b2_application_key) bucket = b2_api.get_bucket_by_name(b2_bucket) - except NonExistentBucket as e: - raise ValueError("B2 Bucket does not exist. Please double check your information!") + except NonExistentBucket: + msg = "B2 Bucket does not exist. Please double check your information!" + raise ValueError(msg) return [(key.file_name, key.size) for key, _ in bucket.ls()] else: @@ -528,7 +558,7 @@ def backup_set_custom(env, target, target_user, target_pass, min_age): # Validate. try: - if config["target"] not in ("off", "local"): + if config["target"] not in {"off", "local"}: # these aren't supported by the following function, which expects a full url in the target key, # which is what is there except when loading the config prior to saving list_target_files(config) @@ -550,8 +580,9 @@ def get_backup_config(env, for_save=False, for_ui=False): # Merge in anything written to custom.yaml. try: - custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml'))) - if not isinstance(custom_config, dict): raise ValueError() # caught below + with open(os.path.join(backup_root, 'custom.yaml'), encoding="utf-8") as f: + custom_config = rtyaml.load(f) + if not isinstance(custom_config, dict): raise ValueError # caught below config.update(custom_config) except: pass @@ -575,17 +606,17 @@ def get_backup_config(env, for_save=False, for_ui=False): config["target"] = "file://" + config["file_target_directory"] ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub') if os.path.exists(ssh_pub_key): - config["ssh_pub_key"] = open(ssh_pub_key, 'r').read() + with open(ssh_pub_key, encoding="utf-8") as f: + config["ssh_pub_key"] = f.read() return config def write_backup_config(env, newconfig): backup_root = os.path.join(env["STORAGE_ROOT"], 'backup') - with open(os.path.join(backup_root, 'custom.yaml'), "w") as f: + with open(os.path.join(backup_root, 'custom.yaml'), "w", encoding="utf-8") as f: f.write(rtyaml.dump(newconfig)) if __name__ == "__main__": - import sys if sys.argv[-1] == "--verify": # Run duplicity's verification command to check a) the backup files # are readable, and b) report if they are up to date. @@ -594,7 +625,7 @@ if __name__ == "__main__": elif sys.argv[-1] == "--list": # List the saved backup files. for fn, size in list_target_files(get_backup_config(load_environment())): - print("{}\t{}".format(fn, size)) + print(f"{fn}\t{size}") elif sys.argv[-1] == "--status": # Show backup status. @@ -607,6 +638,9 @@ if __name__ == "__main__": # to duplicity. The restore path should be specified. run_duplicity_restore(sys.argv[2:]) + elif sys.argv[-1] == "--duplicity-command": + print_duplicity_command() + else: # Perform a backup. Add --full to force a full backup rather than # possibly performing an incremental backup. diff --git a/management/cli.py b/management/cli.py index 9d4e89f2..1c3fa4bd 100755 --- a/management/cli.py +++ b/management/cli.py @@ -6,7 +6,8 @@ # 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 +import sys, getpass, urllib.request, urllib.error, json, csv +import contextlib def mgmt(cmd, data=None, is_json=False): # The base URL for the management daemon. (Listens on IPv4 only.) @@ -19,10 +20,8 @@ def mgmt(cmd, data=None, is_json=False): response = urllib.request.urlopen(req) except urllib.error.HTTPError as e: if e.code == 401: - try: + with contextlib.suppress(Exception): 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) @@ -47,7 +46,8 @@ def read_password(): return first def setup_key_auth(mgmt_uri): - key = open('/var/lib/mailinabox/api.key').read().strip() + with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f: + key = f.read().strip() auth_handler = urllib.request.HTTPBasicAuthHandler() auth_handler.add_password( @@ -96,12 +96,9 @@ elif sys.argv[1] == "user" and len(sys.argv) == 2: print(" " + user['quota'], end='') print() -elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): +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] + email = input('email: ') if len(sys.argv) < 4 else sys.argv[3] pw = read_password() else: email, pw = sys.argv[3:5] @@ -114,11 +111,8 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"): 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" +elif sys.argv[1] == "user" and sys.argv[2] in {"make-admin", "remove-admin"} and len(sys.argv) == 4: + action = 'add' if sys.argv[2] == 'make-admin' else 'remove' print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) elif sys.argv[1] == "user" and sys.argv[2] == "admins": @@ -145,7 +139,7 @@ elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", " 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"]: +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 })) diff --git a/management/daemon.py b/management/daemon.py index 9fee8068..e850e6c6 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -11,11 +11,11 @@ # service mailinabox start # when done debugging, start it up again import os, os.path, re, json, time -import multiprocessing.pool, subprocess +import multiprocessing.pool from functools import wraps -from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response +from flask import Flask, request, render_template, Response, send_from_directory, make_response import auth, utils from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user @@ -23,6 +23,7 @@ from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias from mailconfig import get_mail_quota, set_mail_quota, get_default_quota, validate_quota from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa +import contextlib env = utils.load_environment() @@ -30,14 +31,12 @@ auth_service = auth.AuthService() # We may deploy via a symbolic link, which confuses flask's template finding. me = __file__ -try: +with contextlib.suppress(OSError): me = os.readlink(__file__) -except OSError: - pass # for generating CSRs we need a list of country codes csr_country_codes = [] -with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv")) as f: +with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv"), encoding="utf-8") as f: for line in f: if line.strip() == "" or line.startswith("#"): continue code, name = line.strip().split("\t")[0:2] @@ -81,7 +80,7 @@ def authorized_personnel_only(viewfunc): # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. status = 401 headers = { - 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm), + 'WWW-Authenticate': f'Basic realm="{auth_service.auth_realm}"', 'X-Reason': error, } @@ -91,7 +90,7 @@ def authorized_personnel_only(viewfunc): status = 403 headers = None - if request.headers.get('Accept') in (None, "", "*/*"): + if request.headers.get('Accept') in {None, "", "*/*"}: # Return plain text output. return Response(error+"\n", status=status, mimetype='text/plain', headers=headers) else: @@ -122,9 +121,9 @@ def index(): no_users_exist = (len(get_mail_users(env)) == 0) no_admins_exist = (len(get_admins(env)) == 0) - utils.fix_boto() # must call prior to importing boto - import boto.s3 - backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()] + import boto3.s3 + backup_s3_hosts = [(r, f"s3.{r}.amazonaws.com") for r in boto3.session.Session().get_available_regions('s3')] + return render_template('index.html', hostname=env['PRIMARY_HOSTNAME'], @@ -165,7 +164,7 @@ def login(): "api_key": auth_service.create_session_key(email, env, type='login'), } - app.logger.info("New login session created for {}".format(email)) + app.logger.info(f"New login session created for {email}") # Return. return json_response(resp) @@ -174,8 +173,8 @@ def login(): def logout(): try: email, _ = auth_service.authenticate(request, env, logout=True) - app.logger.info("{} logged out".format(email)) - except ValueError as e: + app.logger.info(f"{email} logged out") + except ValueError: pass finally: return json_response({ "status": "ok" }) @@ -379,9 +378,9 @@ def dns_set_record(qname, rtype="A"): # Get the existing records matching the qname and rtype. return dns_get_records(qname, rtype) - elif request.method in ("POST", "PUT"): + elif request.method in {"POST", "PUT"}: # There is a default value for A/AAAA records. - if rtype in ("A", "AAAA") and value == "": + if rtype in {"A", "AAAA"} and value == "": value = request.environ.get("HTTP_X_FORWARDED_FOR") # normally REMOTE_ADDR but we're behind nginx as a reverse proxy # Cannot add empty records. @@ -443,7 +442,7 @@ def ssl_get_status(): { "domain": d["domain"], "status": d["ssl_certificate"][0], - "text": d["ssl_certificate"][1] + ((" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else "")) + "text": d["ssl_certificate"][1] + (" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else "") } for d in domains_status ] # Warn the user about domain names not hosted here because of other settings. @@ -515,7 +514,7 @@ def totp_post_enable(): secret = request.form.get('secret') token = request.form.get('token') label = request.form.get('label') - if type(token) != str: + if not isinstance(token, str): return ("Bad Input", 400) try: validate_totp_secret(secret) @@ -595,6 +594,8 @@ def system_status(): # Create a temporary pool of processes for the status checks with multiprocessing.pool.Pool(processes=5) as pool: run_checks(False, env, output, pool) + pool.close() + pool.join() return json_response(output.items) @app.route('/system/updates') @@ -602,8 +603,7 @@ def system_status(): def show_updates(): from status_checks import list_apt_updates return "".join( - "%s (%s)\n" - % (p["package"], p["version"]) + "{} ({})\n".format(p["package"], p["version"]) for p in list_apt_updates()) @app.route('/system/update-packages', methods=["POST"]) @@ -754,7 +754,7 @@ def munin_cgi(filename): support infrastructure like spawn-fcgi. """ - COMMAND = 'su - munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph' + COMMAND = 'su munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph' # su changes user, we use the munin user here # --preserve-environment retains the environment, which is where Popen's `env` data is # --shell=/bin/bash ensures the shell used is bash @@ -796,14 +796,11 @@ def log_failed_login(request): # During setup we call the management interface directly to determine the user # status. So we can't always use X-Forwarded-For because during setup that header # will not be present. - if request.headers.getlist("X-Forwarded-For"): - ip = request.headers.getlist("X-Forwarded-For")[0] - else: - ip = request.remote_addr + ip = request.headers.getlist("X-Forwarded-For")[0] if request.headers.getlist("X-Forwarded-For") else request.remote_addr # We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate" # message. - app.logger.warning( "Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s" % (ip, time.time())) + app.logger.warning( f"Mail-in-a-Box Management Daemon: Failed login attempt from ip {ip} - timestamp {time.time()}") # APP diff --git a/management/daily_tasks.sh b/management/daily_tasks.sh index db496399..24a97245 100755 --- a/management/daily_tasks.sh +++ b/management/daily_tasks.sh @@ -11,7 +11,7 @@ export LC_TYPE=en_US.UTF-8 # On Mondays, i.e. once a week, send the administrator a report of total emails # sent and received so the admin might notice server abuse. -if [ `date "+%u"` -eq 1 ]; then +if [ "$(date "+%u")" -eq 1 ]; then management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report" fi diff --git a/management/dns_update.py b/management/dns_update.py index fde9b146..599f27b1 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -4,19 +4,20 @@ # and mail aliases and restarts nsd. ######################################################################## -import sys, os, os.path, urllib.parse, datetime, re, hashlib, base64 +import sys, os, os.path, datetime, re, hashlib, base64 import ipaddress import rtyaml import dns.resolver -from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains +from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains, get_ssh_port from ssl_certificates import get_ssl_certificates, check_certificate +import contextlib # From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074 # This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot, # underscores, as well as asteriks which are allowed in domain names but not hostnames (i.e. allowed in # DNS but not in URLs), which are common in certain record types like for DKIM. -DOMAIN_RE = "^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$" +DOMAIN_RE = r"^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$" def get_dns_domains(env): # Add all domain names in use by email users and mail aliases, any @@ -38,7 +39,7 @@ def get_dns_zones(env): # Exclude domains that are subdomains of other domains we know. Proceed # by looking at shorter domains first. zone_domains = set() - for domain in sorted(domains, key=lambda d : len(d)): + for domain in sorted(domains, key=len): for d in zone_domains: if domain.endswith("." + d): # We found a parent domain already in the list. @@ -48,9 +49,7 @@ def get_dns_zones(env): zone_domains.add(domain) # Make a nice and safe filename for each domain. - zonefiles = [] - for domain in zone_domains: - zonefiles.append([domain, safe_domain_name(domain) + ".txt"]) + zonefiles = [[domain, safe_domain_name(domain) + ".txt"] for domain in zone_domains] # Sort the list so that the order is nice and so that nsd.conf has a # stable order so we don't rewrite the file & restart the service @@ -96,9 +95,18 @@ def do_dns_update(env, force=False): if len(updated_domains) == 0: updated_domains.append("DNS configuration") - # Kick nsd if anything changed. + # Tell nsd to reload changed zone files. if len(updated_domains) > 0: - shell('check_call', ["/usr/sbin/service", "nsd", "restart"]) + # 'reconfig' is needed if there are added or removed zones, but + # it may not reload existing zones, so we call 'reload' too. If + # nsd isn't running, nsd-control fails, so in that case revert + # to restarting nsd to make sure it is running. Restarting nsd + # should also refresh everything. + try: + shell('check_call', ["/usr/sbin/nsd-control", "reconfig"]) + shell('check_call', ["/usr/sbin/nsd-control", "reload"]) + except: + shell('check_call', ["/usr/sbin/service", "nsd", "restart"]) # Write the OpenDKIM configuration tables for all of the mail domains. from mailconfig import get_mail_domains @@ -185,8 +193,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) # User may provide one or more additional nameservers secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \ or ["ns2." + env["PRIMARY_HOSTNAME"]] - for secondary_ns in secondary_ns_list: - records.append((None, "NS", secondary_ns+'.', False)) + records.extend((None, "NS", secondary_ns+'.', False) for secondary_ns in secondary_ns_list) # In PRIMARY_HOSTNAME... @@ -203,8 +210,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it.")) # Add a SSHFP records to help SSH key validation. One per available SSH key on this system. - for value in build_sshfp_records(): - records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.")) + records.extend((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.") for value in build_sshfp_records()) # Add DNS records for any subdomains of this domain. We should not have a zone for # both a domain and one of its subdomains. @@ -214,7 +220,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) subdomain_qname = subdomain[0:-len("." + domain)] subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False) for child_qname, child_rtype, child_value, child_explanation in subzone: - if child_qname == None: + if child_qname is None: child_qname = subdomain_qname else: child_qname += "." + subdomain_qname @@ -222,10 +228,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) has_rec_base = list(records) # clone current state def has_rec(qname, rtype, prefix=None): - for rec in has_rec_base: - if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)): - return True - return False + return any(rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)) for rec in has_rec_base) # The user may set other records that don't conflict with our settings. # Don't put any TXT records above this line, or it'll prevent any custom TXT records. @@ -253,7 +256,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) has_rec_base = list(records) a_expl = "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain if domain_properties[domain]["auto"]: - if domain.startswith("ns1.") or domain.startswith("ns2."): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server + if domain.startswith(("ns1.", "ns2.")): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server if domain.startswith("www."): a_expl = "Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain." % domain if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt." if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig." @@ -289,7 +292,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) # Append the DKIM TXT record to the zone as generated by OpenDKIM. # Skip if the user has set a DKIM record already. opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') - with open(opendkim_record_file) as orf: + with open(opendkim_record_file, encoding="utf-8") as orf: m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) val = "".join(re.findall(r'"([^"]+)"', m.group(2))) if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): @@ -298,7 +301,7 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) # Append a DMARC record. # Skip if the user has set a DMARC record already. if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "): - records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain)) + records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine;', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain)) if domain_properties[domain]["user"]: # Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname @@ -355,15 +358,15 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True) # non-mail domain and also may include qnames from custom DNS records. # Do this once at the end of generating a zone. if is_zone: - qnames_with_a = set(qname for (qname, rtype, value, explanation) in records if rtype in ("A", "AAAA")) - qnames_with_mx = set(qname for (qname, rtype, value, explanation) in records if rtype == "MX") + qnames_with_a = {qname for (qname, rtype, value, explanation) in records if rtype in {"A", "AAAA"}} + qnames_with_mx = {qname for (qname, rtype, value, explanation) in records if rtype == "MX"} for qname in qnames_with_a - qnames_with_mx: # Mark this domain as not sending mail with hard-fail SPF and DMARC records. d = (qname+"." if qname else "") + domain if not has_rec(qname, "TXT", prefix="v=spf1 "): records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % d)) if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "): - records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d)) + records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject;', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d)) # And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record) if not has_rec(qname, "MX"): @@ -445,24 +448,19 @@ def build_sshfp_records(): # if SSH has been configured to listen on a nonstandard port, we must # specify that port to sshkeyscan. - port = 22 - with open('/etc/ssh/sshd_config', 'r') as f: - for line in f: - s = line.rstrip().split() - if len(s) == 2 and s[0] == 'Port': - try: - port = int(s[1]) - except ValueError: - pass - break + port = get_ssh_port() - keys = shell("check_output", ["ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"]) + # If nothing returned, SSH is probably not installed. + if not port: + return + + keys = shell("check_output", ["ssh-keyscan", "-4", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"]) keys = sorted(keys.split("\n")) for key in keys: if key.strip() == "" or key[0] == "#": continue try: - host, keytype, pubkey = key.split(" ") + _host, keytype, pubkey = key.split(" ") yield "%d %d ( %s )" % ( algorithm_number[keytype], 2, # specifies we are using SHA-256 on next line @@ -507,7 +505,7 @@ $TTL 86400 ; default time to live zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"]) # Add records. - for subdomain, querytype, value, explanation in records: + for subdomain, querytype, value, _explanation in records: if subdomain: zone += subdomain zone += "\tIN\t" + querytype + "\t" @@ -525,7 +523,7 @@ $TTL 86400 ; default time to live zone += value + "\n" # Append a stable hash of DNSSEC signing keys in a comment. - zone += "\n; DNSSEC signing keys hash: {}\n".format(hash_dnssec_keys(domain, env)) + zone += f"\n; DNSSEC signing keys hash: {hash_dnssec_keys(domain, env)}\n" # DNSSEC requires re-signing a zone periodically. That requires # bumping the serial number even if no other records have changed. @@ -541,7 +539,7 @@ $TTL 86400 ; default time to live # We've signed the domain. Check if we are close to the expiration # time of the signature. If so, we'll force a bump of the serial # number so we can re-sign it. - with open(zonefile + ".signed") as f: + with open(zonefile + ".signed", encoding="utf-8") as f: signed_zone = f.read() expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone) if len(expiration_times) == 0: @@ -560,7 +558,7 @@ $TTL 86400 ; default time to live if os.path.exists(zonefile): # If the zone already exists, is different, and has a later serial number, # increment the number. - with open(zonefile) as f: + with open(zonefile, encoding="utf-8") as f: existing_zone = f.read() m = re.search(r"(\d+)\s*;\s*serial number", existing_zone) if m: @@ -584,7 +582,7 @@ $TTL 86400 ; default time to live zone = zone.replace("__SERIAL__", serial) # Write the zone file. - with open(zonefile, "w") as f: + with open(zonefile, "w", encoding="utf-8") as f: f.write(zone) return True # file is updated @@ -597,7 +595,7 @@ def get_dns_zonefile(zone, env): raise ValueError("%s is not a domain name that corresponds to a zone." % zone) nsd_zonefile = "/etc/nsd/zones/" + fn - with open(nsd_zonefile, "r") as f: + with open(nsd_zonefile, encoding="utf-8") as f: return f.read() ######################################################################## @@ -609,11 +607,11 @@ def write_nsd_conf(zonefiles, additional_records, env): # Append the zones. for domain, zonefile in zonefiles: - nsdconf += """ + nsdconf += f""" zone: - name: %s - zonefile: %s -""" % (domain, zonefile) + name: {domain} + zonefile: {zonefile} +""" # If custom secondary nameservers have been set, allow zone transfers # and, if not a subnet, notifies to them. @@ -625,13 +623,13 @@ zone: # Check if the file is changing. If it isn't changing, # return False to flag that no change was made. if os.path.exists(nsd_conf_file): - with open(nsd_conf_file) as f: + with open(nsd_conf_file, encoding="utf-8") as f: if f.read() == nsdconf: return False # Write out new contents and return True to signal that # configuration changed. - with open(nsd_conf_file, "w") as f: + with open(nsd_conf_file, "w", encoding="utf-8") as f: f.write(nsdconf) return True @@ -665,9 +663,8 @@ def hash_dnssec_keys(domain, env): keydata = [] for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)): oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private") - keydata.append(keytype) - keydata.append(keyfn) - with open(oldkeyfn, "r") as fr: + keydata.extend((keytype, keyfn)) + with open(oldkeyfn, encoding="utf-8") as fr: keydata.append( fr.read() ) keydata = "".join(keydata).encode("utf8") return hashlib.sha1(keydata).hexdigest() @@ -695,12 +692,12 @@ def sign_zone(domain, zonefile, env): # Use os.umask and open().write() to securely create a copy that only # we (root) can read. oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext) - with open(oldkeyfn, "r") as fr: + with open(oldkeyfn, encoding="utf-8") as fr: keydata = fr.read() keydata = keydata.replace("_domain_", domain) prev_umask = os.umask(0o77) # ensure written file is not world-readable try: - with open(newkeyfn + ext, "w") as fw: + with open(newkeyfn + ext, "w", encoding="utf-8") as fw: fw.write(keydata) finally: os.umask(prev_umask) # other files we write should be world-readable @@ -734,7 +731,7 @@ def sign_zone(domain, zonefile, env): # be used, so we'll pre-generate all for each key. One DS record per line. Only one # needs to actually be deployed at the registrar. We'll select the preferred one # in the status checks. - with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f: + with open("/etc/nsd/zones/" + zonefile + ".ds", "w", encoding="utf-8") as f: for key in ksk_keys: for digest_type in ('1', '2', '4'): rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds", @@ -771,7 +768,7 @@ def write_opendkim_tables(domains, env): # So we must have a separate KeyTable entry for each domain. "SigningTable": "".join( - "*@{domain} {domain}\n".format(domain=domain) + f"*@{domain} {domain}\n" for domain in domains ), @@ -780,7 +777,7 @@ def write_opendkim_tables(domains, env): # signing domain must match the sender's From: domain. "KeyTable": "".join( - "{domain} {domain}:mail:{key_file}\n".format(domain=domain, key_file=opendkim_key_file) + f"{domain} {domain}:mail:{opendkim_key_file}\n" for domain in domains ), } @@ -789,12 +786,12 @@ def write_opendkim_tables(domains, env): for filename, content in config.items(): # Don't write the file if it doesn't need an update. if os.path.exists("/etc/opendkim/" + filename): - with open("/etc/opendkim/" + filename) as f: + with open("/etc/opendkim/" + filename, encoding="utf-8") as f: if f.read() == content: continue # The contents needs to change. - with open("/etc/opendkim/" + filename, "w") as f: + with open("/etc/opendkim/" + filename, "w", encoding="utf-8") as f: f.write(content) did_update = True @@ -806,8 +803,9 @@ def write_opendkim_tables(domains, env): def get_custom_dns_config(env, only_real_records=False): try: - custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'))) - if not isinstance(custom_dns, dict): raise ValueError() # caught below + with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), encoding="utf-8") as f: + custom_dns = rtyaml.load(f) + if not isinstance(custom_dns, dict): raise ValueError # caught below except: return [ ] @@ -825,7 +823,7 @@ def get_custom_dns_config(env, only_real_records=False): # No other type of data is allowed. else: - raise ValueError() + raise ValueError for rtype, value2 in values: if isinstance(value2, str): @@ -835,7 +833,7 @@ def get_custom_dns_config(env, only_real_records=False): yield (qname, rtype, value3) # No other type of data is allowed. else: - raise ValueError() + raise ValueError def filter_custom_records(domain, custom_dns_iter): for qname, rtype, value in custom_dns_iter: @@ -851,10 +849,7 @@ def filter_custom_records(domain, custom_dns_iter): # our short form (None => domain, or a relative QNAME) if # domain is not None. if domain is not None: - if qname == domain: - qname = None - else: - qname = qname[0:len(qname)-len("." + domain)] + qname = None if qname == domain else qname[0:len(qname) - len("." + domain)] yield (qname, rtype, value) @@ -890,12 +885,12 @@ def write_custom_dns_config(config, env): # Write. config_yaml = rtyaml.dump(dns) - with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f: + with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w", encoding="utf-8") as f: f.write(config_yaml) def set_custom_dns_record(qname, rtype, value, action, env): # validate qname - for zone, fn in get_dns_zones(env): + for zone, _fn in get_dns_zones(env): # It must match a zone apex or be a subdomain of a zone # that we are otherwise hosting. if qname == zone or qname.endswith("."+zone): @@ -909,24 +904,27 @@ def set_custom_dns_record(qname, rtype, value, action, env): rtype = rtype.upper() if value is not None and qname != "_secondary_nameserver": if not re.search(DOMAIN_RE, qname): - raise ValueError("Invalid name.") + msg = "Invalid name." + raise ValueError(msg) - if rtype in ("A", "AAAA"): + if rtype in {"A", "AAAA"}: if value != "local": # "local" is a special flag for us v = ipaddress.ip_address(value) # raises a ValueError if there's a problem if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.") if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.") - elif rtype in ("CNAME", "NS"): + elif rtype in {"CNAME", "NS"}: if rtype == "NS" and qname == zone: - raise ValueError("NS records can only be set for subdomains.") + msg = "NS records can only be set for subdomains." + raise ValueError(msg) # ensure value has a trailing dot if not value.endswith("."): value = value + "." if not re.search(DOMAIN_RE, value): - raise ValueError("Invalid value.") - elif rtype in ("CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"): + msg = "Invalid value." + raise ValueError(msg) + elif rtype in {"CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"}: # anything goes pass else: @@ -959,7 +957,7 @@ def set_custom_dns_record(qname, rtype, value, action, env): # Drop this record. made_change = True continue - if value == None and (_qname, _rtype) == (qname, rtype): + if value is None and (_qname, _rtype) == (qname, rtype): # Drop all qname-rtype records. made_change = True continue @@ -969,7 +967,7 @@ def set_custom_dns_record(qname, rtype, value, action, env): # Preserve this record. newconfig.append((_qname, _rtype, _value)) - if action in ("add", "set") and needs_add and value is not None: + if action in {"add", "set"} and needs_add and value is not None: newconfig.append((qname, rtype, value)) made_change = True @@ -983,36 +981,45 @@ def set_custom_dns_record(qname, rtype, value, action, env): def get_secondary_dns(custom_dns, mode=None): resolver = dns.resolver.get_default_resolver() resolver.timeout = 10 + resolver.lifetime = 10 values = [] - for qname, rtype, value in custom_dns: + for qname, _rtype, value in custom_dns: if qname != '_secondary_nameserver': continue for hostname in value.split(" "): hostname = hostname.strip() - if mode == None: + if mode is None: # Just return the setting. values.append(hostname) continue - # This is a hostname. Before including in zone xfr lines, - # resolve to an IP address. Otherwise just return the hostname. - # It may not resolve to IPv6, so don't throw an exception if it - # doesn't. - if not hostname.startswith("xfr:"): - if mode == "xfr": - response = dns.resolver.query(hostname+'.', "A", raise_on_no_answer=False) - values.extend(map(str, response)) - response = dns.resolver.query(hostname+'.', "AAAA", raise_on_no_answer=False) - values.extend(map(str, response)) - continue - values.append(hostname) + # If the entry starts with "xfr:" only include it in the zone transfer settings. + if hostname.startswith("xfr:"): + if mode != "xfr": continue + hostname = hostname[4:] - # This is a zone-xfer-only IP address. Do not return if - # we're querying for NS record hostnames. Only return if - # we're querying for zone xfer IP addresses - return the - # IP address. - elif mode == "xfr": - values.append(hostname[4:]) + # If is a hostname, before including in zone xfr lines, + # resolve to an IP address. + # It may not resolve to IPv6, so don't throw an exception if it + # doesn't. Skip the entry if there is a DNS error. + if mode == "xfr": + try: + ipaddress.ip_interface(hostname) # test if it's an IP address or CIDR notation + values.append(hostname) + except ValueError: + try: + response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False) + values.extend(map(str, response)) + except dns.exception.DNSException: + pass + try: + response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False) + values.extend(map(str, response)) + except dns.exception.DNSException: + pass + + else: + values.append(hostname) return values @@ -1021,23 +1028,25 @@ def set_secondary_dns(hostnames, env): # Validate that all hostnames are valid and that all zone-xfer IP addresses are valid. resolver = dns.resolver.get_default_resolver() resolver.timeout = 5 + resolver.lifetime = 5 + for item in hostnames: if not item.startswith("xfr:"): # Resolve hostname. try: - response = resolver.query(item, "A") - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + resolver.resolve(item, "A") + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): try: - response = resolver.query(item, "AAAA") - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + resolver.resolve(item, "AAAA") + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout): raise ValueError("Could not resolve the IP address of %s." % item) else: # Validate IP address. try: if "/" in item[4:]: - v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem + ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem else: - v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem + ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem except ValueError: raise ValueError("'%s' is not an IPv4 or IPv6 address or subnet." % item[4:]) @@ -1055,14 +1064,13 @@ def get_custom_dns_records(custom_dns, qname, rtype): for qname1, rtype1, value in custom_dns: if qname1 == qname and rtype1 == rtype: yield value - return None ######################################################################## def build_recommended_dns(env): ret = [] - for (domain, zonefile, records) in build_zones(env): - # remove records that we don't dislay + for (domain, _zonefile, records) in build_zones(env): + # remove records that we don't display records = [r for r in records if r[3] is not False] # put Required at the top, then Recommended, then everythiing else @@ -1070,10 +1078,7 @@ def build_recommended_dns(env): # expand qnames for i in range(len(records)): - if records[i][0] == None: - qname = domain - else: - qname = records[i][0] + "." + domain + qname = domain if records[i][0] is None else records[i][0] + "." + domain records[i] = { "qname": qname, @@ -1092,7 +1097,7 @@ if __name__ == "__main__": if sys.argv[-1] == "--lint": write_custom_dns_config(get_custom_dns_config(env), env) else: - for zone, records in build_recommended_dns(env): + for _zone, records in build_recommended_dns(env): for record in records: print("; " + record['explanation']) print(record['qname'], record['rtype'], record['value'], sep="\t") diff --git a/management/email_administrator.py b/management/email_administrator.py index 8ed6e2a8..e5307e32 100755 --- a/management/email_administrator.py +++ b/management/email_administrator.py @@ -29,7 +29,7 @@ content = sys.stdin.read().strip() # If there's nothing coming in, just exit. if content == "": - sys.exit(0) + sys.exit(0) # create MIME message msg = MIMEMultipart('alternative') @@ -37,11 +37,11 @@ msg = MIMEMultipart('alternative') # In Python 3.6: #msg = Message() -msg['From'] = "\"%s\" <%s>" % (env['PRIMARY_HOSTNAME'], admin_addr) +msg['From'] = '"{}" <{}>'.format(env['PRIMARY_HOSTNAME'], admin_addr) msg['To'] = admin_addr -msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject) +msg['Subject'] = "[{}] {}".format(env['PRIMARY_HOSTNAME'], subject) -content_html = "
{}
".format(html.escape(content)) +content_html = f'
{html.escape(content)}
' msg.attach(MIMEText(content, 'plain')) msg.attach(MIMEText(content_html, 'html')) diff --git a/management/mail_log.py b/management/mail_log.py index bdf757cc..e127af3b 100755 --- a/management/mail_log.py +++ b/management/mail_log.py @@ -73,7 +73,8 @@ def scan_files(collector): continue elif fn[-3:] == '.gz': tmp_file = tempfile.NamedTemporaryFile() - shutil.copyfileobj(gzip.open(fn), tmp_file) + with gzip.open(fn, 'rb') as f: + shutil.copyfileobj(f, tmp_file) if VERBOSE: print("Processing file", fn, "...") @@ -115,12 +116,11 @@ def scan_mail_log(env): try: import mailconfig collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) | - set(alias[0] for alias in mailconfig.get_mail_aliases(env))) + {alias[0] for alias in mailconfig.get_mail_aliases(env)}) except ImportError: pass - print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".format( - START_DATE, END_DATE) + print(f"Scanning logs from {START_DATE:%Y-%m-%d %H:%M:%S} to {END_DATE:%Y-%m-%d %H:%M:%S}" ) # Scan the lines in the log files until the date goes out of range @@ -226,7 +226,7 @@ def scan_mail_log(env): ], sub_data=[ ("Protocol and Source", [[ - "{} {}: {} times".format(protocol_name, host, count) + f"{protocol_name} {host}: {count} times" for (protocol_name, host), count in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1]) ] for u in data.values()]) @@ -302,8 +302,7 @@ def scan_mail_log(env): for date, sender, message in user_data["blocked"]: if len(sender) > 64: sender = sender[:32] + "…" + sender[-32:] - user_rejects.append("%s - %s " % (date, sender)) - user_rejects.append(" %s" % message) + user_rejects.extend((f'{date} - {sender} ', ' %s' % message)) rejects.append(user_rejects) print_user_table( @@ -321,7 +320,7 @@ def scan_mail_log(env): if collector["other-services"] and VERBOSE and False: print_header("Other services") print("The following unkown services were found in the log file.") - print(" ", *sorted(list(collector["other-services"])), sep='\n│ ') + print(" ", *sorted(collector["other-services"]), sep='\n│ ') def scan_mail_log_line(line, collector): @@ -332,7 +331,7 @@ def scan_mail_log_line(line, collector): if not m: return True - date, system, service, log = m.groups() + date, _system, service, log = m.groups() collector["scan_count"] += 1 # print() @@ -343,7 +342,7 @@ def scan_mail_log_line(line, collector): # Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster. # date = dateutil.parser.parse(date) - + # strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided. # See https://bugs.python.org/issue26460 date = datetime.datetime.strptime(str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S') @@ -375,9 +374,9 @@ def scan_mail_log_line(line, collector): elif service == "postfix/smtpd": if SCAN_BLOCKED: scan_postfix_smtpd_line(date, log, collector) - elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", + elif service in {"postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", "spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp", - "postfix/tlsmgr", "anvil"): + "postfix/tlsmgr", "anvil"}: # nothing to look at return True else: @@ -391,7 +390,7 @@ def scan_mail_log_line(line, collector): def scan_postgrey_line(date, log, collector): """ Scan a postgrey log line and extract interesting data """ - m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), " + m = re.match(r"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), " "client_address=(.*), sender=(.*), recipient=(.*)", log) @@ -434,36 +433,35 @@ def scan_postfix_smtpd_line(date, log, collector): return # only log mail to known recipients - if user_match(user): - if collector["known_addresses"] is None or user in collector["known_addresses"]: - data = collector["rejected"].get( - user, - { - "blocked": [], - "earliest": None, - "latest": None, - } - ) - # simplify this one + if user_match(user) and (collector["known_addresses"] is None or user in collector["known_addresses"]): + data = collector["rejected"].get( + user, + { + "blocked": [], + "earliest": None, + "latest": None, + } + ) + # simplify this one + m = re.search( + r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message + ) + if m: + message = "ip blocked: " + m.group(2) + else: + # simplify this one too m = re.search( - r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message + r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message ) if m: - message = "ip blocked: " + m.group(2) - else: - # simplify this one too - m = re.search( - r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message - ) - if m: - message = "domain blocked: " + m.group(2) + message = "domain blocked: " + m.group(2) - if data["earliest"] is None: - data["earliest"] = date - data["latest"] = date - data["blocked"].append((date, sender, message)) + if data["earliest"] is None: + data["earliest"] = date + data["latest"] = date + data["blocked"].append((date, sender, message)) - collector["rejected"][user] = data + collector["rejected"][user] = data def scan_dovecot_login_line(date, log, collector, protocol_name): @@ -499,7 +497,7 @@ def add_login(user, date, protocol_name, host, collector): data["totals_by_protocol"][protocol_name] += 1 data["totals_by_protocol_and_host"][(protocol_name, host)] += 1 - if host not in ("127.0.0.1", "::1") or True: + if host not in {"127.0.0.1", "::1"} or True: data["activity-by-hour"][protocol_name][date.hour] += 1 collector["logins"][user] = data @@ -513,7 +511,7 @@ def scan_postfix_lmtp_line(date, log, collector): """ - m = re.match("([A-Z0-9]+): to=<(\S+)>, .* Saved", log) + m = re.match(r"([A-Z0-9]+): to=<(\S+)>, .* Saved", log) if m: _, user = m.groups() @@ -549,12 +547,12 @@ def scan_postfix_submission_line(date, log, collector): """ # Match both the 'plain' and 'login' sasl methods, since both authentication methods are - # allowed by Dovecot. Exclude trailing comma after the username when additional fields + # allowed by Dovecot. Exclude trailing comma after the username when additional fields # follow after. - m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(? 32 else d[row]) col_left[col] = True elif isinstance(d[row], datetime.datetime): - col_str = "{:<20}".format(str(d[row])) + col_str = f"{d[row]!s:<20}" col_left[col] = True else: temp = "{:>%s}" % max(5, len(l) + 1, len(str(d[row])) + 1) @@ -683,7 +679,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None data_accum[col] += d[row] try: - if None not in [latest, earliest]: + if None not in {latest, earliest}: vert_pos = len(line) e = earliest[row] l = latest[row] @@ -711,10 +707,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None if sub_data is not None: for l, d in sub_data: if d[row]: - lines.append("┬") - lines.append("│ %s" % l) - lines.append("├─%s─" % (len(l) * "─")) - lines.append("│") + lines.extend(('┬', '│ %s' % l, '├─%s─' % (len(l) * '─'), '│')) max_len = 0 for v in list(d[row]): lines.append("│ %s" % v) @@ -739,7 +732,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None else: header += l.rjust(max(5, len(l) + 1, col_widths[col])) - if None not in (latest, earliest): + if None not in {latest, earliest}: header += " │ timespan " lines.insert(0, header.rstrip()) @@ -764,7 +757,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None footer += temp.format(data_accum[row]) try: - if None not in [latest, earliest]: + if None not in {latest, earliest}: max_l = max(latest) min_e = min(earliest) timespan = relativedelta(max_l, min_e) @@ -843,7 +836,7 @@ if __name__ == "__main__": END_DATE = args.enddate if args.timespan == 'today': args.timespan = 'day' - print("Setting end date to {}".format(END_DATE)) + print(f"Setting end date to {END_DATE}") START_DATE = END_DATE - TIME_DELTAS[args.timespan] diff --git a/management/mailconfig.py b/management/mailconfig.py index 8ba15adf..f9c8f237 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -9,7 +9,7 @@ # Python 3 in setup/questions.sh to validate the email # address entered by the user. -import subprocess, shutil, os, sqlite3, re +import os, sqlite3, re import utils from email_validator import validate_email as validate_email_, EmailNotValidError import idna @@ -86,10 +86,7 @@ def prettify_idn_email_address(email): def is_dcv_address(email): email = email.lower() - for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"): - if email.startswith(localpart+"@") or email.startswith(localpart+"+"): - return True - return False + return any(email.startswith((localpart + "@", localpart + "+")) for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse")) def open_database(env, with_connection=False): conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite") @@ -241,8 +238,7 @@ def get_mail_aliases(env): aliases = { row[0]: row for row in c.fetchall() } # make dict # put in a canonical order: sort by domain, then by email address lexicographically - aliases = [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] - return aliases + return [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] def get_mail_aliases_ex(env): # Returns a complex data structure of all mail aliases, similar @@ -274,7 +270,7 @@ def get_mail_aliases_ex(env): domain = get_domain(address) # add to list - if not domain in domains: + if domain not in domains: domains[domain] = { "domain": domain, "aliases": [], @@ -589,10 +585,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist forwards_to = ",".join(validated_forwards_to) - if len(validated_permitted_senders) == 0: - permitted_senders = None - else: - permitted_senders = ",".join(validated_permitted_senders) + permitted_senders = None if len(validated_permitted_senders) == 0 else ",".join(validated_permitted_senders) conn, c = open_database(env, with_connection=True) try: @@ -610,6 +603,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist if do_kick: # Update things in case any new domains are added. return kick(env, return_status) + return None def remove_mail_alias(address, env, do_kick=True): # convert Unicode domain to IDNA @@ -625,10 +619,11 @@ def remove_mail_alias(address, env, do_kick=True): if do_kick: # Update things in case any domains are removed. return kick(env, "alias removed") + return None def add_auto_aliases(aliases, env): conn, c = open_database(env, with_connection=True) - c.execute("DELETE FROM auto_aliases"); + c.execute("DELETE FROM auto_aliases") for source, destination in aliases.items(): c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination)) conn.commit() @@ -698,14 +693,14 @@ def kick(env, mail_result=None): # Remove auto-generated postmaster/admin/abuse alises from the main aliases table. # They are now stored in the auto_aliases table. - for address, forwards_to, permitted_senders, auto in get_mail_aliases(env): + for address, forwards_to, _permitted_senders, auto in get_mail_aliases(env): user, domain = address.split("@") - if user in ("postmaster", "admin", "abuse") \ + if user in {"postmaster", "admin", "abuse"} \ and address not in required_aliases \ and forwards_to == get_system_administrator(env) \ and not auto: remove_mail_alias(address, env, do_kick=False) - results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to)) + results.append(f"removed alias {address} (was to {forwards_to}; domain no longer used for email)\n") # Update DNS and nginx in case any domains are added/removed. @@ -720,9 +715,11 @@ def kick(env, mail_result=None): def validate_password(pw): # validate password if pw.strip() == "": - raise ValueError("No password provided.") + msg = "No password provided." + raise ValueError(msg) if len(pw) < 8: - raise ValueError("Passwords must be at least eight characters.") + msg = "Passwords must be at least eight characters." + raise ValueError(msg) if __name__ == "__main__": import sys diff --git a/management/mfa.py b/management/mfa.py index 32eb5183..6b56ad86 100644 --- a/management/mfa.py +++ b/management/mfa.py @@ -41,9 +41,11 @@ def enable_mfa(email, type, secret, token, label, env): # Sanity check with the provide current token. totp = pyotp.TOTP(secret) if not totp.verify(token, valid_window=1): - raise ValueError("Invalid token.") + msg = "Invalid token." + raise ValueError(msg) else: - raise ValueError("Invalid MFA type.") + msg = "Invalid MFA type." + raise ValueError(msg) 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)) @@ -66,10 +68,12 @@ def disable_mfa(email, mfa_id, env): return c.rowcount > 0 def validate_totp_secret(secret): - if type(secret) != str or secret.strip() == "": - raise ValueError("No secret provided.") + if not isinstance(secret, str) or secret.strip() == "": + msg = "No secret provided." + raise ValueError(msg) if len(secret) != 32: - raise ValueError("Secret should be a 32 characters base32 string") + msg = "Secret should be a 32 characters base32 string" + raise ValueError(msg) def provision_totp(email, env): # Make a new secret. diff --git a/management/ssl_certificates.py b/management/ssl_certificates.py index 3e1b5856..ab0cea4d 100755 --- a/management/ssl_certificates.py +++ b/management/ssl_certificates.py @@ -4,7 +4,8 @@ import os, os.path, re, shutil, subprocess, tempfile from utils import shell, safe_domain_name, sort_domains -import idna +import functools +import operator # SELECTING SSL CERTIFICATES FOR USE IN WEB @@ -58,37 +59,33 @@ def get_ssl_certificates(env): # Not a valid PEM format for a PEM type we care about. continue - # Remember where we got this object. - pem._filename = fn - # Is it a private key? if isinstance(pem, RSAPrivateKey): - private_keys[pem.public_key().public_numbers()] = pem + private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem } # Is it a certificate? if isinstance(pem, Certificate): - certificates.append(pem) + certificates.append({ "filename": fn, "cert": pem }) # Process the certificates. domains = { } for cert in certificates: # What domains is this certificate good for? - cert_domains, primary_domain = get_certificate_domains(cert) - cert._primary_domain = primary_domain + cert_domains, primary_domain = get_certificate_domains(cert["cert"]) + cert["primary_domain"] = primary_domain # Is there a private key file for this certificate? - private_key = private_keys.get(cert.public_key().public_numbers()) + private_key = private_keys.get(cert["cert"].public_key().public_numbers()) if not private_key: continue - cert._private_key = private_key + cert["private_key"] = private_key # Add this cert to the list of certs usable for the domains. for domain in cert_domains: # The primary hostname can only use a certificate mapped # to the system private key. - if domain == env['PRIMARY_HOSTNAME']: - if cert._private_key._filename != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'): - continue + if domain == env['PRIMARY_HOSTNAME'] and cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'): + continue domains.setdefault(domain, []).append(cert) @@ -100,10 +97,10 @@ def get_ssl_certificates(env): #for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename) cert_list.sort(key = lambda cert : ( # must be valid NOW - cert.not_valid_before <= now <= cert.not_valid_after, + cert["cert"].not_valid_before <= now <= cert["cert"].not_valid_after, # prefer one that is not self-signed - cert.issuer != cert.subject, + cert["cert"].issuer != cert["cert"].subject, ########################################################### # The above lines ensure that valid certificates are chosen @@ -113,7 +110,7 @@ def get_ssl_certificates(env): # prefer one with the expiration furthest into the future so # that we can easily rotate to new certs as we get them - cert.not_valid_after, + cert["cert"].not_valid_after, ########################################################### # We always choose the certificate that is good for the @@ -128,15 +125,15 @@ def get_ssl_certificates(env): # in case a certificate is installed in multiple paths, # prefer the... lexicographically last one? - cert._filename, + cert["filename"], ), reverse=True) cert = cert_list.pop(0) ret[domain] = { - "private-key": cert._private_key._filename, - "certificate": cert._filename, - "primary-domain": cert._primary_domain, - "certificate_object": cert, + "private-key": cert["private_key"]["filename"], + "certificate": cert["filename"], + "primary-domain": cert["primary_domain"], + "certificate_object": cert["cert"], } return ret @@ -153,13 +150,12 @@ def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False "certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]), } - if use_main_cert: - if domain == env['PRIMARY_HOSTNAME']: - # The primary domain must use the server certificate because - # it is hard-coded in some service configuration files. - return system_certificate + if use_main_cert and domain == env['PRIMARY_HOSTNAME']: + # The primary domain must use the server certificate because + # it is hard-coded in some service configuration files. + return system_certificate - wildcard_domain = re.sub("^[^\.]+", "*", domain) + wildcard_domain = re.sub(r"^[^\.]+", "*", domain) if domain in ssl_certificates: return ssl_certificates[domain] elif wildcard_domain in ssl_certificates: @@ -215,7 +211,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True if not value: continue # IPv6 is not configured response = query_dns(domain, rtype) if response != normalize_ip(value): - bad_dns.append("%s (%s)" % (response, rtype)) + bad_dns.append(f"{response} ({rtype})") if bad_dns: domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \ @@ -268,11 +264,11 @@ def provision_certificates(env, limit_domains): # primary domain listed in each certificate. from dns_update import get_dns_zones certs = { } - for zone, zonefile in get_dns_zones(env): + for zone, _zonefile in get_dns_zones(env): certs[zone] = [[]] for domain in sort_domains(domains, env): # Does the domain end with any domain we've seen so far. - for parent in certs.keys(): + for parent in certs: if domain.endswith("." + parent): # Add this to the parent's list of domains. # Start a new group if the list already has @@ -289,7 +285,7 @@ def provision_certificates(env, limit_domains): # Flatten to a list of lists of domains (from a mapping). Remove empty # lists (zones with no domains that need certs). - certs = sum(certs.values(), []) + certs = functools.reduce(operator.iadd, certs.values(), []) certs = [_ for _ in certs if len(_) > 0] # Prepare to provision. @@ -417,7 +413,7 @@ def create_csr(domain, ssl_key, country_code, env): "openssl", "req", "-new", "-key", ssl_key, "-sha256", - "-subj", "/C=%s/CN=%s" % (country_code, domain)]) + "-subj", f"/C={country_code}/CN={domain}"]) def install_cert(domain, ssl_cert, ssl_chain, env, raw=False): # Write the combined cert+chain to a temporary path and validate that it is OK. @@ -453,8 +449,8 @@ def install_cert_copy_file(fn, env): from cryptography.hazmat.primitives import hashes from binascii import hexlify cert = load_pem(load_cert_chain(fn)[0]) - all_domains, cn = get_certificate_domains(cert) - path = "%s-%s-%s.pem" % ( + _all_domains, cn = get_certificate_domains(cert) + path = "{}-{}-{}.pem".format( safe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix @@ -525,12 +521,12 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring # First check that the domain name is one of the names allowed by # the certificate. if domain is not None: - certificate_names, cert_primary_name = get_certificate_domains(cert) + certificate_names, _cert_primary_name = get_certificate_domains(cert) # Check that the domain appears among the acceptable names, or a wildcard # form of the domain name (which is a stricter check than the specs but # should work in normal cases). - wildcard_domain = re.sub("^[^\.]+", "*", domain) + wildcard_domain = re.sub(r"^[^\.]+", "*", domain) if domain not in certificate_names and wildcard_domain not in certificate_names: return ("The certificate is for the wrong domain name. It is for %s." % ", ".join(sorted(certificate_names)), None) @@ -538,9 +534,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring # Second, check that the certificate matches the private key. if ssl_private_key is not None: try: - priv_key = load_pem(open(ssl_private_key, 'rb').read()) + with open(ssl_private_key, 'rb') as f: + priv_key = load_pem(f.read()) except ValueError as e: - return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None) + return (f"The private key file {ssl_private_key} is not a private key file: {e!s}", None) if not isinstance(priv_key, RSAPrivateKey): return ("The private key file %s is not a private key file." % ssl_private_key, None) @@ -568,7 +565,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring import datetime now = datetime.datetime.utcnow() if not(cert.not_valid_before <= now <= cert.not_valid_after): - return ("The certificate has expired or is not yet valid. It is valid from %s to %s." % (cert.not_valid_before, cert.not_valid_after), None) + return (f"The certificate has expired or is not yet valid. It is valid from {cert.not_valid_before} to {cert.not_valid_after}.", None) # Next validate that the certificate is valid. This checks whether the certificate # is self-signed, that the chain of trust makes sense, that it is signed by a CA @@ -627,7 +624,8 @@ def load_cert_chain(pemfile): pem = f.read() + b"\n" # ensure trailing newline pemblocks = re.findall(re_pem, pem) if len(pemblocks) == 0: - raise ValueError("File does not contain valid PEM data.") + msg = "File does not contain valid PEM data." + raise ValueError(msg) return pemblocks def load_pem(pem): @@ -638,9 +636,10 @@ def load_pem(pem): from cryptography.hazmat.backends import default_backend pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem) if pem_type is None: - raise ValueError("File is not a valid PEM-formatted file.") + msg = "File is not a valid PEM-formatted file." + raise ValueError(msg) pem_type = pem_type.group(1) - if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"): + if pem_type in {b"RSA PRIVATE KEY", b"PRIVATE KEY"}: return serialization.load_pem_private_key(pem, password=None, backend=default_backend()) if pem_type == b"CERTIFICATE": return load_pem_x509_certificate(pem, default_backend()) diff --git a/management/status_checks.py b/management/status_checks.py index b4ee4f66..9a02a1f5 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -4,11 +4,10 @@ # TLS certificates have been signed, etc., and if not tells the user # what to do next. -import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool +import sys, os, os.path, re, datetime, multiprocessing.pool import asyncio import dns.reversename, dns.resolver -import dateutil.parser, dateutil.tz import idna import psutil import postfix_mta_sts_resolver.resolver @@ -18,7 +17,7 @@ from web_update import get_web_domains, get_domains_with_a_records from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate from mailconfig import get_mail_domains, get_mail_aliases -from utils import shell, sort_domains, load_env_vars_from_file, load_settings +from utils import shell, sort_domains, load_env_vars_from_file, load_settings, get_ssh_port, get_ssh_config_value def get_services(): return [ @@ -66,35 +65,23 @@ def run_checks(rounded_values, env, output, pool, domains_to_check=None): run_network_checks(env, output) run_domain_checks(rounded_values, env, output, pool, domains_to_check=domains_to_check) -def get_ssh_port(): - # Returns ssh port - try: - output = shell('check_output', ['sshd', '-T']) - except FileNotFoundError: - # sshd is not installed. That's ok. - return None - - returnNext = False - for e in output.split(): - if returnNext: - return int(e) - if e == "port": - returnNext = True - - # Did not find port! - return None - def run_services_checks(env, output, pool): # Check that system services are running. all_running = True fatal = False ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(get_services())), chunksize=1) - for i, running, fatal2, output2 in sorted(ret): + for _i, running, fatal2, output2 in sorted(ret): if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd) all_running = all_running and running fatal = fatal or fatal2 output2.playback(output) + # Check fail2ban. + code, ret = shell('check_output', ["fail2ban-client", "status"], capture_stderr=True, trap=True) + if code != 0: + output.print_error("fail2ban is not running.") + all_running = False + if all_running: output.print_ok("All system services are running.") @@ -119,7 +106,7 @@ def check_service(i, service, env): try: s.connect((ip, service["port"])) return True - except OSError as e: + except OSError: # timed out or some other odd error return False finally: @@ -146,18 +133,17 @@ def check_service(i, service, env): output.print_error("%s is not running (port %d)." % (service['name'], service['port'])) # Why is nginx not running? - if not running and service["port"] in (80, 443): + if not running and service["port"] in {80, 443}: output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip()) + # Service should be running locally. + elif try_connect("127.0.0.1"): + running = True else: - # Service should be running locally. - if try_connect("127.0.0.1"): - running = True - else: - output.print_error("%s is not running (port %d)." % (service['name'], service['port'])) + output.print_error("%s is not running (port %d)." % (service['name'], service['port'])) # Flag if local DNS is not running. - if not running and service["port"] == 53 and service["public"] == False: + if not running and service["port"] == 53 and service["public"] is False: fatal = True return (i, running, fatal, output) @@ -189,7 +175,7 @@ def check_ufw(env, output): for service in get_services(): if service["public"] and not is_port_allowed(ufw, service["port"]): not_allowed_ports += 1 - output.print_error("Port %s (%s) should be allowed in the firewall, please re-run the setup." % (service["port"], service["name"])) + output.print_error("Port {} ({}) should be allowed in the firewall, please re-run the setup.".format(service["port"], service["name"])) if not_allowed_ports == 0: output.print_ok("Firewall is active.") @@ -202,20 +188,15 @@ def is_port_allowed(ufw, port): return any(re.match(str(port) +"[/ \t].*", item) for item in ufw) def check_ssh_password(env, output): - # Check that SSH login with password is disabled. The openssh-server - # package may not be installed so check that before trying to access - # the configuration file. - if not os.path.exists("/etc/ssh/sshd_config"): - return - sshd = open("/etc/ssh/sshd_config").read() - if re.search("\nPasswordAuthentication\s+yes", sshd) \ - or not re.search("\nPasswordAuthentication\s+no", sshd): - output.print_error("""The SSH server on this machine permits password-based login. A more secure - way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check - that you can log in without a password, set the option 'PasswordAuthentication no' in - /etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""") - else: - output.print_ok("SSH disallows password-based login.") + config_value = get_ssh_config_value("passwordauthentication") + if config_value: + if config_value == "no": + output.print_ok("SSH disallows password-based login.") + else: + output.print_error("""The SSH server on this machine permits password-based login. A more secure + way to log in is using a public key. Add your SSH public key to $HOME/.ssh/authorized_keys, check + that you can log in without a password, set the option 'PasswordAuthentication no' in + /etc/ssh/sshd_config, and then restart the openssh via 'sudo service ssh restart'.""") def is_reboot_needed_due_to_package_installation(): return os.path.exists("/var/run/reboot-required") @@ -230,7 +211,7 @@ def check_software_updates(env, output): else: output.print_error("There are %d software packages that can be updated." % len(pkgs)) for p in pkgs: - output.print_line("%s (%s)" % (p["package"], p["version"])) + output.print_line("{} ({})".format(p["package"], p["version"])) def check_system_aliases(env, output): # Check that the administrator alias exists since that's where all @@ -262,8 +243,7 @@ def check_free_disk_space(rounded_values, env, output): except: backup_cache_count = 0 if backup_cache_count > 1: - output.print_warning("The backup cache directory {} has more than one backup target cache. Consider clearing this directory to save disk space." - .format(backup_cache_path)) + output.print_warning(f"The backup cache directory {backup_cache_path} has more than one backup target cache. Consider clearing this directory to save disk space.") def check_free_memory(rounded_values, env, output): # Check free memory. @@ -289,7 +269,7 @@ def run_network_checks(env, output): # Stop if we cannot make an outbound connection on port 25. Many residential # networks block outbound port 25 to prevent their network from sending spam. # See if we can reach one of Google's MTAs with a 5-second timeout. - code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True) + _code, ret = shell("check_call", ["/bin/nc", "-z", "-w5", "aspmx.l.google.com", "25"], trap=True) if ret == 0: output.print_ok("Outbound mail (SMTP port 25) is not blocked.") else: @@ -302,16 +282,26 @@ def run_network_checks(env, output): # The user might have ended up on an IP address that was previously in use # by a spammer, or the user may be deploying on a residential network. We # will not be able to reliably send mail in these cases. + + # See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for + # information on spamhaus return codes rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.'))) zen = query_dns(rev_ip4+'.zen.spamhaus.org', 'A', nxdomain=None) if zen is None: output.print_ok("IP address is not blacklisted by zen.spamhaus.org.") elif zen == "[timeout]": - output.print_warning("Connection to zen.spamhaus.org timed out. We could not determine whether your server's IP address is blacklisted. Please try again later.") + output.print_warning("Connection to zen.spamhaus.org timed out. Could not determine whether this box's IP address is blacklisted. Please try again later.") + elif zen == "[Not Set]": + output.print_warning("Could not connect to zen.spamhaus.org. Could not determine whether this box's IP address is blacklisted. Please try again later.") + elif zen == "127.255.255.252": + output.print_warning("Incorrect spamhaus query: %s. Could not determine whether this box's IP address is blacklisted." % (rev_ip4+'.zen.spamhaus.org')) + elif zen == "127.255.255.254": + output.print_warning("Mail-in-a-Box is configured to use a public DNS server. This is not supported by spamhaus. Could not determine whether this box's IP address is blacklisted.") + elif zen == "127.255.255.255": + output.print_warning("Too many queries have been performed on the spamhaus server. Could not determine whether this box's IP address is blacklisted.") else: - output.print_error("""The IP address of this machine %s is listed in the Spamhaus Block List (code %s), - which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/%s.""" - % (env['PUBLIC_IP'], zen, env['PUBLIC_IP'])) + output.print_error("""The IP address of this machine {} is listed in the Spamhaus Block List (code {}), + which may prevent recipients from receiving your email. See http://www.spamhaus.org/query/ip/{}.""".format(env['PUBLIC_IP'], zen, env['PUBLIC_IP'])) def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None): # Get the list of domains we handle mail for. @@ -332,7 +322,7 @@ def run_domain_checks(rounded_time, env, output, pool, domains_to_check=None): domains_to_check = [ d for d in domains_to_check if not ( - d.split(".", 1)[0] in ("www", "autoconfig", "autodiscover", "mta-sts") + d.split(".", 1)[0] in {"www", "autoconfig", "autodiscover", "mta-sts"} and len(d.split(".", 1)) == 2 and d.split(".", 1)[1] in domains_to_check ) @@ -414,10 +404,9 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): # If a DS record is set on the zone containing this domain, check DNSSEC now. has_dnssec = False for zone in dns_domains: - if zone == domain or domain.endswith("." + zone): - if query_dns(zone, "DS", nxdomain=None) is not None: - has_dnssec = True - check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True) + if (zone == domain or domain.endswith("." + zone)) and query_dns(zone, "DS", nxdomain=None) is not None: + has_dnssec = True + check_dnssec(zone, env, output, dns_zonefiles, is_checking_primary=True) ip = query_dns(domain, "A") ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A") @@ -429,44 +418,41 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): # the nameserver, are reporting the right info --- but if the glue is incorrect this # will probably fail. if ns_ips == env['PUBLIC_IP'] + '/' + env['PUBLIC_IP']: - output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) + output.print_ok("Nameserver glue records are correct at registrar. [ns1/ns2.{} ↦ {}]".format(env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'])) elif ip == env['PUBLIC_IP']: # The NS records are not what we expect, but the domain resolves correctly, so # the user may have set up external DNS. List this discrepancy as a warning. - output.print_warning("""Nameserver glue records (ns1.%s and ns2.%s) should be configured at your domain name - registrar as having the IP address of this box (%s). They currently report addresses of %s. If you have set up External DNS, this may be OK.""" - % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips)) + output.print_warning("""Nameserver glue records (ns1.{} and ns2.{}) should be configured at your domain name + registrar as having the IP address of this box ({}). They currently report addresses of {}. If you have set up External DNS, this may be OK.""".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips)) else: - output.print_error("""Nameserver glue records are incorrect. The ns1.%s and ns2.%s nameservers must be configured at your domain name - registrar as having the IP address %s. They currently report addresses of %s. It may take several hours for - public DNS to update after a change.""" - % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips)) + output.print_error("""Nameserver glue records are incorrect. The ns1.{} and ns2.{} nameservers must be configured at your domain name + registrar as having the IP address {}. They currently report addresses of {}. It may take several hours for + public DNS to update after a change.""".format(env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips)) # Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP[V6] in public DNS. ipv6 = query_dns(domain, "AAAA") if env.get("PUBLIC_IPV6") else None if ip == env['PUBLIC_IP'] and not (ipv6 and env['PUBLIC_IPV6'] and ipv6 != normalize_ip(env['PUBLIC_IPV6'])): - output.print_ok("Domain resolves to box's IP address. [%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], my_ips)) + output.print_ok("Domain resolves to box's IP address. [{} ↦ {}]".format(env['PRIMARY_HOSTNAME'], my_ips)) else: - output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves - to %s. It may take several hours for public DNS to update after a change. This problem may result from other - issues listed above.""" - % (my_ips, ip + ((" / " + ipv6) if ipv6 is not None else ""))) + output.print_error("""This domain must resolve to this box's IP address ({}) in public DNS but it currently resolves + to {}. It may take several hours for public DNS to update after a change. This problem may result from other + issues listed above.""".format(my_ips, ip + ((" / " + ipv6) if ipv6 is not None else ""))) # Check reverse DNS matches the PRIMARY_HOSTNAME. Note that it might not be # a DNS zone if it is a subdomain of another domain we have a zone for. existing_rdns_v4 = query_dns(dns.reversename.from_address(env['PUBLIC_IP']), "PTR") existing_rdns_v6 = query_dns(dns.reversename.from_address(env['PUBLIC_IPV6']), "PTR") if env.get("PUBLIC_IPV6") else None - if existing_rdns_v4 == domain and existing_rdns_v6 in (None, domain): - output.print_ok("Reverse DNS is set correctly at ISP. [%s ↦ %s]" % (my_ips, env['PRIMARY_HOSTNAME'])) + if existing_rdns_v4 == domain and existing_rdns_v6 in {None, domain}: + output.print_ok("Reverse DNS is set correctly at ISP. [{} ↦ {}]".format(my_ips, env['PRIMARY_HOSTNAME'])) elif existing_rdns_v4 == existing_rdns_v6 or existing_rdns_v6 is None: - output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions - on setting up reverse DNS for your box.""" % (existing_rdns_v4, domain) ) + output.print_error(f"""This box's reverse DNS is currently {existing_rdns_v4}, but it should be {domain}. Your ISP or cloud provider will have instructions + on setting up reverse DNS for this box.""" ) else: - output.print_error("""Your box's reverse DNS is currently %s (IPv4) and %s (IPv6), but it should be %s. Your ISP or cloud provider will have instructions - on setting up reverse DNS for your box.""" % (existing_rdns_v4, existing_rdns_v6, domain) ) + output.print_error(f"""This box's reverse DNS is currently {existing_rdns_v4} (IPv4) and {existing_rdns_v6} (IPv6), but it should be {domain}. Your ISP or cloud provider will have instructions + on setting up reverse DNS for this box.""" ) # Check the TLSA record. tlsa_qname = "_25._tcp." + domain @@ -480,18 +466,17 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): # since TLSA shouldn't be used without DNSSEC. output.print_warning("""The DANE TLSA record for incoming mail is not set. This is optional.""") else: - output.print_error("""The DANE TLSA record for incoming mail (%s) is not correct. It is '%s' but it should be '%s'. - It may take several hours for public DNS to update after a change.""" - % (tlsa_qname, tlsa25, tlsa25_expected)) + output.print_error(f"""The DANE TLSA record for incoming mail ({tlsa_qname}) is not correct. It is '{tlsa25}' but it should be '{tlsa25_expected}'. + It may take several hours for public DNS to update after a change.""") # Check that the hostmaster@ email address exists. check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output) def check_alias_exists(alias_name, alias, env, output): - mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)]) + mail_aliases = {address: receivers for address, receivers, *_ in get_mail_aliases(env)} if alias in mail_aliases: if mail_aliases[alias]: - output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias])) + output.print_ok(f"{alias_name} exists as a mail alias. [{alias} ↦ {mail_aliases[alias]}]") else: output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias) else: @@ -517,7 +502,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles): secondary_ns = custom_secondary_ns or ["ns2." + env['PRIMARY_HOSTNAME']] existing_ns = query_dns(domain, "NS") - correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns)) + correct_ns = "; ".join(sorted(["ns1." + env["PRIMARY_HOSTNAME"], *secondary_ns])) ip = query_dns(domain, "A") probably_external_dns = False @@ -526,14 +511,12 @@ def check_dns_zone(domain, env, output, dns_zonefiles): output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns) elif ip == correct_ip: # The domain resolves correctly, so maybe the user is using External DNS. - output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s. - If you are using External DNS, this may be OK.""" - % (correct_ns, existing_ns) ) + output.print_warning(f"""The nameservers set on this domain at your domain name registrar should be {correct_ns}. They are currently {existing_ns}. + If you are using External DNS, this may be OK.""" ) probably_external_dns = True else: - output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's - control panel to set the nameservers to %s.""" - % (existing_ns, correct_ns) ) + output.print_error(f"""The nameservers set on this domain are incorrect. They are currently {existing_ns}. Use your domain name registrar's + control panel to set the nameservers to {correct_ns}.""" ) # Check that each custom secondary nameserver resolves the IP address. @@ -541,7 +524,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles): for ns in custom_secondary_ns: # We must first resolve the nameserver to an IP address so we can query it. ns_ips = query_dns(ns, "A") - if not ns_ips: + if not ns_ips or ns_ips in {'[Not Set]', '[timeout]'}: output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns) continue # Choose the first IP if nameserver returns multiple @@ -554,7 +537,7 @@ def check_dns_zone(domain, env, output, dns_zonefiles): elif ip is None: output.print_error("Secondary nameserver %s is not configured to resolve this domain." % ns) else: - output.print_error("Secondary nameserver %s is not configured correctly. (It resolved this domain as %s. It should be %s.)" % (ns, ip, correct_ip)) + output.print_error(f"Secondary nameserver {ns} is not configured correctly. (It resolved this domain as {ip}. It should be {correct_ip}.)") def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records): # Warn if a custom DNS record is preventing this or the automatic www redirect from @@ -583,7 +566,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False): expected_ds_records = { } ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds' if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated. - with open(ds_file) as f: + with open(ds_file, encoding="utf-8") as f: for rr_ds in f: rr_ds = rr_ds.rstrip() ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ") @@ -592,7 +575,8 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False): # record that we suggest using is for the KSK (and that's how the DS records were generated). # We'll also give the nice name for the key algorithm. dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % alg_name_map[ds_alg])) - dnsssec_pubkey = open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key')).read().split("\t")[3].split(" ")[3] + with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), encoding="utf-8") as f: + dnsssec_pubkey = f.read().split("\t")[3].split(" ")[3] expected_ds_records[ (ds_keytag, ds_alg, ds_digalg, ds_digest) ] = { "record": rr_ds, @@ -624,10 +608,10 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False): # # But it may not be preferred. Only algorithm 13 is preferred. Warn if any of the # matched zones uses a different algorithm. - if set(r[1] for r in matched_ds) == { '13' } and set(r[2] for r in matched_ds) <= { '2', '4' }: # all are alg 13 and digest type 2 or 4 + if {r[1] for r in matched_ds} == { '13' } and {r[2] for r in matched_ds} <= { '2', '4' }: # all are alg 13 and digest type 2 or 4 output.print_ok("DNSSEC 'DS' record is set correctly at registrar.") return - elif len([r for r in matched_ds if r[1] == '13' and r[2] in ( '2', '4' )]) > 0: # some but not all are alg 13 + elif len([r for r in matched_ds if r[1] == '13' and r[2] in { '2', '4' }]) > 0: # some but not all are alg 13 output.print_ok("DNSSEC 'DS' record is set correctly at registrar. (Records using algorithm other than ECDSAP256SHA256 and digest types other than SHA-256/384 should be removed.)") return else: # no record uses alg 13 @@ -659,8 +643,8 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False): output.print_line("----------") output.print_line("Key Tag: " + ds_suggestion['keytag']) output.print_line("Key Flags: KSK / 257") - output.print_line("Algorithm: %s / %s" % (ds_suggestion['alg'], ds_suggestion['alg_name'])) - output.print_line("Digest Type: %s / %s" % (ds_suggestion['digalg'], ds_suggestion['digalg_name'])) + output.print_line("Algorithm: {} / {}".format(ds_suggestion['alg'], ds_suggestion['alg_name'])) + output.print_line("Digest Type: {} / {}".format(ds_suggestion['digalg'], ds_suggestion['digalg_name'])) output.print_line("Digest: " + ds_suggestion['digest']) output.print_line("Public Key: ") output.print_line(ds_suggestion['pubkey'], monospace=True) @@ -671,7 +655,7 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False): output.print_line("") output.print_line("The DS record is currently set to:") for rr in sorted(ds): - output.print_line("Key Tag: {0}, Algorithm: {1}, Digest Type: {2}, Digest: {3}".format(*rr)) + output.print_line("Key Tag: {}, Algorithm: {}, Digest Type: {}, Digest: {}".format(*rr)) def check_mail_domain(domain, env, output): # Check the MX record. @@ -679,21 +663,19 @@ def check_mail_domain(domain, env, output): recommended_mx = "10 " + env['PRIMARY_HOSTNAME'] mx = query_dns(domain, "MX", nxdomain=None) - if mx is None: - mxhost = None - elif mx == "[timeout]": + if mx is None or mx == "[timeout]": mxhost = None else: # query_dns returns a semicolon-delimited list # of priority-host pairs. mxhost = mx.split('; ')[0].split(' ')[1] - if mxhost == None: + if mxhost is None: # A missing MX record is okay on the primary hostname because # the primary hostname's A record (the MX fallback) is... itself, # which is what we want the MX to be. if domain == env['PRIMARY_HOSTNAME']: - output.print_ok("Domain's email is directed to this domain. [%s has no MX record, which is ok]" % (domain,)) + output.print_ok(f"Domain's email is directed to this domain. [{domain} has no MX record, which is ok]") # And a missing MX record is okay on other domains if the A record # matches the A record of the PRIMARY_HOSTNAME. Actually this will @@ -701,35 +683,35 @@ def check_mail_domain(domain, env, output): else: domain_a = query_dns(domain, "A", nxdomain=None) primary_a = query_dns(env['PRIMARY_HOSTNAME'], "A", nxdomain=None) - if domain_a != None and domain_a == primary_a: - output.print_ok("Domain's email is directed to this domain. [%s has no MX record but its A record is OK]" % (domain,)) + if domain_a is not None and domain_a == primary_a: + output.print_ok(f"Domain's email is directed to this domain. [{domain} has no MX record but its A record is OK]") else: - output.print_error("""This domain's DNS MX record is not set. It should be '%s'. Mail will not + output.print_error(f"""This domain's DNS MX record is not set. It should be '{recommended_mx}'. Mail will not be delivered to this box. It may take several hours for public DNS to update after a - change. This problem may result from other issues listed here.""" % (recommended_mx,)) + change. This problem may result from other issues listed here.""") elif mxhost == env['PRIMARY_HOSTNAME']: - good_news = "Domain's email is directed to this domain. [%s ↦ %s]" % (domain, mx) + good_news = f"Domain's email is directed to this domain. [{domain} ↦ {mx}]" if mx != recommended_mx: - good_news += " This configuration is non-standard. The recommended configuration is '%s'." % (recommended_mx,) + good_news += f" This configuration is non-standard. The recommended configuration is '{recommended_mx}'." output.print_ok(good_news) # Check MTA-STS policy. - loop = asyncio.get_event_loop() + loop = asyncio.new_event_loop() sts_resolver = postfix_mta_sts_resolver.resolver.STSResolver(loop=loop) valid, policy = loop.run_until_complete(sts_resolver.resolve(domain)) if valid == postfix_mta_sts_resolver.resolver.STSFetchResult.VALID: if policy[1].get("mx") == [env['PRIMARY_HOSTNAME']] and policy[1].get("mode") == "enforce": # policy[0] is the policyid output.print_ok("MTA-STS policy is present.") else: - output.print_error("MTA-STS policy is present but has unexpected settings. [{}]".format(policy[1])) + output.print_error(f"MTA-STS policy is present but has unexpected settings. [{policy[1]}]") else: - output.print_error("MTA-STS policy is missing: {}".format(valid)) + output.print_error(f"MTA-STS policy is missing: {valid}") else: - output.print_error("""This domain's DNS MX record is incorrect. It is currently set to '%s' but should be '%s'. Mail will not + output.print_error(f"""This domain's DNS MX record is incorrect. It is currently set to '{mx}' but should be '{recommended_mx}'. Mail will not be delivered to this box. It may take several hours for public DNS to update after a change. This problem may result from - other issues listed here.""" % (mx, recommended_mx)) + other issues listed here.""") # Check that the postmaster@ email address exists. Not required if the domain has a # catch-all address or domain alias. @@ -739,15 +721,26 @@ def check_mail_domain(domain, env, output): # Stop if the domain is listed in the Spamhaus Domain Block List. # The user might have chosen a domain that was previously in use by a spammer # and will not be able to reliably send mail. + + # See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for + # information on spamhaus return codes dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None) if dbl is None: output.print_ok("Domain is not blacklisted by dbl.spamhaus.org.") elif dbl == "[timeout]": - output.print_warning("Connection to dbl.spamhaus.org timed out. We could not determine whether the domain {} is blacklisted. Please try again later.".format(domain)) + output.print_warning(f"Connection to dbl.spamhaus.org timed out. Could not determine whether the domain {domain} is blacklisted. Please try again later.") + elif dbl == "[Not Set]": + output.print_warning(f"Could not connect to dbl.spamhaus.org. Could not determine whether the domain {domain} is blacklisted. Please try again later.") + elif dbl == "127.255.255.252": + output.print_warning("Incorrect spamhaus query: %s. Could not determine whether the domain %s is blacklisted." % (domain+'.dbl.spamhaus.org', domain)) + elif dbl == "127.255.255.254": + output.print_warning("Mail-in-a-Box is configured to use a public DNS server. This is not supported by spamhaus. Could not determine whether the domain {} is blacklisted.".format(domain)) + elif dbl == "127.255.255.255": + output.print_warning("Too many queries have been performed on the spamhaus server. Could not determine whether the domain {} is blacklisted.".format(domain)) else: - output.print_error("""This domain is listed in the Spamhaus Domain Block List (code %s), + output.print_error(f"""This domain is listed in the Spamhaus Domain Block List (code {dbl}), which may prevent recipients from receiving your mail. - See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain)) + See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/{domain}.""") def check_web_domain(domain, rounded_time, ssl_certificates, env, output): # See if the domain's A record resolves to our PUBLIC_IP. This is already checked @@ -761,13 +754,13 @@ def check_web_domain(domain, rounded_time, ssl_certificates, env, output): if value == normalize_ip(expected): ok_values.append(value) else: - output.print_error("""This domain should resolve to your box's IP address (%s %s) if you would like the box to serve - webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for - public DNS to update after a change. This problem may result from other issues listed here.""" % (rtype, expected, value)) + output.print_error(f"""This domain should resolve to this box's IP address ({rtype} {expected}) if you would like the box to serve + webmail or a website on this domain. The domain currently resolves to {value} in public DNS. It may take several hours for + public DNS to update after a change. This problem may result from other issues listed here.""") return # If both A and AAAA are correct... - output.print_ok("Domain resolves to this box's IP address. [%s ↦ %s]" % (domain, '; '.join(ok_values))) + output.print_ok("Domain resolves to this box's IP address. [{} ↦ {}]".format(domain, '; '.join(ok_values))) # We need a TLS certificate for PRIMARY_HOSTNAME because that's where the @@ -788,16 +781,21 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False): # running bind server), or if the 'at' argument is specified, use that host # as the nameserver. resolver = dns.resolver.get_default_resolver() - if at: + + # Make sure at is not a string that cannot be used as a nameserver + if at and at not in {'[Not set]', '[timeout]'}: resolver = dns.resolver.Resolver() resolver.nameservers = [at] # Set a timeout so that a non-responsive server doesn't hold us back. resolver.timeout = 5 + # The number of seconds to spend trying to get an answer to the question. If the + # lifetime expires a dns.exception.Timeout exception will be raised. + resolver.lifetime = 5 # Do the query. try: - response = resolver.query(qname, rtype) + response = resolver.resolve(qname, rtype) except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): # Host did not have an answer for this query; not sure what the # difference is between the two exceptions. @@ -809,7 +807,7 @@ def query_dns(qname, rtype, nxdomain='[Not Set]', at=None, as_list=False): # be expressed in equivalent string forms. Canonicalize the form before # returning them. The caller should normalize any IP addresses the result # of this method is compared with. - if rtype in ("A", "AAAA"): + if rtype in {"A", "AAAA"}: response = [normalize_ip(str(r)) for r in response] if as_list: @@ -825,7 +823,7 @@ def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output): # Check that TLS certificate is signed. # Skip the check if the A record is not pointed here. - if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return + if query_dns(domain, "A", None) not in {env['PUBLIC_IP'], None}: return # Where is the certificate file stored? tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True) @@ -895,22 +893,20 @@ def list_apt_updates(apt_update=True): return pkgs def what_version_is_this(env): - # This function runs `git describe --abbrev=0` on the Mail-in-a-Box installation directory. + # This function runs `git describe --always --abbrev=0` on the Mail-in-a-Box installation directory. # Git may not be installed and Mail-in-a-Box may not have been cloned from github, # so this function may raise all sorts of exceptions. miab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - tag = shell("check_output", ["/usr/bin/git", "describe", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip() - return tag + return shell("check_output", ["/usr/bin/git", "describe", "--always", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip() def get_latest_miab_version(): # This pings https://mailinabox.email/setup.sh and extracts the tag named in # the script to determine the current product version. from urllib.request import urlopen, HTTPError, URLError - from socket import timeout try: - return re.search(b'TAG=(.*)', urlopen("https://raw.githubusercontent.com/jrsupplee/mailinabox/master/setup/bootstrap.sh", timeout=5).read()).group(1).decode("utf8") - except (HTTPError, URLError, timeout): + return re.search(b'TAG=(.*)', urlopen("https://mailinabox.email/setup.sh?ping=1", timeout=5).read()).group(1).decode("utf8") + except (TimeoutError, HTTPError, URLError): return None def check_miab_version(env, output): @@ -931,8 +927,7 @@ def check_miab_version(env, output): elif latest_ver is None: output.print_error("Latest Mail-in-a-Box version could not be determined. You are running version %s." % this_ver) else: - output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://github.com/jrsupplee/mailinabox/blob/master/README.md. " - % (this_ver, latest_ver)) + output.print_error(f"A new version of Mail-in-a-Box is available. You are running version {this_ver}. The latest version is {latest_ver}. For upgrade instructions, see https://mailinabox.email. ") def run_and_output_changes(env, pool): import json @@ -947,7 +942,11 @@ def run_and_output_changes(env, pool): # Load previously saved status checks. cache_fn = "/var/cache/mailinabox/status_checks.json" if os.path.exists(cache_fn): - prev = json.load(open(cache_fn)) + with open(cache_fn, encoding="utf-8") as f: + try: + prev = json.load(f) + except json.JSONDecodeError: + prev = [] # Group the serial output into categories by the headings. def group_by_heading(lines): @@ -982,14 +981,14 @@ def run_and_output_changes(env, pool): out.add_heading(category + " -- Previously:") elif op == "delete": out.add_heading(category + " -- Removed") - if op in ("replace", "delete"): + if op in {"replace", "delete"}: BufferedOutput(with_lines=prev_lines[i1:i2]).playback(out) if op == "replace": out.add_heading(category + " -- Currently:") elif op == "insert": out.add_heading(category + " -- Added") - if op in ("replace", "insert"): + if op in {"replace", "insert"}: BufferedOutput(with_lines=cur_lines[j1:j2]).playback(out) for category, prev_lines in prev_status.items(): @@ -999,7 +998,7 @@ def run_and_output_changes(env, pool): # Store the current status checks output for next time. os.makedirs(os.path.dirname(cache_fn), exist_ok=True) - with open(cache_fn, "w") as f: + with open(cache_fn, "w", encoding="utf-8") as f: json.dump(cur.buf, f, indent=True) def normalize_ip(ip): @@ -1033,8 +1032,8 @@ class FileOutput: def print_block(self, message, first_line=" "): print(first_line, end='', file=self.buf) - message = re.sub("\n\s*", " ", message) - words = re.split("(\s+)", message) + message = re.sub("\n\\s*", " ", message) + words = re.split(r"(\s+)", message) linelen = 0 for w in words: if self.width and (linelen + len(w) > self.width-1-len(first_line)): @@ -1073,9 +1072,9 @@ class ConsoleOutput(FileOutput): class BufferedOutput: # Record all of the instance method calls so we can play them back later. def __init__(self, with_lines=None): - self.buf = [] if not with_lines else with_lines + self.buf = with_lines if with_lines else [] def __getattr__(self, attr): - if attr not in ("add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"): + if attr not in {"add_heading", "print_ok", "print_error", "print_warning", "print_block", "print_line"}: raise AttributeError # Return a function that just records the call & arguments to our buffer. def w(*args, **kwargs): diff --git a/management/templates/aliases.html b/management/templates/aliases.html index c2a141f7..6c8b0376 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -7,7 +7,7 @@

Add a mail alias

-

Aliases are email forwarders. An alias can forward email to a mail user or to any email address.

+

Aliases are email forwarders. An alias can forward email to a mail user or to any email address.

To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.

diff --git a/management/templates/custom-dns.html b/management/templates/custom-dns.html index c59624eb..7d3b6b37 100644 --- a/management/templates/custom-dns.html +++ b/management/templates/custom-dns.html @@ -77,7 +77,7 @@

Using a secondary nameserver

-

If your TLD requires you to have two separate nameservers, you can either set up external DNS and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka “slave”) nameserver.

+

If your TLD requires you to have two separate nameservers, you can either set up external DNS and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka “slave”) nameserver.

If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of their secondary nameserver in the box below.

@@ -96,7 +96,7 @@

Multiple secondary servers can be separated with commas or spaces (i.e., ns2.hostingcompany.com ns3.hostingcompany.com). - To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using xfr:10.20.30.40 or xfr:10.0.0.0/8. + To enable zone transfers to additional servers without listing them as secondary nameservers, prefix a hostname, IP address, or subnet with xfr:, e.g. xfr:10.20.30.40 or xfr:10.0.0.0/8.