1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2024-11-23 02:27:05 +00:00

merge upstream

This commit is contained in:
KiekerJan 2024-07-23 20:02:45 +02:00
commit 5cd36d23dc
62 changed files with 959 additions and 857 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ externals/
.env
.vagrant
api/docs/api-docs.html
*.code-workspace

View File

@ -1,6 +1,66 @@
CHANGELOG
=========
Version 69 (July 20, 2024)
--------------------------
Package updates:
* Nextcloud is updated to 26.0.13.
* Z-Push is updated to 2.7.3.
Other updates:
* Fixed an error generating the weekly statistics.
* Fixed file permissions when setting up Nextcloud.
* Added an undocumented option to proxy websockets.
* Internal improvements to the code to make it more reliable and readable.
Version 69a (July 21, 2024) and 69b (July 23, 2024) correct setup failures.
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)
------------------------------
@ -144,7 +204,7 @@ Other:
* Set systemd journald log retention to 10 days (from no limit) to reduce disk usage.
* Fixed log processing for submission lines that have a sasl_sender or other extra information.
* Fix DNS secondary nameserver refesh failure retry period.
* Fix DNS secondary nameserver refresh failure retry period.
Version 55 (October 18, 2021)
-----------------------------
@ -169,7 +229,7 @@ Control panel:
Other:
* Fail2ban's IPv6 support is enabled.
* The mail log tool now doesn't crash if there are email addresess in log messages with invalid UTF-8 characters.
* The mail log tool now doesn't crash if there are email addresses in log messages with invalid UTF-8 characters.
* Additional nsd.conf files can be placed in /etc/nsd.conf.d.
v0.54 (June 20, 2021)
@ -202,7 +262,7 @@ Setup:
v0.53a (May 8, 2021)
--------------------
The download URL for Z-Push has been revised becaue the old URL stopped working.
The download URL for Z-Push has been revised because the old URL stopped working.
v0.53 (April 12, 2021)
----------------------
@ -421,7 +481,7 @@ Changes:
* Added support for S3-compatible backup services besides Amazon S3.
* Fixed the control panel login page to let LastPass save passwords.
* Fixed an error in the user privileges API.
* Silenced some spurrious messages.
* Silenced some spurious messages.
Software updates:
@ -485,7 +545,7 @@ Setup:
Control Panel:
* The users page now documents that passwords should only have ASCII characters to prevent character encoding mismaches between clients and the server.
* The users page now documents that passwords should only have ASCII characters to prevent character encoding mismatches between clients and the server.
* The users page no longer shows user mailbox sizes because this was extremely slow for very large mailboxes.
* The Mail-in-a-Box version is now shown in the system status checks even when the new-version check is disabled.
* The alises page now warns that alises should not be used to forward mail off of the box. Mail filters within Roundcube are better for that.
@ -813,7 +873,7 @@ v0.17c (April 1, 2016)
This update addresses some minor security concerns and some installation issues.
ownCoud:
ownCloud:
* Block web access to the configuration parameters (config.php). There is no immediate impact (see [#776](https://github.com/mail-in-a-box/mailinabox/pull/776)), although advanced users may want to take note.
@ -829,7 +889,7 @@ Control panel:
Setup:
* Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows.
* We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittant unavailability.
* We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittent unavailability.
v0.17b (March 1, 2016)
----------------------
@ -872,7 +932,7 @@ This update primarily adds automatic SSL (now "TLS") certificate provisioning fr
Control Panel:
* The SSL certificates (now referred to as "TLS ccertificates") page now supports provisioning free certificates from Let's Encrypt.
* The SSL certificates (now referred to as "TLS certificates") page now supports provisioning free certificates from Let's Encrypt.
* Report free memory usage.
* Fix a crash when the git directory is not checked out to a tag.
* When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6.
@ -965,7 +1025,7 @@ Control panel:
System:
* Tweaks to fail2ban settings.
* Fixed a spurrious warning while installing munin.
* Fixed a spurious warning while installing munin.
v0.13b (August 30, 2015)
------------------------
@ -979,7 +1039,7 @@ Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud
Mail:
* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability.
* Outbound mail headers (the Received: header) are tweaked to possibly improve deliverability.
* Some MIME messages would hang Roundcube due to a missing package.
* The users permitted to send as an alias can now be different from where an alias forwards to.
@ -1011,7 +1071,7 @@ v0.12c was posted to work around the current Sourceforge.net outage: pyzor's rem
v0.12b (July 4, 2015)
---------------------
This version corrects a minor regression in v0.12 related to creating aliases targetting multiple addresses.
This version corrects a minor regression in v0.12 related to creating aliases targeting multiple addresses.
v0.12 (July 3, 2015)
--------------------

View File

@ -2,13 +2,13 @@
Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face.
We are committed to providing a safe, welcoming, and harrassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals.
We are committed to providing a safe, welcoming, and harassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals.
The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other.
## Scope
This Code of Conduct applies to all places where Mail-in-a-Box community activity is ocurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box.
This Code of Conduct applies to all places where Mail-in-a-Box community activity is occurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box.
This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large.

View File

@ -56,11 +56,11 @@ See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-fr
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:
Clone this repository and checkout the tag corresponding to the most recent release (which you can find in the tags or releases lists on GitHub):
$ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox
$ git checkout v64
$ git checkout TAGNAME
Begin the installation.

View File

@ -52,7 +52,7 @@ namespace inbox {
# dovevot's standard mailboxes configuration file marks two sent folders
# with the \Sent attribute, just in case clients don't agree about which
# they're using. We'll keep that, plus add Junk as an alterative for Spam.
# they're using. We'll keep that, plus add Junk as an alternative for Spam.
# These are not auto-created.
mailbox "Sent Messages" {
special_use = \Sent

View File

@ -3,5 +3,5 @@
before = common.conf
[Definition]
failregex=<HOST> - .*GET /admin/munin/.* HTTP/1.1\" 401.*
failregex=<HOST> - .*GET /admin/munin/.* HTTP/\d+\.\d+\" 401.*
ignoreregex =

View File

@ -74,7 +74,7 @@ action = iptables-allports[name=recidive]
# The last line on the action will sent an email to the configured address. This mail will
# notify the administrator that someone has been repeatedly triggering one of the other jails.
# By default we don't configure this address and no action is required from the admin anyway.
# So the notification is ommited. This will prevent message appearing in the mail.log that mail
# So the notification is omitted. This will prevent message appearing in the mail.log that mail
# can't be delivered to fail2ban@$HOSTNAME.
[postfix-sasl]

View File

@ -37,7 +37,7 @@
return 403;
}
location ~ /mail/.*\.php {
# note: ~ has precendence over a regular location block
# note: ~ has precedence over a regular location block
include fastcgi_params;
fastcgi_split_path_info ^/mail(/.*)()$;
fastcgi_index index.php;

View File

@ -8,6 +8,7 @@
rewrite ^/admin/munin$ /admin/munin/ redirect;
location /admin/ {
proxy_pass http://127.0.0.1:10222/;
proxy_read_timeout 600s;
proxy_set_header X-Forwarded-For $remote_addr;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options nosniff;
@ -38,7 +39,7 @@
}
}
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
# note: ~ has precendence over a regular location block
# note: ~ has precedence over a regular location block
# Accept URLs like:
# /cloud/index.php/apps/files/
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)

View File

@ -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,7 +22,7 @@ class AuthService:
def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API"""
with open(self.key_path, 'r') as file:
with open(self.key_path, encoding='utf-8') as file:
self.key = file.read()
def authenticate(self, request, env, login_only=False, logout=False):
@ -48,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.
@ -72,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
@ -114,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)

View File

@ -7,7 +7,7 @@
# 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
@ -15,7 +15,7 @@ from exclusiveprocess import Lock
from utils import load_environment, shell, wait_for_service, get_php_version
def backup_status(env):
# If backups are dissbled, return no status.
# If backups are disabled, return no status.
config = get_backup_config(env)
if config["target"] == "off":
return { }
@ -59,7 +59,7 @@ def backup_status(env):
"--archive-dir", backup_cache_dir,
"--gpg-options", "'--cipher-algo=AES256'",
"--log-fd", "1",
] + get_duplicity_additional_args(env) + [
*get_duplicity_additional_args(env),
get_duplicity_target_url(config)
],
get_duplicity_env_vars(env),
@ -69,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
@ -185,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!")
@ -257,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()
@ -324,8 +323,8 @@ def perform_backup(full_backup):
"--exclude", backup_root,
"--volsize", "250",
"--gpg-options", "'--cipher-algo=AES256'",
"--allow-source-mismatch"
] + get_duplicity_additional_args(env) + [
"--allow-source-mismatch",
*get_duplicity_additional_args(env),
env["STORAGE_ROOT"],
get_duplicity_target_url(config),
],
@ -346,7 +345,7 @@ def perform_backup(full_backup):
"--verbosity", "error",
"--archive-dir", backup_cache_dir,
"--force",
] + get_duplicity_additional_args(env) + [
*get_duplicity_additional_args(env),
get_duplicity_target_url(config)
],
get_duplicity_env_vars(env))
@ -362,7 +361,7 @@ def perform_backup(full_backup):
"--verbosity", "error",
"--archive-dir", backup_cache_dir,
"--force",
] + get_duplicity_additional_args(env) + [
*get_duplicity_additional_args(env),
get_duplicity_target_url(config)
],
get_duplicity_env_vars(env))
@ -401,7 +400,7 @@ def run_duplicity_verification():
"--compare-data",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
] + get_duplicity_additional_args(env) + [
*get_duplicity_additional_args(env),
get_duplicity_target_url(config),
env["STORAGE_ROOT"],
], get_duplicity_env_vars(env))
@ -414,9 +413,9 @@ def run_duplicity_restore(args):
"/usr/bin/duplicity",
"restore",
"--archive-dir", backup_cache_dir,
] + get_duplicity_additional_args(env) + [
get_duplicity_target_url(config)
] + args,
*get_duplicity_additional_args(env),
get_duplicity_target_url(config),
*args],
get_duplicity_env_vars(env))
def print_duplicity_command():
@ -428,7 +427,7 @@ def print_duplicity_command():
print(f"export {k}={shlex.quote(v)}")
print("duplicity", "{command}", shlex.join([
"--archive-dir", backup_cache_dir,
] + get_duplicity_additional_args(env) + [
*get_duplicity_additional_args(env),
get_duplicity_target_url(config)
]))
@ -484,16 +483,17 @@ 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":
import boto3.s3
@ -508,7 +508,8 @@ def list_target_files(config):
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:
@ -535,8 +536,9 @@ def list_target_files(config):
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:
@ -557,7 +559,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)
@ -579,9 +581,9 @@ def get_backup_config(env, for_save=False, for_ui=False):
# Merge in anything written to custom.yaml.
try:
with open(os.path.join(backup_root, 'custom.yaml'), 'r') as f:
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
if not isinstance(custom_config, dict): raise ValueError # caught below
config.update(custom_config)
except:
pass
@ -605,18 +607,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):
with open(ssh_pub_key, 'r') as f:
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.
@ -625,7 +626,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.

View File

@ -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,7 @@ def read_password():
return first
def setup_key_auth(mgmt_uri):
with open('/var/lib/mailinabox/api.key', 'r') as f:
with open('/var/lib/mailinabox/api.key', encoding='utf-8') as f:
key = f.read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler()
@ -91,12 +90,9 @@ elif sys.argv[1] == "user" and len(sys.argv) == 2:
print("*", 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]
@ -109,11 +105,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":
@ -132,7 +125,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 }))

View File

@ -11,17 +11,18 @@
# 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
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 mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
import contextlib
env = utils.load_environment()
@ -29,14 +30,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]
@ -80,7 +79,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,
}
@ -90,7 +89,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:
@ -164,7 +163,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)
@ -173,8 +172,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" })
@ -355,9 +354,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.
@ -419,7 +418,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.
@ -491,7 +490,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)
@ -580,8 +579,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"])
@ -751,14 +749,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

View File

@ -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

View File

@ -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
# underscores, as well as asterisks 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
@ -194,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...
@ -212,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.
@ -223,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
@ -231,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.
@ -262,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."
@ -298,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; "):
@ -364,8 +358,8 @@ 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
@ -449,21 +443,16 @@ def build_sshfp_records():
# Get our local fingerprints by running ssh-keyscan. The output looks
# like the known_hosts file: hostname, keytype, fingerprint. The order
# of the output is arbitrary, so sort it to prevent spurrious updates
# of the output is arbitrary, so sort it to prevent spurious updates
# to the zone file (that trigger bumping the serial number). However,
# 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()
# 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"))
@ -471,7 +460,7 @@ def build_sshfp_records():
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
@ -516,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"
@ -534,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.
@ -550,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:
@ -569,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:
@ -593,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
@ -606,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()
########################################################################
@ -618,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.
@ -634,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
@ -674,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()
@ -704,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
@ -743,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",
@ -780,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
),
@ -789,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
),
}
@ -798,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
@ -815,9 +803,9 @@ def write_opendkim_tables(domains, env):
def get_custom_dns_config(env, only_real_records=False):
try:
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), 'r') as f:
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
if not isinstance(custom_dns, dict): raise ValueError # caught below
except:
return [ ]
@ -835,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):
@ -845,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:
@ -861,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)
@ -900,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):
@ -919,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:
@ -969,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
@ -979,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
@ -996,11 +984,11 @@ def get_secondary_dns(custom_dns, mode=None):
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
@ -1046,19 +1034,19 @@ def set_secondary_dns(hostnames, env):
if not item.startswith("xfr:"):
# Resolve hostname.
try:
response = resolver.resolve(item, "A")
resolver.resolve(item, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.Timeout):
try:
response = resolver.resolve(item, "AAAA")
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:])
@ -1076,13 +1064,12 @@ 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):
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]
@ -1091,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,
@ -1113,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")

View File

@ -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 = '<html><body><pre style="overflow-x: scroll; white-space: pre;">{}</pre></body></html>'.format(html.escape(content))
content_html = f'<html><body><pre style="overflow-x: scroll; white-space: pre;">{html.escape(content)}</pre></body></html>'
msg.attach(MIMEText(content, 'plain'))
msg.attach(MIMEText(content_html, 'html'))

View File

@ -116,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
@ -227,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()])
@ -303,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,8 +319,8 @@ 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("The following unknown services were found in the log file.")
print(" ", *sorted(collector["other-services"]), sep='\n')
def scan_mail_log_line(line, collector):
@ -333,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()
@ -376,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:
@ -392,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)
@ -435,8 +433,7 @@ 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"]:
if user_match(user) and (collector["known_addresses"] is None or user in collector["known_addresses"]):
data = collector["rejected"].get(
user,
{
@ -500,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
@ -514,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()
@ -552,10 +549,10 @@ 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
# follow after.
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log)
m = re.match(r"([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log)
if m:
_, client, method, user = m.groups()
_, client, _method, user = m.groups()
if user_match(user):
# Get the user data, or create it if the user is new
@ -588,7 +585,7 @@ def scan_postfix_submission_line(date, log, collector):
def readline(filename):
""" A generator that returns the lines of a file
"""
with open(filename, errors='replace') as file:
with open(filename, errors='replace', encoding='utf-8') as file:
while True:
line = file.readline()
if not line:
@ -622,10 +619,7 @@ def print_time_table(labels, data, do_print=True):
data.insert(0, [str(h) for h in range(24)])
temp = "{:<%d} " % max(len(l) for l in labels)
lines = []
for label in labels:
lines.append(temp.format(label))
lines = [temp.format(label) for label in labels]
for h in range(24):
max_len = max(len(str(d[h])) for d in data)
@ -639,6 +633,7 @@ def print_time_table(labels, data, do_print=True):
if do_print:
print("\n".join(lines))
return None
else:
return lines
@ -672,7 +667,7 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
col_str = str_temp.format(d[row][:31] + "" if len(d[row]) > 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)
@ -684,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]: # noqa PLR6201
vert_pos = len(line)
e = earliest[row]
l = latest[row]
@ -712,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)
@ -740,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]: # noqa PLR6201
header += " │ timespan "
lines.insert(0, header.rstrip())
@ -765,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]: # noqa PLR6201
max_l = max(latest)
min_e = min(earliest)
timespan = relativedelta(max_l, min_e)
@ -844,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]

View File

@ -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")
@ -192,8 +189,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
@ -225,7 +221,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": [],
@ -477,10 +473,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:
@ -498,6 +491,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
@ -513,10 +507,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()
@ -566,7 +561,7 @@ def kick(env, mail_result=None):
auto_aliases = { }
# Mape required aliases to the administrator alias (which should be created manually).
# Map required aliases to the administrator alias (which should be created manually).
administrator = get_system_administrator(env)
required_aliases = get_required_aliases(env)
for alias in required_aliases:
@ -586,14 +581,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.
@ -608,9 +603,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

View File

@ -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.

View File

@ -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
@ -83,8 +84,7 @@ def get_ssl_certificates(env):
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'):
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)
@ -150,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']:
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:
@ -212,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: " \
@ -265,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
@ -286,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.
@ -414,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.
@ -435,7 +434,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
cert_status += " " + cert_status_details
return cert_status
# Copy certifiate into ssl directory.
# Copy certificate into ssl directory.
install_cert_copy_file(fn, env)
# Run post-install steps.
@ -450,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
@ -522,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,7 +537,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
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)
@ -566,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
@ -625,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):
@ -636,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())

View File

@ -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,30 +65,12 @@ 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
@ -125,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:
@ -152,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())
else:
# Service should be running locally.
if try_connect("127.0.0.1"):
elif try_connect("127.0.0.1"):
running = True
else:
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)
@ -195,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.")
@ -208,21 +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
with open("/etc/ssh/sshd_config", "r") as f:
sshd = f.read()
if re.search("\nPasswordAuthentication\s+yes", sshd) \
or not re.search("\nPasswordAuthentication\s+no", sshd):
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'.""")
else:
output.print_ok("SSH disallows password-based login.")
def is_reboot_needed_due_to_package_installation():
return os.path.exists("/var/run/reboot-required")
@ -237,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
@ -269,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.
@ -296,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:
@ -309,18 +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. We could not determine whether your server's IP address is blacklisted. Please try again later.")
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.
@ -341,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
)
@ -423,8 +404,7 @@ 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:
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)
@ -438,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
@ -489,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:
@ -526,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
@ -535,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.
@ -563,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
@ -592,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(" ")
@ -601,7 +575,7 @@ 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]))
with open(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys['KSK'] + '.key'), 'r') as f:
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) ] = {
@ -634,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
@ -669,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)
@ -681,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.
@ -689,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
@ -711,17 +683,17 @@ 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.
@ -732,14 +704,14 @@ def check_mail_domain(domain, env, output):
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.
@ -749,17 +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("Could not connect to dbl.spamhaus.org. We could not determine whether the domain {} is blacklisted. Please try again later.".format(domain))
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
@ -773,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
@ -826,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:
@ -842,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)
@ -916,18 +897,16 @@ def what_version_is_this(env):
# 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", "--always", "--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://mailinabox.email/setup.sh?ping=1", timeout=5).read()).group(1).decode("utf8")
except (HTTPError, URLError, timeout):
except (TimeoutError, HTTPError, URLError):
return None
def check_miab_version(env, output):
@ -948,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://mailinabox.email. "
% (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
@ -964,8 +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):
with open(cache_fn, 'r') as f:
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):
@ -1000,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():
@ -1017,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):
@ -1051,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)):
@ -1091,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):

View File

@ -73,7 +73,7 @@
filter: invert(100%) hue-rotate(180deg);
}
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
/* Override Bootstrap theme here to give more contrast. The black turns to white by the filter. */
.form-control {
color: black !important;
}

View File

@ -16,7 +16,7 @@
<h4>Automatic configuration</h4>
<p>iOS and OS X only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p>
<p>iOS and macOS only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p>
<h4>Manual configuration</h4>

View File

@ -5,7 +5,7 @@
<h2>Backup Status</h2>
<p>The box makes an incremental backup each night. You can store the backup on any Amazone Web Services S3-compatible service, or other options.</p>
<p>The box makes an incremental backup each night. You can store the backup on any Amazon Web Services S3-compatible service, or other options.</p>
<h3>Configuration</h3>
@ -361,7 +361,7 @@ function init_inputs(target_type) {
}
// Return a two-element array of the substring preceding and the substring following
// the first occurence of separator in string. Return [undefined, string] if the
// the first occurrence of separator in string. Return [undefined, string] if the
// separator does not appear in string.
const split1_rest = (string, separator) => {
const index = string.indexOf(separator);

View File

@ -14,31 +14,31 @@ def load_env_vars_from_file(fn):
# Load settings from a KEY=VALUE file.
import collections
env = collections.OrderedDict()
with open(fn, 'r') as f:
with open(fn, encoding="utf-8") as f:
for line in f:
env.setdefault(*line.strip().split("=", 1))
return env
def save_environment(env):
with open("/etc/mailinabox.conf", "w") as f:
with open("/etc/mailinabox.conf", "w", encoding="utf-8") as f:
for k, v in env.items():
f.write("%s=%s\n" % (k, v))
f.write(f"{k}={v}\n")
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
def write_settings(config, env):
import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
with open(fn, "w") as f:
with open(fn, "w", encoding="utf-8") as f:
f.write(rtyaml.dump(config))
def load_settings(env):
import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
try:
with open(fn, "r") as f:
with open(fn, encoding="utf-8") as f:
config = rtyaml.load(f)
if not isinstance(config, dict): raise ValueError() # caught below
if not isinstance(config, dict): raise ValueError # caught below
return config
except:
return { }
@ -59,7 +59,7 @@ def sort_domains(domain_names, env):
# from shortest to longest since zones are always shorter than their
# subdomains.
zones = { }
for domain in sorted(domain_names, key=lambda d : len(d)):
for domain in sorted(domain_names, key=len):
for z in zones.values():
if domain.endswith("." + z):
# We found a parent domain already in the list.
@ -81,7 +81,7 @@ def sort_domains(domain_names, env):
))
# Now sort the domain names that fall within each zone.
domain_names = sorted(domain_names,
return sorted(domain_names,
key = lambda d : (
# First by zone.
zone_domains.index(zones[d]),
@ -96,24 +96,25 @@ def sort_domains(domain_names, env):
list(reversed(d.split("."))),
))
return domain_names
def sort_email_addresses(email_addresses, env):
email_addresses = set(email_addresses)
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email)
domains = {email.split("@", 1)[1] for email in email_addresses if "@" in email}
ret = []
for domain in sort_domains(domains, env):
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain))
domain_emails = {email for email in email_addresses if email.endswith("@" + domain)}
ret.extend(sorted(domain_emails))
email_addresses -= domain_emails
ret.extend(sorted(email_addresses)) # whatever is left
return ret
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
def shell(method, cmd_args, env=None, capture_stderr=False, return_bytes=False, trap=False, input=None):
# A safe way to execute processes.
# Some processes like apt-get require being given a sane PATH.
import subprocess
if env is None:
env = {}
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
kwargs = {
'env': env,
@ -149,7 +150,7 @@ def du(path):
# soft and hard links.
total_size = 0
seen = set()
for dirpath, dirnames, filenames in os.walk(path):
for dirpath, _dirnames, filenames in os.walk(path):
for f in filenames:
fp = os.path.join(dirpath, f)
try:
@ -182,6 +183,34 @@ def get_php_version():
# Gets the version of PHP installed in the system.
return shell("check_output", ["/usr/bin/php", "-v"])[4:7]
def get_ssh_port():
port_value = get_ssh_config_value("port")
if port_value:
return int(port_value)
return None
def get_ssh_config_value(parameter_name):
# Returns ssh configuration value for the provided parameter
try:
output = shell('check_output', ['sshd', '-T'])
except FileNotFoundError:
# sshd is not installed. That's ok.
return None
except subprocess.CalledProcessError:
# error while calling shell command
return None
for line in output.split("\n"):
if " " not in line: continue # there's a blank line at the end
key, values = line.split(" ", 1)
if key == parameter_name:
return values # space-delimited if there are multiple values
# Did not find the parameter!
return None
if __name__ == "__main__":
from web_update import get_web_domains
env = load_environment()

View File

@ -22,17 +22,17 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
# Add 'www.' subdomains that we want to provide default redirects
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
# the topmost of each domain we serve.
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
domains |= {'www.' + zone for zone, zonefile in get_dns_zones(env)}
if include_auto:
# Add Autoconfiguration domains for domains that there are user accounts at:
# 'autoconfig.' for Mozilla Thunderbird auto setup.
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
domains |= {'autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}
domains |= {'autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True)}
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
domains |= {'mta-sts.' + maildomain for maildomain in get_mail_domains(env)}
if exclude_dns_elsewhere:
# ...Unless the domain has an A/AAAA record that maps it to a different
@ -45,15 +45,14 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
domains.add(env['PRIMARY_HOSTNAME'])
# Sort the list so the nginx conf gets written in a stable order.
domains = sort_domains(domains, env)
return sort_domains(domains, env)
return domains
def get_domains_with_a_records(env):
domains = set()
dns = get_custom_dns_config(env)
for domain, rtype, value in dns:
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])):
if rtype == "CNAME" or (rtype in {"A", "AAAA"} and value not in {"local", env['PUBLIC_IP']}):
domains.add(domain)
return domains
@ -63,7 +62,7 @@ def get_web_domains_with_root_overrides(env):
root_overrides = { }
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
with open(nginx_conf_custom_fn, 'r') as f:
with open(nginx_conf_custom_fn, encoding='utf-8') as f:
custom_settings = rtyaml.load(f)
for domain, settings in custom_settings.items():
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
@ -78,7 +77,7 @@ def do_web_update(env):
# Helper for reading config files and templates
def read_conf(conf_fn):
with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn), "r") as f:
with open(os.path.join(os.path.dirname(__file__), "../conf", conf_fn), encoding='utf-8') as f:
return f.read()
# Build an nginx configuration file.
@ -114,12 +113,12 @@ def do_web_update(env):
# Did the file change? If not, don't bother writing & restarting nginx.
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
if os.path.exists(nginx_conf_fn):
with open(nginx_conf_fn) as f:
with open(nginx_conf_fn, encoding='utf-8') as f:
if f.read() == nginx_conf:
return ""
# Save the file.
with open(nginx_conf_fn, "w") as f:
with open(nginx_conf_fn, "w", encoding='utf-8') as f:
f.write(nginx_conf)
# Kick nginx. Since this might be called from the web admin
@ -151,13 +150,13 @@ def make_domain_config(domain, templates, ssl_certificates, env):
with open(filepath, 'rb') as f:
sha1.update(f.read())
return sha1.hexdigest()
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
nginx_conf_extra += "\t# ssl files sha1: {} / {}\n".format(hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
# Add in any user customizations in YAML format.
hsts = "yes"
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
with open(nginx_conf_custom_fn, 'r') as f:
with open(nginx_conf_custom_fn, encoding='utf-8') as f:
yaml = rtyaml.load(f)
if domain in yaml:
yaml = yaml[domain]
@ -168,6 +167,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
pass_http_host_header = False
proxy_redirect_off = False
frame_options_header_sameorigin = False
web_sockets = False
m = re.search("#(.*)$", url)
if m:
for flag in m.group(1).split(","):
@ -177,6 +177,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
proxy_redirect_off = True
elif flag == "frame-options-sameorigin":
frame_options_header_sameorigin = True
elif flag == "web-sockets":
web_sockets = True
url = re.sub("#(.*)$", "", url)
nginx_conf_extra += "\tlocation %s {" % path
@ -187,6 +189,10 @@ def make_domain_config(domain, templates, ssl_certificates, env):
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
if frame_options_header_sameorigin:
nginx_conf_extra += "\n\t\tproxy_set_header X-Frame-Options SAMEORIGIN;"
if web_sockets:
nginx_conf_extra += "\n\t\tproxy_http_version 1.1;"
nginx_conf_extra += "\n\t\tproxy_set_header Upgrade $http_upgrade;"
nginx_conf_extra += "\n\t\tproxy_set_header Connection 'Upgrade';"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;"
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Proto $scheme;"
@ -197,16 +203,16 @@ def make_domain_config(domain, templates, ssl_certificates, env):
nginx_conf_extra += "\n\t\talias %s;" % alias
nginx_conf_extra += "\n\t}\n"
for path, url in yaml.get("redirects", {}).items():
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
nginx_conf_extra += f"\trewrite {path} {url} permanent;\n"
# override the HSTS directive type
hsts = yaml.get("hsts", hsts)
# Add the HSTS header.
if hsts == "yes":
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n"
nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000" always;\n'
elif hsts == "preload":
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n"
nginx_conf_extra += '\tadd_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;\n'
# Add in any user customizations in the includes/ folder.
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
@ -217,7 +223,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
# Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder
# of the previous template.
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
for t in templates + [nginx_conf_extra]:
for t in [*templates, nginx_conf_extra]:
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
# Replace substitution strings in the template & return.
@ -226,9 +232,8 @@ def make_domain_config(domain, templates, ssl_certificates, env):
nginx_conf = nginx_conf.replace("$ROOT", root)
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"])
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
return nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
return nginx_conf
def get_web_root(domain, env, test_exists=True):
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.

View File

@ -1,5 +1,5 @@
from daemon import app
import auth, utils
import utils
app.logger.addHandler(utils.create_syslog_handler())

View File

@ -23,7 +23,7 @@ if [ -z "$TAG" ]; then
if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
# This machine is running Ubuntu 22.04, which is supported by
# Mail-in-a-Box versions 60 and later.
TAG=v64
TAG=v69b
elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
# This machine is running Ubuntu 18.04, which is supported by
# Mail-in-a-Box versions 0.40 through 5x.
@ -51,33 +51,37 @@ if [[ $EUID -ne 0 ]]; then
fi
# Clone the Mail-in-a-Box repository if it doesn't exist.
if [ ! -d $HOME/mailinabox ]; then
if [ ! -d "$HOME/mailinabox" ]; then
if [ ! -f /usr/bin/git ]; then
echo Installing git . . .
echo "Installing git . . ."
apt-get -q -q update
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
echo
fi
echo Downloading Mail-in-a-Box $TAG. . .
if [ "$SOURCE" == "" ]; then
SOURCE=https://github.com/mail-in-a-box/mailinabox
fi
echo "Downloading Mail-in-a-Box $TAG. . ."
git clone \
-b $TAG --depth 1 \
https://github.com/mail-in-a-box/mailinabox \
$HOME/mailinabox \
-b "$TAG" --depth 1 \
"$SOURCE" \
"$HOME/mailinabox" \
< /dev/null 2> /dev/null
echo
fi
# Change directory to it.
cd $HOME/mailinabox
cd "$HOME/mailinabox" || exit
# Update it.
if [ "$TAG" != $(git describe --always) ]; then
echo Updating Mail-in-a-Box to $TAG . . .
git fetch --depth 1 --force --prune origin tag $TAG
if ! git checkout -q $TAG; then
echo "Update failed. Did you modify something in $(pwd)?"
if [ "$TAG" != "$(git describe --always)" ]; then
echo "Updating Mail-in-a-Box to $TAG . . ."
git fetch --depth 1 --force --prune origin tag "$TAG"
if ! git checkout -q "$TAG"; then
echo "Update failed. Did you modify something in $PWD?"
exit 1
fi
echo

View File

@ -10,12 +10,12 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install DKIM...
echo Installing OpenDKIM/OpenDMARC...
echo "Installing OpenDKIM/OpenDMARC..."
apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist.
mkdir -p /etc/opendkim;
mkdir -p $STORAGE_ROOT/mail/dkim
mkdir -p "$STORAGE_ROOT/mail/dkim"
# Used in InternalHosts and ExternalIgnoreList configuration directives.
# Not quite sure why.
@ -53,12 +53,12 @@ fi
# such as Google. But they and others use a 2048 bit key, so we'll
# do the same. Keys beyond 2048 bits may exceed DNS record limits.
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim
opendkim-genkey -b 2048 -r -s mail -D "$STORAGE_ROOT/mail/dkim"
fi
# Ensure files are owned by the opendkim user and are private otherwise.
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
chmod go-rwx $STORAGE_ROOT/mail/dkim
chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim"
chmod go-rwx "$STORAGE_ROOT/mail/dkim"
tools/editconf.py /etc/opendmarc.conf -s \
"Syslog=true" \
@ -66,7 +66,7 @@ tools/editconf.py /etc/opendmarc.conf -s \
"FailureReports=false"
# SPFIgnoreResults causes the filter to ignore any SPF results in the header
# of the message. This is useful if you want the filter to perfrom SPF checks
# of the message. This is useful if you want the filter to perform SPF checks
# itself, or because you don't trust the arriving header. This added header is
# used by spamassassin to evaluate the mail for spamminess.

View File

@ -101,12 +101,12 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# we're capturing into the `KSK` variable.
#
# ldns-keygen uses /dev/random for generating random numbers by default.
# This is slow and unecessary if we ensure /dev/urandom is seeded properly,
# This is slow and unnecessary if we ensure /dev/urandom is seeded properly,
# so we use /dev/urandom. See system.sh for an explanation. See #596, #115.
# (This previously used -b 2048 but it's unclear if this setting makes sense
# for non-RSA keys, so it's removed. The RSA-based keys are not recommended
# anymore anyway.)
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -k _domain_);
KSK=$(umask 077; cd "$STORAGE_ROOT/dns/dnssec"; ldns-keygen -r /dev/urandom -a $algo -k _domain_);
# Now create a Zone-Signing Key (ZSK) which is expected to be
# rotated more often than a KSK, although we have no plans to
@ -114,7 +114,7 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# disturbing DNS availability.) Omit `-k`.
# (This previously used -b 1024 but it's unclear if this setting makes sense
# for non-RSA keys, so it's removed.)
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo _domain_);
ZSK=$(umask 077; cd "$STORAGE_ROOT/dns/dnssec"; ldns-keygen -r /dev/urandom -a $algo _domain_);
# These generate two sets of files like:
#
@ -126,7 +126,7 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# options. So we'll store the names of the files we just generated.
# We might have multiple keys down the road. This will identify
# what keys are the current keys.
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
cat > "$STORAGE_ROOT/dns/dnssec/$algo.conf" << EOF;
KSK=$KSK
ZSK=$ZSK
EOF
@ -142,7 +142,7 @@ cat > /etc/cron.daily/mailinabox-dnssec << EOF;
#!/bin/bash
# Mail-in-a-Box
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
$(pwd)/tools/dns_update
$PWD/tools/dns_update
EOF
chmod +x /etc/cron.daily/mailinabox-dnssec

View File

@ -1,6 +1,7 @@
#!/bin/bash
# If there aren't any mail users yet, create one.
if [ -z "$(management/cli.py user)" ]; then
# The outut of "management/cli.py user" is a list of mail users. If there
# The output of "management/cli.py user" is a list of mail users. If there
# aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now.
@ -10,7 +11,7 @@ if [ -z "$(management/cli.py user)" ]; then
input_box "Mail Account" \
"Let's create your first mail account.
\n\nWhat email address do you want?" \
me@$(get_default_hostname) \
"me@$(get_default_hostname)" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
@ -22,7 +23,7 @@ if [ -z "$(management/cli.py user)" ]; then
input_box "Mail Account" \
"That's not a valid email address.
\n\nWhat email address do you want?" \
$EMAIL_ADDR \
"$EMAIL_ADDR" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
@ -47,11 +48,11 @@ if [ -z "$(management/cli.py user)" ]; then
fi
# Create the user's mail account. This will ask for a password if none was given above.
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
management/cli.py user add "$EMAIL_ADDR" ${EMAIL_PW:+"$EMAIL_PW"}
# Make it an admin.
hide_output management/cli.py user make-admin $EMAIL_ADDR
hide_output management/cli.py user make-admin "$EMAIL_ADDR"
# Create an alias to which we'll direct all automatically-created administrative aliases.
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
management/cli.py alias add "administrator@$PRIMARY_HOSTNAME" "$EMAIL_ADDR" > /dev/null
fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
# -e: exit if any command unexpectedly fails.
# -u: exit if we have a variable typo.
@ -14,7 +15,7 @@ function hide_output {
# Execute command, redirecting stderr/stdout to the temporary file. Since we
# check the return code ourselves, disable 'set -e' temporarily.
set +e
"$@" &> $OUTPUT
"$@" &> "$OUTPUT"
E=$?
set -e
@ -22,15 +23,15 @@ function hide_output {
if [ $E != 0 ]; then
# Something failed.
echo
echo FAILED: "$@"
echo "FAILED: $*"
echo -----------------------------------------
cat $OUTPUT
cat "$OUTPUT"
echo -----------------------------------------
exit $E
fi
# Remove temporary file.
rm -f $OUTPUT
rm -f "$OUTPUT"
}
function apt_get_quiet {
@ -60,9 +61,9 @@ function get_default_hostname {
# Guess the machine's hostname. It should be a fully qualified
# domain name suitable for DNS. None of these calls may provide
# the right value, but it's the best guess we can make.
set -- $(hostname --fqdn 2>/dev/null ||
set -- "$(hostname --fqdn 2>/dev/null ||
hostname --all-fqdns 2>/dev/null ||
hostname 2>/dev/null)
hostname 2>/dev/null)"
printf '%s\n' "$1" # return this value
}
@ -74,7 +75,7 @@ function get_publicip_from_web_service {
#
# Pass '4' or '6' as an argument to this function to specify
# what type of address to get (IPv4, IPv6).
curl -$1 --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true
curl -"$1" --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true
}
function get_default_privateip {
@ -117,19 +118,19 @@ function get_default_privateip {
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
# Get the route information.
route=$(ip -$1 -o route get $target 2>/dev/null | grep -v unreachable)
route=$(ip -"$1" -o route get $target 2>/dev/null | grep -v unreachable)
# Parse the address out of the route information.
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
address=$(echo "$route" | sed "s/.* src \([^ ]*\).*/\1/")
if [[ "$1" == "6" && $address == fe80:* ]]; then
# For IPv6 link-local addresses, parse the interface out
# of the route information and append it with a '%'.
interface=$(echo $route | sed "s/.* dev \([^ ]*\).*/\1/")
interface=$(echo "$route" | sed "s/.* dev \([^ ]*\).*/\1/")
address=$address%$interface
fi
echo $address
echo "$address"
}
function ufw_allow {
@ -147,7 +148,7 @@ function ufw_limit {
}
function restart_service {
hide_output service $1 restart
hide_output service "$1" restart
}
## Dialog Functions ##
@ -176,7 +177,7 @@ function input_menu {
declare -n result_code=$4_EXITCODE
local IFS=^$'\n'
set +e
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 "$3")
result_code=$?
set -e
}
@ -188,17 +189,17 @@ function wget_verify {
HASH=$2
DEST=$3
CHECKSUM="$HASH $DEST"
rm -f $DEST
hide_output wget -O $DEST $URL
rm -f "$DEST"
hide_output wget -O "$DEST" "$URL"
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
echo "------------------------------------------------------------"
echo "Download of $URL did not match expected checksum."
echo "Found:"
sha1sum $DEST
sha1sum "$DEST"
echo
echo "Expected:"
echo "$CHECKSUM"
rm -f $DEST
rm -f "$DEST"
exit 1
fi
}
@ -214,10 +215,10 @@ function git_clone {
SUBDIR=$3
TARGETPATH=$4
TMPPATH=/tmp/git-clone-$$
rm -rf $TMPPATH $TARGETPATH
git clone -q $REPO $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
mv $TMPPATH/$SUBDIR $TARGETPATH
rm -rf $TMPPATH "$TARGETPATH"
git clone -q "$REPO" $TMPPATH || exit 1
(cd $TMPPATH; git checkout -q "$TREEISH";) || exit 1
mv $TMPPATH/"$SUBDIR" "$TARGETPATH"
rm -rf $TMPPATH
}

View File

@ -45,8 +45,8 @@ apt_install \
# - https://www.dovecot.org/list/dovecot/2012-August/137569.html
# - https://www.dovecot.org/list/dovecot/2011-December/132455.html
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
default_process_limit=$(echo "$(nproc) * 250" | bc) \
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \
default_process_limit="$(($(nproc) * 250))" \
default_vsz_limit="$(($(free -tm | tail -1 | awk '{print $2}') / 3))M" \
log_path=/var/log/mail.log
# The inotify `max_user_instances` default is 128, which constrains
@ -61,7 +61,7 @@ tools/editconf.py /etc/sysctl.conf \
# username part of the user's email address. We'll ensure that no bad domains or email addresses
# are created within the management daemon.
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
mail_location="maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n" \
mail_privileged_group=mail \
first_valid_uid=0
@ -152,7 +152,7 @@ EOF
# Setting a `postmaster_address` is required or LMTP won't start. An alias
# will be created automatically by our management daemon.
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
postmaster_address=postmaster@$PRIMARY_HOSTNAME
"postmaster_address=postmaster@$PRIMARY_HOSTNAME"
# ### Sieve
@ -201,14 +201,14 @@ chown -R mail:dovecot /etc/dovecot
chmod -R o-rwx /etc/dovecot
# Ensure mailbox files have a directory that exists and are owned by the mail user.
mkdir -p $STORAGE_ROOT/mail/mailboxes
chown -R mail:mail $STORAGE_ROOT/mail/mailboxes
mkdir -p "$STORAGE_ROOT/mail/mailboxes"
chown -R mail:mail "$STORAGE_ROOT/mail/mailboxes"
# Same for the sieve scripts.
mkdir -p $STORAGE_ROOT/mail/sieve
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
chown -R mail:mail $STORAGE_ROOT/mail/sieve
mkdir -p "$STORAGE_ROOT/mail/sieve"
mkdir -p "$STORAGE_ROOT/mail/sieve/global_before"
mkdir -p "$STORAGE_ROOT/mail/sieve/global_after"
chown -R mail:mail "$STORAGE_ROOT/mail/sieve"
# Allow the IMAP/POP ports in the firewall.
ufw_allow imaps

View File

@ -37,7 +37,7 @@ source /etc/mailinabox.conf # load global vars
# * `postfix`: The SMTP server.
# * `postfix-pcre`: Enables header filtering.
# * `postgrey`: A mail policy service that soft-rejects mail the first time
# it is received. Spammers don't usually try agian. Legitimate mail
# it is received. Spammers don't usually try again. Legitimate mail
# always will.
# * `ca-certificates`: A trust store used to squelch postfix warnings about
# untrusted opportunistically-encrypted connections.
@ -55,9 +55,9 @@ apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates
# * Set the SMTP banner (which must have the hostname first, then anything).
tools/editconf.py /etc/postfix/main.cf \
inet_interfaces=all \
smtp_bind_address=$PRIVATE_IP \
smtp_bind_address6=$PRIVATE_IPV6 \
myhostname=$PRIMARY_HOSTNAME\
smtp_bind_address="$PRIVATE_IP" \
smtp_bind_address6="$PRIVATE_IPV6" \
myhostname="$PRIMARY_HOSTNAME"\
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
mydestination=localhost
@ -69,6 +69,18 @@ tools/editconf.py /etc/postfix/main.cf \
maximal_queue_lifetime=2d \
bounce_queue_lifetime=1d
# Guard against SMTP smuggling
# This "long-term" fix is recommended at https://www.postfix.org/smtp-smuggling.html.
# This beecame supported in a backported fix in package version 3.6.4-1ubuntu1.3. It is
# unnecessary in Postfix 3.9+ where this is the default. The "short-term" workarounds
# that we previously had are reverted to postfix defaults (though smtpd_discard_ehlo_keywords
# was never included in a released version of Mail-in-a-Box).
tools/editconf.py /etc/postfix/main.cf -e \
smtpd_data_restrictions= \
smtpd_discard_ehlo_keywords=
tools/editconf.py /etc/postfix/main.cf \
smtpd_forbid_bare_newline=normalize
# ### Outgoing Mail
# Enable the 'submission' ports 465 and 587 and tweak their settings.
@ -126,9 +138,9 @@ sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
tools/editconf.py /etc/postfix/main.cf \
smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
smtpd_tls_cert_file="$STORAGE_ROOT/ssl/ssl_certificate.pem" \
smtpd_tls_key_file="$STORAGE_ROOT/ssl/ssl_private_key.pem" \
smtpd_tls_dh1024_param_file="$STORAGE_ROOT/ssl/dh2048.pem" \
smtpd_tls_protocols="!SSLv2,!SSLv3" \
smtpd_tls_ciphers=medium \
tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \
@ -160,7 +172,7 @@ tools/editconf.py /etc/postfix/main.cf \
# When connecting to remote SMTP servers, prefer TLS and use DANE if available.
#
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
# Preferring ("opportunistic") TLS means Postfix will use TLS if the remote end
# offers it, otherwise it will transmit the message in the clear. Postfix will
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
# protects against passive easvesdropping (but not man-in-the-middle attacks).
@ -176,7 +188,7 @@ tools/editconf.py /etc/postfix/main.cf \
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
# relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`.
#
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
# The `smtp_tls_CAfile` is superfluous, but it eliminates warnings in the logs about untrusted certs,
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
@ -218,14 +230,15 @@ tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
# * `check_policy_service`: Apply greylisting using postgrey.
#
# Note the spamhaus rbl return codes are taken into account as advised here: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html
# Notes: #NODOC
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #NODOC
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org" \
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023"
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org=127.0.1.[2..99]" \
smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service inet:127.0.0.1:10023"
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
# Postgrey listens on the same interface (and not IPv6, for instance).
@ -234,7 +247,7 @@ tools/editconf.py /etc/postfix/main.cf \
# other MTA have their own intervals. To fix the problem of receiving
# e-mails really latter, delay of greylisting has been set to
# 180 seconds (default is 300 seconds). We will move the postgrey database
# under $STORAGE_ROOT. This prevents a "warming up" that would have occured
# under $STORAGE_ROOT. This prevents a "warming up" that would have occurred
# previously with a migrated or reinstalled OS. We will specify this new path
# with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces,
# including dbdir. This is due to the way the init script sources the
@ -247,17 +260,17 @@ tools/editconf.py /etc/default/postgrey \
# If the $STORAGE_ROOT/mail/postgrey is empty, copy the postgrey database over from the old location
if [ ! -d $STORAGE_ROOT/mail/postgrey/db ]; then
if [ ! -d "$STORAGE_ROOT/mail/postgrey/db" ]; then
# Stop the service
service postgrey stop
# Ensure the new paths for postgrey db exists
mkdir -p $STORAGE_ROOT/mail/postgrey/db
mkdir -p "$STORAGE_ROOT/mail/postgrey/db"
# Move over database files
mv /var/lib/postgrey/* $STORAGE_ROOT/mail/postgrey/db/ || true
mv /var/lib/postgrey/* "$STORAGE_ROOT/mail/postgrey/db/" || true
fi
# Ensure permissions are set
chown -R postgrey:postgrey $STORAGE_ROOT/mail/postgrey/
chmod 700 $STORAGE_ROOT/mail/postgrey/{,db}
chown -R postgrey:postgrey "$STORAGE_ROOT/mail/postgrey/"
chmod 700 "$STORAGE_ROOT/mail/postgrey/"{,db}
# We are going to setup a newer whitelist for postgrey, the version included in the distribution is old
cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;

View File

@ -5,7 +5,7 @@
#
# This script configures user authentication for Dovecot
# and Postfix (which relies on Dovecot) and destination
# validation by quering an Sqlite3 database of mail users.
# validation by querying an Sqlite3 database of mail users.
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
@ -18,12 +18,12 @@ source /etc/mailinabox.conf # load global vars
db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then
echo Creating new user database: $db_path;
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
if [ ! -f "$db_path" ]; then
echo "Creating new user database: $db_path";
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 "$db_path";
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 "$db_path";
echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 "$db_path";
fi
# ### User Authentication

View File

@ -27,6 +27,12 @@ inst_dir=/usr/local/lib/mailinabox
mkdir -p $inst_dir
venv=$inst_dir/env
if [ ! -d $venv ]; then
# A bug specific to Ubuntu 22.04 and Python 3.10 requires
# forcing a virtualenv directory layout option (see #2335
# and https://github.com/pypa/virtualenv/pull/2415). In
# our issue, reportedly installing python3-distutils didn't
# fix the problem.)
export DEB_PYTHON_INSTALL_LAYOUT='deb'
hide_output virtualenv -ppython3 $venv
fi
@ -46,9 +52,9 @@ hide_output $venv/bin/pip install --upgrade \
# CONFIGURATION
# Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
mkdir -p "$STORAGE_ROOT/backup"
if [ ! -f "$STORAGE_ROOT/backup/secret_key.txt" ]; then
(umask 077; openssl rand -base64 2048 > "$STORAGE_ROOT/backup/secret_key.txt")
fi
@ -60,18 +66,18 @@ rm -rf $assets_dir
mkdir -p $assets_dir
# jQuery CDN URL
jquery_version=2.1.4
jquery_version=2.2.4
jquery_url=https://code.jquery.com
# Get jQuery
wget_verify $jquery_url/jquery-$jquery_version.min.js 43dc554608df885a59ddeece1598c6ace434d747 $assets_dir/jquery.min.js
wget_verify $jquery_url/jquery-$jquery_version.min.js 69bb69e25ca7d5ef0935317584e6153f3fd9a88c $assets_dir/jquery.min.js
# Bootstrap CDN URL
bootstrap_version=3.3.7
bootstrap_version=3.4.1
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
# Get Bootstrap
wget_verify $bootstrap_url e6b1000b94e835ffd37f4c6dcbdad43f4b48a02a /tmp/bootstrap.zip
wget_verify $bootstrap_url 0bb64c67c2552014d48ab4db81c2e8c01781f580 /tmp/bootstrap.zip
unzip -q /tmp/bootstrap.zip -d $assets_dir
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
rm -f /tmp/bootstrap.zip
@ -94,7 +100,7 @@ tr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key
chmod 640 /var/lib/mailinabox/api.key
source $venv/bin/activate
export PYTHONPATH=$(pwd)/management
export PYTHONPATH=$PWD/management
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
EOF
chmod +x $inst_dir/start
@ -110,7 +116,7 @@ minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
cat > /etc/cron.d/mailinabox-nightly << EOF;
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
# Run nightly tasks: backup, status checks.
$minute 3 * * * root (cd $(pwd) && management/daily_tasks.sh)
$minute 3 * * * root (cd $PWD && management/daily_tasks.sh)
EOF
# Start the management server.

View File

@ -9,6 +9,7 @@ import sys, os, os.path, glob, re, shutil
sys.path.insert(0, 'management')
from utils import load_environment, save_environment, shell
import contextlib
def migration_1(env):
# Re-arrange where we store SSL certificates. There was a typo also.
@ -31,10 +32,8 @@ def migration_1(env):
move_file(sslfn, domain_name, file_type)
# Move the old domains directory if it is now empty.
try:
with contextlib.suppress(Exception):
os.rmdir(os.path.join( env["STORAGE_ROOT"], 'ssl/domains'))
except:
pass
def migration_2(env):
# Delete the .dovecot_sieve script everywhere. This was formerly a copy of our spam -> Spam
@ -168,12 +167,12 @@ def migration_12(env):
dropcmd = "DROP TABLE %s" % table
c.execute(dropcmd)
except:
print("Failed to drop table", table, e)
print("Failed to drop table", table)
# Save.
conn.commit()
conn.close()
# Delete all sessions, requring users to login again to recreate carddav_*
# Delete all sessions, requiring users to login again to recreate carddav_*
# databases
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite"))
c = conn.cursor()
@ -212,8 +211,8 @@ def run_migrations():
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
migration_id = None
if os.path.exists(migration_id_file):
with open(migration_id_file) as f:
migration_id = f.read().strip();
with open(migration_id_file, encoding='utf-8') as f:
migration_id = f.read().strip()
if migration_id is None:
# Load the legacy location of the migration ID. We'll drop support
@ -222,7 +221,7 @@ def run_migrations():
if migration_id is None:
print()
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
print(f"{migration_id_file} file doesn't exists. Skipping migration...")
return
ourver = int(migration_id)
@ -253,7 +252,7 @@ def run_migrations():
# Write out our current version now. Do this sooner rather than later
# in case of any problems.
with open(migration_id_file, "w") as f:
with open(migration_id_file, "w", encoding='utf-8') as f:
f.write(str(ourver) + "\n")
# Delete the legacy location of this field.

View File

@ -40,7 +40,7 @@ chown munin /var/log/munin/munin-cgi-graph.log
# ensure munin-node knows the name of this machine
# and reduce logging level to warning
tools/editconf.py /etc/munin/munin-node.conf -s \
host_name=$PRIMARY_HOSTNAME \
host_name="$PRIMARY_HOSTNAME" \
log_level=1
# Update the activated plugins through munin's autoconfiguration.
@ -52,9 +52,9 @@ find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
IF=$(echo $f | sed s/.*_//);
if ! grep -qFx up /sys/class/net/$IF/operstate 2>/dev/null; then
rm $f;
IF=$(echo "$f" | sed s/.*_//);
if ! grep -qFx up "/sys/class/net/$IF/operstate" 2>/dev/null; then
rm "$f";
fi;
done
@ -62,7 +62,7 @@ done
mkdir -p /var/lib/munin-node/plugin-state/
# Create a systemd service for munin.
ln -sf $(pwd)/management/munin_start.sh /usr/local/lib/mailinabox/munin_start.sh
ln -sf "$PWD/management/munin_start.sh" /usr/local/lib/mailinabox/munin_start.sh
chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first
hide_output systemctl link -f /lib/systemd/system/munin.service

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
# the rest of the system setup so we may not yet have things installed.
apt_get_quiet install bind9-host sed netcat-openbsd
@ -6,7 +7,7 @@ apt_get_quiet install bind9-host sed netcat-openbsd
# The user might have chosen a name that was previously in use by a spammer
# and will not be able to reliably send mail. Do this after any automatic
# choices made above.
if host $PRIMARY_HOSTNAME.dbl.spamhaus.org > /dev/null; then
if host "$PRIMARY_HOSTNAME.dbl.spamhaus.org" > /dev/null; then
echo
echo "The hostname you chose '$PRIMARY_HOSTNAME' is listed in the"
echo "Spamhaus Domain Block List. See http://www.spamhaus.org/dbl/"
@ -22,8 +23,8 @@ fi
# 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.
REVERSED_IPV4=$(echo $PUBLIC_IP | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/")
if host $REVERSED_IPV4.zen.spamhaus.org > /dev/null; then
REVERSED_IPV4=$(echo "$PUBLIC_IP" | sed "s/\([0-9]*\).\([0-9]*\).\([0-9]*\).\([0-9]*\)/\4.\3.\2.\1/")
if host "$REVERSED_IPV4.zen.spamhaus.org" > /dev/null; then
echo
echo "The IP address $PUBLIC_IP is listed in the Spamhaus Block List."
echo "See http://www.spamhaus.org/query/ip/$PUBLIC_IP."

View File

@ -21,31 +21,48 @@ echo "Installing Nextcloud (contacts/calendar)..."
# we automatically install intermediate versions as needed.
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
# copying it from the error message when it doesn't match what is below.
nextcloud_ver=25.0.7
nextcloud_hash=a5a565c916355005c7b408dd41a1e53505e1a080
nextcloud_ver=26.0.13
nextcloud_hash=d5c10b650e5396d5045131c6d22c02a90572527c
# Nextcloud apps
# --------------
# * Find the most recent tag that is compatible with the Nextcloud version above by
# consulting the <dependencies>...<nextcloud> node at:
# https://github.com/nextcloud-releases/contacts/blob/main/appinfo/info.xml
# https://github.com/nextcloud-releases/calendar/blob/main/appinfo/info.xml
# https://github.com/nextcloud/user_external/blob/master/appinfo/info.xml
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
# copying it from the error message when it doesn't match what is below.
contacts_ver=5.3.0
contacts_hash=4b0a6666374e3b55cfd2ae9b72e1d458b87d4c8c
# * Find the most recent tag that is compatible with the Nextcloud version above by:
# https://github.com/nextcloud-releases/contacts/tags
# https://github.com/nextcloud-releases/calendar/tags
# https://github.com/nextcloud/user_external/tags
#
# * For these three packages, contact, calendar and user_external, the hash is the SHA1 hash of
# the ZIP package, which you can find by just running this script and copying it from
# the error message when it doesn't match what is below:
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/contacts
contacts_ver=5.5.3
contacts_hash=799550f38e46764d90fa32ca1a6535dccd8316e5
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/calendar
calendar_ver=4.4.2
calendar_hash=21a42e15806adc9b2618760ef94f1797ef399e2f
calendar_ver=4.7.6
calendar_hash=a995bca4effeecb2cab25f3bbeac9bfe05fee766
# And https://apps.nextcloud.com/apps/user_external
user_external_ver=3.2.0
user_external_hash=a494073dcdecbbbc79a9c77f72524ac9994d2eec
# Always ensure the versions are supported, see https://apps.nextcloud.com/apps/user_external
user_external_ver=3.3.0
user_external_hash=280d24eb2a6cb56b4590af8847f925c28d8d853e
# Developer advice (test plan)
# ----------------------------
# When upgrading above versions, how to test?
#
# 1. Enter your server instance (or on the Vagrant image)
# 1. Git clone <your fork>
# 2. Git checkout <your fork>
# 3. Run `sudo ./setup/nextcloud.sh`
# 4. Ensure the installation completes. If any hashes mismatch, correct them.
# 5. Enter nextcloud web, run following tests:
# 5.1 You still can create, edit and delete contacts
# 5.2 You still can create, edit and delete calendar events
# 5.3 You still can create, edit and delete users
# 5.4 Go to Administration > Logs and ensure no new errors are shown
# Clear prior packages and install dependencies from apt.
apt-get purge -qq -y owncloud* # we used to use the package manager
apt_install curl php8.1 php8.1-fpm \
@ -56,7 +73,7 @@ apt_install curl php8.1 php8.1-fpm \
PHP_VER=$(php_version)
# Enable APC before Nextcloud tools are run.
tools/editconf.py /etc/php/$PHP_VER/mods-available/apcu.ini -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/mods-available/apcu.ini -c ';' \
apc.enabled=1 \
apc.enable_cli=1
@ -76,7 +93,7 @@ InstallNextcloud() {
echo
# Download and verify
wget_verify https://download.nextcloud.com/server/releases/nextcloud-$version.zip $hash /tmp/nextcloud.zip
wget_verify "https://download.nextcloud.com/server/releases/nextcloud-$version.zip" "$hash" /tmp/nextcloud.zip
# Remove the current owncloud/Nextcloud
rm -rf /usr/local/lib/owncloud
@ -90,18 +107,18 @@ InstallNextcloud() {
# their github repositories.
mkdir -p /usr/local/lib/owncloud/apps
wget_verify https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz $hash_contacts /tmp/contacts.tgz
wget_verify "https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz" "$hash_contacts" /tmp/contacts.tgz
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/contacts.tgz
wget_verify https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz $hash_calendar /tmp/calendar.tgz
wget_verify "https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz" "$hash_calendar" /tmp/calendar.tgz
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/calendar.tgz
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
# we will install from their github repository.
if [ -n "$version_user_external" ]; then
wget_verify https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
wget_verify "https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz" "$hash_user_external" /tmp/user_external.tgz
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/user_external.tgz
fi
@ -111,33 +128,35 @@ InstallNextcloud() {
# Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously
# put in, and in new installs we're creating a symlink and will create the actual config later).
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php
# Make sure permissions are correct or the upgrade step won't run.
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
# that error.
chown -f -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud || /bin/true
chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud || /bin/true
# If this isn't a new installation, immediately run the upgrade script.
# Then check for success (0=ok and 3=no upgrade needed, both are success).
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then
if [ -e "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
# ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but
# that can be OK.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off
echo "...which seemed to work."
fi
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:add-missing-indices
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:add-missing-primary-keys
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:add-missing-indices
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:add-missing-primary-keys
# Run conversion to BigInt identifiers, this process may take some time on large tables.
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction
fi
}
@ -149,7 +168,7 @@ InstallNextcloud() {
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
CURRENT_NEXTCLOUD_VER=$(php$PHP_VER -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
CURRENT_NEXTCLOUD_VER=$(php"$PHP_VER" -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
else
CURRENT_NEXTCLOUD_VER=""
fi
@ -159,7 +178,7 @@ fi
if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then
# Stop php-fpm if running. If they are not running (which happens on a previously failed install), dont bail.
service php$PHP_VER-fpm stop &> /dev/null || /bin/true
service php"$PHP_VER"-fpm stop &> /dev/null || /bin/true
# Backup the existing ownCloud/Nextcloud.
# Create a backup directory to store the current installation and database to
@ -169,21 +188,21 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
echo "Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY..."
cp -r /usr/local/lib/owncloud "$BACKUP_DIRECTORY/owncloud-install"
fi
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then
cp $STORAGE_ROOT/owncloud/owncloud.db $BACKUP_DIRECTORY
if [ -e "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
cp "$STORAGE_ROOT/owncloud/owncloud.db" "$BACKUP_DIRECTORY"
fi
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then
cp $STORAGE_ROOT/owncloud/config.php $BACKUP_DIRECTORY
if [ -e "$STORAGE_ROOT/owncloud/config.php" ]; then
cp "$STORAGE_ROOT/owncloud/config.php" "$BACKUP_DIRECTORY"
fi
# If ownCloud or Nextcloud was previously installed....
if [ ! -z ${CURRENT_NEXTCLOUD_VER} ]; then
if [ -n "${CURRENT_NEXTCLOUD_VER}" ]; then
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
# PHP 7.
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then
if [ -e "$STORAGE_ROOT/owncloud/config.php" ]; then
# Remove the read-onlyness of the config, which is needed for migrations, especially for v24
sed -i -e '/config_is_read_only/d' $STORAGE_ROOT/owncloud/config.php
sed -i -e '/config_is_read_only/d' "$STORAGE_ROOT/owncloud/config.php"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
@ -223,6 +242,7 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
apc.enabled=1 \
apc.enable_cli=1
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then
InstallNextcloud 21.0.7 f5c7079c5b56ce1e301c6a27c0d975d608bb01c9 4.0.7 45e7cf4bfe99cd8d03625cf9e5a1bb2e90549136 3.0.4 d0284b68135777ec9ca713c307216165b294d0fe
CURRENT_NEXTCLOUD_VER="21.0.7"
@ -254,7 +274,15 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
# Remove the ppa
add-apt-repository --yes --remove ppa:ondrej/php
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^24 ]]; then
InstallNextcloud 25.0.7 a5a565c916355005c7b408dd41a1e53505e1a080 5.3.0 4b0a6666374e3b55cfd2ae9b72e1d458b87d4c8c 4.4.2 21a42e15806adc9b2618760ef94f1797ef399e2f 3.2.0 a494073dcdecbbbc79a9c77f72524ac9994d2eec
CURRENT_NEXTCLOUD_VER="25.0.7"
fi
# Hint: whenever you bump, remember this:
# - Run a server with the previous version
# - On a new if-else block, copy the versions/hashes from the previous version
# - Run sudo ./setup/start.sh on the new machine. Upon completion, test its basic functionalities.
fi
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
@ -264,13 +292,13 @@ fi
# Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when
# the database does exist wipes the database and user data.
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
if [ ! -f "$STORAGE_ROOT/owncloud/owncloud.db" ]; then
# Create user data directory
mkdir -p $STORAGE_ROOT/owncloud
mkdir -p "$STORAGE_ROOT/owncloud"
# Create an initial configuration file.
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
instanceid=oc$(echo "$PRIMARY_HOSTNAME" | sha1sum | fold -w 10 | head -n 1)
cat > "$STORAGE_ROOT/owncloud/config.php" <<EOF;
<?php
\$CONFIG = array (
'datadirectory' => '$STORAGE_ROOT/owncloud',
@ -290,15 +318,6 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
),
),
'memcache.local' => '\OC\Memcache\APCu',
'mail_smtpmode' => 'sendmail',
'mail_smtpsecure' => '',
'mail_smtpauthtype' => 'LOGIN',
'mail_smtpauth' => false,
'mail_smtphost' => '',
'mail_smtpport' => '',
'mail_smtpname' => '',
'mail_smtppassword' => '',
'mail_from_address' => 'owncloud',
);
?>
EOF
@ -323,12 +342,12 @@ EOF
EOF
# Set permissions
chown -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
chown -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud
# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.
# It also wipes it if it exists. And it updates config.php with database
# settings and deletes the autoconfig.php file.
(cd /usr/local/lib/owncloud; sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/index.php;)
(cd /usr/local/lib/owncloud || exit; sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/index.php;)
fi
# Update config.php.
@ -344,7 +363,7 @@ fi
# Use PHP to read the settings file, modify it, and write out the new settings array.
TIMEZONE=$(cat /etc/timezone)
CONFIG_TEMP=$(/bin/mktemp)
php$PHP_VER <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
php"$PHP_VER" <<EOF > "$CONFIG_TEMP" && mv "$CONFIG_TEMP" "$STORAGE_ROOT/owncloud/config.php";
<?php
include("$STORAGE_ROOT/owncloud/config.php");
@ -354,13 +373,10 @@ include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
\$CONFIG['overwrite.cli.url'] = 'https://${PRIMARY_HOSTNAME}/cloud';
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address
\$CONFIG['logtimezone'] = '$TIMEZONE';
\$CONFIG['logdateformat'] = 'Y-m-d H:i:s';
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['user_backends'] = array(
array(
'class' => '\OCA\UserExternal\IMAP',
@ -370,36 +386,47 @@ include("$STORAGE_ROOT/owncloud/config.php");
),
);
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches the required administrator alias on mail_domain/$PRIMARY_HOSTNAME
\$CONFIG['mail_smtpmode'] = 'sendmail';
\$CONFIG['mail_smtpauth'] = true; # if smtpmode is smtp
\$CONFIG['mail_smtphost'] = '127.0.0.1'; # if smtpmode is smtp
\$CONFIG['mail_smtpport'] = '587'; # if smtpmode is smtp
\$CONFIG['mail_smtpsecure'] = ''; # if smtpmode is smtp, must be empty string
\$CONFIG['mail_smtpname'] = ''; # if smtpmode is smtp, set this to a mail user
\$CONFIG['mail_smtppassword'] = ''; # if smtpmode is smtp, set this to the user's password
echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG);
echo ";";
?>
EOF
chown www-data:www-data $STORAGE_ROOT/owncloud/config.php
chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php"
# Enable/disable apps. Note that this must be done after the Nextcloud setup.
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows Nextcloud to use IMAP for login. The contacts
# and calendar apps are the extensions we really care about here.
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable user_external
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable contacts
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable calendar
hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:disable firstrunwizard
hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable user_external
hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable contacts
hide_output sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/console.php app:enable calendar
# When upgrading, run the upgrade script again now that apps are enabled. It seems like
# the first upgrade at the top won't work because apps may be disabled during upgrade?
# Check for success (0=ok, 3=no upgrade needed).
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
sudo -u www-data php"$PHP_VER" /usr/local/lib/owncloud/occ upgrade
E=$?
if [ $E -ne 0 ] && [ $E -ne 3 ]; then exit 1; fi
# Disable default apps that we don't support
sudo -u www-data \
php$PHP_VER /usr/local/lib/owncloud/occ app:disable photos dashboard activity \
php"$PHP_VER" /usr/local/lib/owncloud/occ app:disable photos dashboard activity \
| (grep -v "No such app enabled" || /bin/true)
# Set PHP FPM values to support large file uploads
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
upload_max_filesize=16G \
post_max_size=16G \
output_buffering=16384 \
@ -408,7 +435,7 @@ tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
short_open_tag=On
# Set Nextcloud recommended opcache settings
tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/cli/conf.d/10-opcache.ini -c ';' \
opcache.enable=1 \
opcache.enable_cli=1 \
opcache.interned_strings_buffer=8 \
@ -422,7 +449,7 @@ tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \
# This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier.
# We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the
# correct backend already.
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true
sqlite3 "$STORAGE_ROOT/owncloud/owncloud.db" "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true
# Set up a general cron job for Nextcloud.
# Also add another job for Calendar updates, per advice in the Nextcloud docs
@ -437,11 +464,11 @@ chmod +x /etc/cron.d/mailinabox-nextcloud
# We also need to change the sending mode from background-job to occ.
# Or else the reminders will just be sent as soon as possible when the background jobs run.
hide_output sudo -u www-data php$PHP_VER -f /usr/local/lib/owncloud/occ config:app:set dav sendEventRemindersMode --value occ
hide_output sudo -u www-data php"$PHP_VER" -f /usr/local/lib/owncloud/occ config:app:set dav sendEventRemindersMode --value occ
# Now set the config to read-only.
# Do this only at the very bottom when no further occ commands are needed.
sed -i'' "s/'config_is_read_only'\s*=>\s*false/'config_is_read_only' => true/" $STORAGE_ROOT/owncloud/config.php
sed -i'' "s/'config_is_read_only'\s*=>\s*false/'config_is_read_only' => true/" "$STORAGE_ROOT/owncloud/config.php"
# Rotate the nextcloud.log file
cat > /etc/logrotate.d/nextcloud <<EOF
@ -466,4 +493,4 @@ EOF
# ```
# Enable PHP modules and restart PHP.
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm

View File

@ -1,3 +1,4 @@
#!/bin/bash
# Are we running as root?
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Please re-run like this:"
@ -26,16 +27,16 @@ fi
#
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
if [ $TOTAL_PHYSICAL_MEM -lt 490000 ]; then
if [ "$TOTAL_PHYSICAL_MEM" -lt 490000 ]; then
if [ ! -d /vagrant ]; then
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
TOTAL_PHYSICAL_MEM=$(( TOTAL_PHYSICAL_MEM * 1024 / 1000 / 1000 ))
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
echo "Please provision a machine with at least 512 MB, 1 GB recommended."
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
exit
fi
fi
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
if [ "$TOTAL_PHYSICAL_MEM" -lt 750000 ]; then
echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory."
echo " It might run unreliably when under heavy load."
fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
if [ -z "${NONINTERACTIVE:-}" ]; then
# Install 'dialog' so we can ask the user questions. The original motivation for
# this was being able to ask the user for input even if stdin has been redirected,
@ -7,7 +8,7 @@ if [ -z "${NONINTERACTIVE:-}" ]; then
#
# Also install dependencies needed to validate the email address.
if [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then
echo Installing packages needed for setup...
echo "Installing packages needed for setup..."
apt-get -q -q update
apt_get_quiet install dialog python3 python3-pip || exit 1
fi
@ -31,7 +32,7 @@ if [ -z "${PRIMARY_HOSTNAME:-}" ]; then
# domain the user possibly wants to use is example.com then.
# We strip the string "box." from the hostname to get the mail
# domain. If the hostname differs, nothing happens here.
DEFAULT_DOMAIN_GUESS=$(echo $(get_default_hostname) | sed -e 's/^box\.//')
DEFAULT_DOMAIN_GUESS=$(get_default_hostname | sed -e 's/^box\.//')
# This is the first run. Ask the user for his email address so we can
# provide the best default for the box's hostname.
@ -55,7 +56,7 @@ you really want.
do
input_box "Your Email Address" \
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
$EMAIL_ADDR \
"$EMAIL_ADDR" \
EMAIL_ADDR
if [ -z "$EMAIL_ADDR" ]; then
# user hit ESC/cancel
@ -65,7 +66,7 @@ you really want.
# Take the part after the @-sign as the user's domain name, and add
# 'box.' to the beginning to create a default hostname for this machine.
DEFAULT_PRIMARY_HOSTNAME=box.$(echo $EMAIL_ADDR | sed 's/.*@//')
DEFAULT_PRIMARY_HOSTNAME=box.$(echo "$EMAIL_ADDR" | sed 's/.*@//')
fi
input_box "Hostname" \
@ -74,7 +75,7 @@ you really want.
address, so we're suggesting $DEFAULT_PRIMARY_HOSTNAME.
\n\nYou can change it, but we recommend you don't.
\n\nHostname:" \
$DEFAULT_PRIMARY_HOSTNAME \
"$DEFAULT_PRIMARY_HOSTNAME" \
PRIMARY_HOSTNAME
if [ -z "$PRIMARY_HOSTNAME" ]; then
@ -92,7 +93,7 @@ if [ -z "${PUBLIC_IP:-}" ]; then
# On the first run, if we got an answer from the Internet then don't
# ask the user.
if [[ -z "${DEFAULT_PUBLIC_IP:-}" && ! -z "$GUESSED_IP" ]]; then
if [[ -z "${DEFAULT_PUBLIC_IP:-}" && -n "$GUESSED_IP" ]]; then
PUBLIC_IP=$GUESSED_IP
# Otherwise on the first run at least provide a default.
@ -109,7 +110,7 @@ if [ -z "${PUBLIC_IP:-}" ]; then
input_box "Public IP Address" \
"Enter the public IP address of this machine, as given to you by your ISP.
\n\nPublic IP address:" \
${DEFAULT_PUBLIC_IP:-} \
"${DEFAULT_PUBLIC_IP:-}" \
PUBLIC_IP
if [ -z "$PUBLIC_IP" ]; then
@ -125,7 +126,7 @@ if [ -z "${PUBLIC_IPV6:-}" ]; then
# Ask the Internet.
GUESSED_IP=$(get_publicip_from_web_service 6)
MATCHED=0
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && ! -z "$GUESSED_IP" ]]; then
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && -n "$GUESSED_IP" ]]; then
PUBLIC_IPV6=$GUESSED_IP
elif [[ "${DEFAULT_PUBLIC_IPV6:-}" == "$GUESSED_IP" ]]; then
# No IPv6 entered and machine seems to have none, or what
@ -141,10 +142,10 @@ if [ -z "${PUBLIC_IPV6:-}" ]; then
"Enter the public IPv6 address of this machine, as given to you by your ISP.
\n\nLeave blank if the machine does not have an IPv6 address.
\n\nPublic IPv6 address:" \
${DEFAULT_PUBLIC_IPV6:-} \
"${DEFAULT_PUBLIC_IPV6:-}" \
PUBLIC_IPV6
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
if [ ! -n "$PUBLIC_IPV6_EXITCODE" ]; then
# user hit ESC/cancel
exit
fi
@ -162,7 +163,7 @@ if [ -z "${PRIVATE_IPV6:-}" ]; then
fi
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
echo
echo "I could not determine the IP or IPv6 address of the network inteface"
echo "I could not determine the IP or IPv6 address of the network interface"
echo "for connecting to the Internet. Setup must stop."
echo
hostname -I
@ -197,7 +198,7 @@ fi
echo
echo "Primary Hostname: $PRIMARY_HOSTNAME"
echo "Public IP Address: $PUBLIC_IP"
if [ ! -z "$PUBLIC_IPV6" ]; then
if [ -n "$PUBLIC_IPV6" ]; then
echo "Public IPv6 Address: $PUBLIC_IPV6"
fi
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
@ -207,6 +208,6 @@ if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
echo "Private IPv6 Address: $PRIVATE_IPV6"
fi
if [ -f /usr/bin/git ] && [ -d .git ]; then
echo "Mail-in-a-Box Version: " $(git describe --always)
echo "Mail-in-a-Box Version: $(git describe --always)"
fi
echo

View File

@ -53,7 +53,7 @@ tools/editconf.py /etc/default/spampd \
# Spamassassin normally wraps spam as an attachment inside a fresh
# email with a report about the message. This also protects the user
# from accidentally openening a message with embedded malware.
# from accidentally opening a message with embedded malware.
#
# It's nice to see what rules caused the message to be marked as spam,
# but it's also annoying to get to the original message when it is an
@ -135,11 +135,11 @@ EOF
# the filemode in the config file.
tools/editconf.py /etc/spamassassin/local.cf -s \
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes \
bayes_path="$STORAGE_ROOT/mail/spamassassin/bayes" \
bayes_file_mode=0666
mkdir -p $STORAGE_ROOT/mail/spamassassin
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
mkdir -p "$STORAGE_ROOT/mail/spamassassin"
chown -R spampd:spampd "$STORAGE_ROOT/mail/spamassassin"
# To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll
# use the Dovecot antispam plugin to detect the message move operation and execute
@ -184,8 +184,8 @@ chmod a+x /usr/local/bin/sa-learn-pipe.sh
# Create empty bayes training data (if it doesn't exist). Once the files exist,
# ensure they are group-writable so that the Dovecot process has access.
sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
chmod -R 660 $STORAGE_ROOT/mail/spamassassin
chmod 770 $STORAGE_ROOT/mail/spamassassin
chmod -R 660 "$STORAGE_ROOT/mail/spamassassin"
chmod 770 "$STORAGE_ROOT/mail/spamassassin"
# Initial training?
# sa-learn --ham storage/mail/mailboxes/*/*/cur/

View File

@ -26,9 +26,9 @@ source /etc/mailinabox.conf # load global vars
# Show a status line if we are going to take any action in this file.
if [ ! -f /usr/bin/openssl ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ] \
|| [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ] \
|| [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|| [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ] \
|| [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ] \
|| [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
fi
@ -38,7 +38,7 @@ apt_install openssl
# Create a directory to store TLS-related things like "SSL" certificates.
mkdir -p $STORAGE_ROOT/ssl
mkdir -p "$STORAGE_ROOT/ssl"
# Generate a new private key.
#
@ -60,39 +60,39 @@ mkdir -p $STORAGE_ROOT/ssl
#
# Since we properly seed /dev/urandom in system.sh we should be fine, but I leave
# in the rest of the notes in case that ever changes.
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
if [ ! -f "$STORAGE_ROOT/ssl/ssl_private_key.pem" ]; then
# Set the umask so the key file is never world-readable.
(umask 077; hide_output \
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
openssl genrsa -out "$STORAGE_ROOT/ssl/ssl_private_key.pem" 2048)
fi
# Generate a self-signed SSL certificate because things like nginx, dovecot,
# etc. won't even start without some certificate in place, and we need nginx
# so we can offer the user a control panel to install a better certificate.
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
if [ ! -f "$STORAGE_ROOT/ssl/ssl_certificate.pem" ]; then
# Generate a certificate signing request.
CSR=/tmp/ssl_cert_sign_req-$$.csr
hide_output \
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CSR \
openssl req -new -key "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out $CSR \
-sha256 -subj "/CN=$PRIMARY_HOSTNAME"
# Generate the self-signed certificate.
CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem
hide_output \
openssl x509 -req -days 365 \
-in $CSR -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CERT
-in $CSR -signkey "$STORAGE_ROOT/ssl/ssl_private_key.pem" -out "$CERT"
# Delete the certificate signing request because it has no other purpose.
rm -f $CSR
# Symlink the certificate into the system certificate path, so system services
# can find it.
ln -s $CERT $STORAGE_ROOT/ssl/ssl_certificate.pem
ln -s "$CERT" "$STORAGE_ROOT/ssl/ssl_certificate.pem"
fi
# Generate some Diffie-Hellman cipher bits.
# openssl's default bit length for this is 1024 bits, but we'll create
# 2048 bits of bits per the latest recommendations.
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
if [ ! -f "$STORAGE_ROOT/ssl/dh2048.pem" ]; then
openssl dhparam -out "$STORAGE_ROOT/ssl/dh2048.pem" 2048
fi

View File

@ -46,7 +46,7 @@ fi
# in the first dialog prompt, so we should do this before that starts.
cat > /usr/local/bin/mailinabox << EOF;
#!/bin/bash
cd $(pwd)
cd $PWD
source setup/start.sh
EOF
chmod +x /usr/local/bin/mailinabox
@ -75,17 +75,17 @@ fi
# migration (schema) number for the files stored there, assume this is a fresh
# installation to that directory and write the file to contain the current
# migration number for this version of Mail-in-a-Box.
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
useradd -m $STORAGE_USER
if ! id -u "$STORAGE_USER" >/dev/null 2>&1; then
useradd -m "$STORAGE_USER"
fi
if [ ! -d $STORAGE_ROOT ]; then
mkdir -p $STORAGE_ROOT
if [ ! -d "$STORAGE_ROOT" ]; then
mkdir -p "$STORAGE_ROOT"
fi
f=$STORAGE_ROOT
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER:$STORAGE_USER $STORAGE_ROOT/mailinabox.version
if [ ! -f "$STORAGE_ROOT/mailinabox.version" ]; then
setup/migrate.py --current > "$STORAGE_ROOT/mailinabox.version"
chown "$STORAGE_USER:$STORAGE_USER" "$STORAGE_ROOT/mailinabox.version"
fi
# Save the global options in /etc/mailinabox.conf so that standalone
@ -122,7 +122,7 @@ source setup/munin.sh
# Wait for the management daemon to start...
until nc -z -w 4 127.0.0.1 10222
do
echo Waiting for the Mail-in-a-Box management daemon to start...
echo "Waiting for the Mail-in-a-Box management daemon to start..."
sleep 2
done
@ -142,41 +142,41 @@ source setup/firstuser.sh
# We'd let certbot ask the user interactively, but when this script is
# run in the recommended curl-pipe-to-bash method there is no TTY and
# certbot will fail if it tries to ask.
if [ ! -d $STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/ ]; then
if [ ! -d "$STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/" ]; then
echo
echo "-----------------------------------------------"
echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates"
echo "to enable HTTPS connections to your box. We're automatically"
echo "agreeing you to their subscriber agreement. See https://letsencrypt.org."
echo
certbot register --register-unsafely-without-email --agree-tos --config-dir $STORAGE_ROOT/ssl/lets_encrypt
certbot register --register-unsafely-without-email --agree-tos --config-dir "$STORAGE_ROOT/ssl/lets_encrypt"
fi
# Done.
echo
echo "-----------------------------------------------"
echo
echo Your Mail-in-a-Box is running.
echo "Your Mail-in-a-Box is running."
echo
echo Please log in to the control panel for further instructions at:
echo "Please log in to the control panel for further instructions at:"
echo
if management/status_checks.py --check-primary-hostname; then
# Show the nice URL if it appears to be resolving and has a valid certificate.
echo https://$PRIMARY_HOSTNAME/admin
echo "https://$PRIMARY_HOSTNAME/admin"
echo
echo "If you have a DNS problem put the box's IP address in the URL"
echo "(https://$PUBLIC_IP/admin) but then check the TLS fingerprint:"
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
| sed "s/SHA256 Fingerprint=//i"
else
echo https://$PUBLIC_IP/admin
echo "https://$PUBLIC_IP/admin"
echo
echo You will be alerted that the website has an invalid certificate. Check that
echo the certificate fingerprint matches:
echo "You will be alerted that the website has an invalid certificate. Check that"
echo "the certificate fingerprint matches:"
echo
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
openssl x509 -in "$STORAGE_ROOT/ssl/ssl_certificate.pem" -noout -fingerprint -sha256\
| sed "s/SHA256 Fingerprint=//i"
echo
echo Then you can confirm the security exception and continue.
echo "Then you can confirm the security exception and continue."
echo
fi

View File

@ -1,3 +1,4 @@
#!/bin/bash
source /etc/mailinabox.conf
source setup/functions.sh # load our functions
@ -11,8 +12,8 @@ source setup/functions.sh # load our functions
#
# First set the hostname in the configuration file, then activate the setting
echo $PRIMARY_HOSTNAME > /etc/hostname
hostname $PRIMARY_HOSTNAME
echo "$PRIMARY_HOSTNAME" > /etc/hostname
hostname "$PRIMARY_HOSTNAME"
# ### Fix permissions
@ -36,7 +37,7 @@ chmod g-w /etc /etc/default /usr
# - Check if the user intents to activate swap on next boot by checking fstab entries.
# - Check if a swapfile already exists
# - Check if the root file system is not btrfs, might be an incompatible version with
# swapfiles. User should hanle it them selves.
# swapfiles. User should handle it them selves.
# - Check the memory requirements
# - Check available diskspace
@ -53,14 +54,14 @@ if
[ -z "$SWAP_IN_FSTAB" ] &&
[ ! -e /swapfile ] &&
[ -z "$ROOT_IS_BTRFS" ] &&
[ $TOTAL_PHYSICAL_MEM -lt 1900000 ] &&
[ $AVAILABLE_DISK_SPACE -gt 5242880 ]
[ "$TOTAL_PHYSICAL_MEM" -lt 1900000 ] &&
[ "$AVAILABLE_DISK_SPACE" -gt 5242880 ]
then
echo "Adding a swap file to the system..."
# Allocate and activate the swap file. Allocate in 1KB chuncks
# Allocate and activate the swap file. Allocate in 1KB chunks
# doing it in one go, could fail on low memory systems
dd if=/dev/zero of=/swapfile bs=1024 count=$[1024*1024] status=none
dd if=/dev/zero of=/swapfile bs=1024 count=$((1024*1024)) status=none
if [ -e /swapfile ]; then
chmod 600 /swapfile
hide_output mkswap /swapfile
@ -106,7 +107,7 @@ hide_output add-apt-repository -y ppa:duplicity-team/duplicity-release-git
# of things from Ubuntu, as well as the directory of packages provide by the
# PPAs so we can install those packages later.
echo Updating system packages...
echo "Updating system packages..."
hide_output apt-get update
apt_get_quiet upgrade
@ -131,7 +132,7 @@ apt_get_quiet autoremove
# * bc: allows us to do math to compute sane defaults
# * openssh-client: provides ssh-keygen
echo Installing system packages...
echo "Installing system packages..."
apt_install python3 python3-dev python3-pip python3-setuptools \
netcat-openbsd wget curl git sudo coreutils bc file \
pollinate openssh-client unzip \
@ -160,7 +161,7 @@ fi
# not likely the user will want to change this, so we only ask on first
# setup.
if [ -z "${NONINTERACTIVE:-}" ]; then
if [ ! -f /etc/timezone ] || [ ! -z ${FIRST_TIME_SETUP:-} ]; then
if [ ! -f /etc/timezone ] || [ -n "${FIRST_TIME_SETUP:-}" ]; then
# If the file is missing or this is the user's first time running
# Mail-in-a-Box setup, run the interactive timezone configuration
# tool.
@ -213,7 +214,7 @@ fi
# issue any warnings if no entropy is actually available. (http://www.2uo.de/myths-about-urandom/)
# Entropy might not be readily available because this machine has no user input
# devices (common on servers!) and either no hard disk or not enough IO has
# ocurred yet --- although haveged tries to mitigate this. So there's a good chance
# occurred yet --- although haveged tries to mitigate this. So there's a good chance
# that accessing /dev/urandom will not be drawing from any hardware entropy and under
# a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom
# may not be seeded at all.
@ -222,7 +223,7 @@ fi
# hardware entropy to get going, by drawing from /dev/random. haveged makes this
# less likely to stall for very long.
echo Initializing system random number generator...
echo "Initializing system random number generator..."
dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null
# This is supposedly sufficient. But because we're not sure if hardware entropy
@ -266,11 +267,11 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
# settings, find the port it is supposedly running on, and open that port #NODOC
# too. #NODOC
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
if [ ! -z "$SSH_PORT" ]; then
if [ -n "$SSH_PORT" ]; then
if [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_limit $SSH_PORT #NODOC
echo "Opening alternate SSH port $SSH_PORT." #NODOC
ufw_limit "$SSH_PORT" #NODOC
fi
fi

View File

@ -6,9 +6,9 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Some Ubuntu images start off with Apache. Remove it since we
# will use nginx. Use autoremove to remove any Apache depenencies.
# will use nginx. Use autoremove to remove any Apache dependencies.
if [ -f /usr/sbin/apache2 ]; then
echo Removing apache...
echo "Removing apache..."
hide_output apt-get -y purge apache2 apache2-*
hide_output apt-get -y --purge autoremove
fi
@ -48,15 +48,15 @@ tools/editconf.py /etc/nginx/nginx.conf -s \
ssl_protocols="TLSv1.2 TLSv1.3;"
# Tell PHP not to expose its version number in the X-Powered-By header.
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
expose_php=Off
# Set PHPs default charset to UTF-8, since we use it. See #367.
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/php.ini -c ';' \
default_charset="UTF-8"
# Configure the path environment for php-fpm
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
env[PATH]=/usr/local/bin:/usr/bin:/bin \
# Configure php-fpm based on the amount of memory the machine has
@ -64,32 +64,32 @@ tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
# Some synchronisation issues can occur when many people access the site at once.
# The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)
if [ $TOTAL_PHYSICAL_MEM -lt 1000000 ]
if [ "$TOTAL_PHYSICAL_MEM" -lt 1000000 ]
then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=ondemand \
pm.max_children=8 \
pm.start_servers=2 \
pm.min_spare_servers=1 \
pm.max_spare_servers=3
elif [ $TOTAL_PHYSICAL_MEM -lt 2000000 ]
elif [ "$TOTAL_PHYSICAL_MEM" -lt 2000000 ]
then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=ondemand \
pm.max_children=16 \
pm.start_servers=4 \
pm.min_spare_servers=1 \
pm.max_spare_servers=6
elif [ $TOTAL_PHYSICAL_MEM -lt 3000000 ]
elif [ "$TOTAL_PHYSICAL_MEM" -lt 3000000 ]
then
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=dynamic \
pm.max_children=60 \
pm.start_servers=6 \
pm.min_spare_servers=3 \
pm.max_spare_servers=9
else
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
tools/editconf.py /etc/php/"$PHP_VER"/fpm/pool.d/www.conf -c ';' \
pm=dynamic \
pm.max_children=120 \
pm.start_servers=12 \
@ -140,16 +140,16 @@ cat conf/mta-sts.txt \
chmod a+r /var/lib/mailinabox/mta-sts.txt
# make a default homepage
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
mkdir -p $STORAGE_ROOT/www/default
if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
cp conf/www_default.html $STORAGE_ROOT/www/default/index.html
if [ -d "$STORAGE_ROOT/www/static" ]; then mv "$STORAGE_ROOT/www/static" "$STORAGE_ROOT/www/default"; fi # migration #NODOC
mkdir -p "$STORAGE_ROOT/www/default"
if [ ! -f "$STORAGE_ROOT/www/default/index.html" ]; then
cp conf/www_default.html "$STORAGE_ROOT/www/default/index.html"
fi
chown -R $STORAGE_USER $STORAGE_ROOT/www
chown -R "$STORAGE_USER" "$STORAGE_ROOT/www"
# Start services.
restart_service nginx
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm
# Open ports.
ufw_allow http

34
setup/webmail.sh Executable file → Normal file
View File

@ -24,8 +24,8 @@ PHP_VER=$(php_version)
echo "Installing Roundcube (webmail)..."
apt_install \
dbconfig-common \
php${PHP_VER}-cli php${PHP_VER}-sqlite3 php${PHP_VER}-intl php${PHP_VER}-common php${PHP_VER}-curl php${PHP_VER}-imap \
php${PHP_VER}-gd php${PHP_VER}-pspell php${PHP_VER}-mbstring libjs-jquery libjs-jquery-mousewheel libmagic1 \
php"${PHP_VER}"-cli php"${PHP_VER}"-sqlite3 php"${PHP_VER}"-intl php"${PHP_VER}"-common php"${PHP_VER}"-curl php"${PHP_VER}"-imap \
php"${PHP_VER}"-gd php"${PHP_VER}"-pspell php"${PHP_VER}"-mbstring libjs-jquery libjs-jquery-mousewheel libmagic1 \
sqlite3
# Install Roundcube from source if it is not already present or if it is out of date.
@ -38,9 +38,9 @@ apt_install \
# https://github.com/mstilkerich/rcmcarddav/releases
# The easiest way to get the package hashes is to run this script and get the hash from
# the error message.
VERSION=1.6.1
HASH=0e1c771ab83ea03bde1fd0be6ab5d09e60b4f293
PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.2
VERSION=1.6.6
HASH=7705d2736890c49e7ae3ac75e3ae00ba56187056
PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.3
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
CARDDAV_VERSION=4.4.3
CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644
@ -172,8 +172,8 @@ cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
EOF
# Create writable directories.
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
chown -R www-data:www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail "$STORAGE_ROOT/mail/roundcube"
chown -R www-data:www-data /var/log/roundcubemail /var/tmp/roundcubemail "$STORAGE_ROOT/mail/roundcube"
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
sudo -u www-data touch /var/log/roundcubemail/errors.log
@ -196,10 +196,10 @@ usermod -a -G dovecot www-data
# set permissions so that PHP can use users.sqlite
# could use dovecot instead of www-data, but not sure it matters
chown root:www-data $STORAGE_ROOT/mail
chmod 775 $STORAGE_ROOT/mail
chown root:www-data $STORAGE_ROOT/mail/users.sqlite
chmod 664 $STORAGE_ROOT/mail/users.sqlite
chown root:www-data "$STORAGE_ROOT/mail"
chmod 775 "$STORAGE_ROOT/mail"
chown root:www-data "$STORAGE_ROOT/mail/users.sqlite"
chmod 664 "$STORAGE_ROOT/mail/users.sqlite"
# Fix Carddav permissions:
chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
@ -207,9 +207,9 @@ chown -f -R root:www-data ${RCM_PLUGIN_DIR}/carddav
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
# Run Roundcube database migration script (database is created if it does not exist)
php$PHP_VER ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
php"$PHP_VER" ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
chown www-data:www-data "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite"
chmod 664 "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite"
# Patch the Roundcube code to eliminate an issue that causes postfix to reject our sqlite
# user database (see https://github.com/mail-in-a-box/mailinabox/issues/2185)
@ -219,8 +219,8 @@ sed -i.miabold 's/^[^#]\+.\+PRAGMA journal_mode = WAL.\+$/#&/' \
# Because Roundcube wants to set the PRAGMA we just deleted from the source, we apply it here
# to the roundcube database (see https://github.com/roundcube/roundcubemail/issues/8035)
# Database should exist, created by migration script
sqlite3 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite 'PRAGMA journal_mode=WAL;'
hide_output sqlite3 "$STORAGE_ROOT/mail/roundcube/roundcube.sqlite" 'PRAGMA journal_mode=WAL;'
# Enable PHP modules.
phpenmod -v $PHP_VER imap
restart_service php$PHP_VER-fpm
phpenmod -v "$PHP_VER" imap
restart_service php"$PHP_VER"-fpm

View File

@ -19,13 +19,13 @@ PHP_VER=$(php_version)
echo "Installing Z-Push (Exchange/ActiveSync server)..."
apt_install \
php${PHP_VER}-soap php${PHP_VER}-imap libawl-php php$PHP_VER-xml
php"${PHP_VER}"-soap php"${PHP_VER}"-imap libawl-php php"$PHP_VER"-xml php"${PHP_VER}"-intl
phpenmod -v $PHP_VER imap
phpenmod -v "$PHP_VER" imap
# Copy Z-Push into place.
VERSION=2.7.0
TARGETHASH=a520bbdc1d637c5aac379611053457edd54f2bf0
VERSION=2.7.3
TARGETHASH=9d4bec41935e9a4e07880c5ff915bcddbda4443b
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 #NODOC
@ -46,10 +46,10 @@ if [ $needs_update == 1 ]; then
# Create admin and top scripts with PHP_VER
rm -f /usr/sbin/z-push-{admin,top}
echo '#!/bin/bash' > /usr/sbin/z-push-admin
echo php$PHP_VER /usr/local/lib/z-push/z-push-admin.php '"$@"' >> /usr/sbin/z-push-admin
echo php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php '"$@"' >> /usr/sbin/z-push-admin
chmod 755 /usr/sbin/z-push-admin
echo '#!/bin/bash' > /usr/sbin/z-push-top
echo php$PHP_VER /usr/local/lib/z-push/z-push-top.php '"$@"' >> /usr/sbin/z-push-top
echo php"$PHP_VER" /usr/local/lib/z-push/z-push-top.php '"$@"' >> /usr/sbin/z-push-top
chmod 755 /usr/sbin/z-push-top
echo $VERSION > /usr/local/lib/z-push/version
@ -110,8 +110,8 @@ EOF
# Restart service.
restart_service php$PHP_VER-fpm
restart_service php"$PHP_VER"-fpm
# Fix states after upgrade
hide_output php$PHP_VER /usr/local/lib/z-push/z-push-admin.php -a fixstates
hide_output php"$PHP_VER" /usr/local/lib/z-push/z-push-admin.php -a fixstates

View File

@ -6,12 +6,12 @@
# try to log in to.
######################################################################
import sys, os, time, functools
import sys, os, time
# parse command line
if len(sys.argv) != 4:
print("Usage: tests/fail2ban.py \"ssh user@hostname\" hostname owncloud_user")
print('Usage: tests/fail2ban.py "ssh user@hostname" hostname owncloud_user')
sys.exit(1)
ssh_command, hostname, owncloud_user = sys.argv[1:4]
@ -24,7 +24,6 @@ socket.setdefaulttimeout(10)
class IsBlocked(Exception):
"""Tests raise this exception when it appears that a fail2ban
jail is in effect, i.e. on a connection refused error."""
pass
def smtp_test():
import smtplib
@ -33,13 +32,14 @@ def smtp_test():
server = smtplib.SMTP(hostname, 587)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
raise IsBlocked
server.starttls()
server.ehlo_or_helo_if_needed()
try:
server.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail")
msg = "authentication didn't fail"
raise Exception(msg)
except smtplib.SMTPAuthenticationError:
# athentication should fail
pass
@ -57,11 +57,12 @@ def imap_test():
M = imaplib.IMAP4_SSL(hostname)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
raise IsBlocked
try:
M.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail")
msg = "authentication didn't fail"
raise Exception(msg)
except imaplib.IMAP4.error:
# authentication should fail
pass
@ -75,17 +76,18 @@ def pop_test():
M = poplib.POP3_SSL(hostname)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
raise IsBlocked
try:
M.user('fakeuser')
try:
M.pass_('fakepassword')
except poplib.error_proto as e:
except poplib.error_proto:
# Authentication should fail.
M = None # don't .quit()
return
M.list()
raise Exception("authentication didn't fail")
msg = "authentication didn't fail"
raise Exception(msg)
finally:
if M:
M.quit()
@ -99,11 +101,12 @@ def managesieve_test():
M = imaplib.IMAP4(hostname, 4190)
except ConnectionRefusedError:
# looks like fail2ban worked
raise IsBlocked()
raise IsBlocked
try:
M.login("fakeuser", "fakepassword")
raise Exception("authentication didn't fail")
msg = "authentication didn't fail"
raise Exception(msg)
except imaplib.IMAP4.error:
# authentication should fail
pass
@ -129,17 +132,17 @@ def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'},
timeout=8,
verify=False) # don't bother with HTTPS validation, it may not be configured yet
except requests.exceptions.ConnectTimeout as e:
raise IsBlocked()
except requests.exceptions.ConnectTimeout:
raise IsBlocked
except requests.exceptions.ConnectionError as e:
if "Connection refused" in str(e):
raise IsBlocked()
raise IsBlocked
raise # some other unexpected condition
# return response status code
if r.status_code != expected_status:
r.raise_for_status() # anything but 200
raise IOError("Got unexpected status code %s." % r.status_code)
raise OSError("Got unexpected status code %s." % r.status_code)
# define how to run a test
@ -149,7 +152,7 @@ def restart_fail2ban_service(final=False):
if not final:
# Stop recidive jails during testing.
command += " && sudo fail2ban-client stop recidive"
os.system("%s \"%s\"" % (ssh_command, command))
os.system(f'{ssh_command} "{command}"')
def testfunc_runner(i, testfunc, *args):
print(i+1, end=" ", flush=True)
@ -163,7 +166,6 @@ def run_test(testfunc, args, count, within_seconds, parallel):
# run testfunc sequentially and still get to count requests within
# the required time. So we split the requests across threads.
import requests.exceptions
from multiprocessing import Pool
restart_fail2ban_service()
@ -179,7 +181,7 @@ def run_test(testfunc, args, count, within_seconds, parallel):
# Distribute the requests across the pool.
asyncresults = []
for i in range(count):
ar = p.apply_async(testfunc_runner, [i, testfunc] + list(args))
ar = p.apply_async(testfunc_runner, [i, testfunc, *list(args)])
asyncresults.append(ar)
# Wait for all runs to finish.

View File

@ -7,7 +7,7 @@
# where ipaddr is the IP address of your Mail-in-a-Box
# and hostname is the domain name to check the DNS for.
import sys, re, difflib
import sys, re
import dns.reversename, dns.resolver
if len(sys.argv) < 3:
@ -27,10 +27,10 @@ def test(server, description):
("ns2." + primary_hostname, "A", ipaddr),
("www." + hostname, "A", ipaddr),
(hostname, "MX", "10 " + primary_hostname + "."),
(hostname, "TXT", "\"v=spf1 mx -all\""),
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""),
(hostname, "TXT", '"v=spf1 mx -all"'),
("mail._domainkey." + hostname, "TXT", '"v=DKIM1; k=rsa; s=email; " "p=__KEY__"'),
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine;\""),
("_dmarc." + hostname, "TXT", '"v=DMARC1; p=quarantine;"'),
]
return test2(tests, server, description)
@ -59,14 +59,14 @@ def test2(tests, server, description):
response = ["[no value]"]
response = ";".join(str(r) for r in response)
response = re.sub(r"(\"p=).*(\")", r"\1__KEY__\2", response) # normalize DKIM key
response = response.replace("\"\" ", "") # normalize TXT records (DNSSEC signing inserts empty text string components)
response = response.replace('"" ', "") # normalize TXT records (DNSSEC signing inserts empty text string components)
# is it right?
if response == expected_answer:
#print(server, ":", qname, rtype, "?", response)
continue
# show prolem
# show problem
if first:
print("Incorrect DNS Response from", description)
print()
@ -98,7 +98,7 @@ else:
# And if that's OK, also check reverse DNS (the PTR record).
if not test_ptr("8.8.8.8", "Google Public DNS (Reverse DNS)"):
print ()
print ("The reverse DNS for %s is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for %s to %s." % (hostname, hostname, ipaddr))
print (f"The reverse DNS for {hostname} is not correct. Consult your ISP for how to set the reverse DNS (also called the PTR record) for {hostname} to {ipaddr}.")
sys.exit(1)
else:
print ("And the reverse DNS for the domain is correct.")

View File

@ -30,15 +30,11 @@ print("IMAP login is OK.")
# Attempt to send a mail to ourself.
mailsubject = "Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex
emailto = emailaddress
msg = """From: {emailaddress}
msg = f"""From: {emailaddress}
To: {emailto}
Subject: {subject}
Subject: {mailsubject}
This is a test message. It should be automatically deleted by the test script.""".format(
emailaddress=emailaddress,
emailto=emailto,
subject=mailsubject,
)
This is a test message. It should be automatically deleted by the test script."""
# Connect to the server on the SMTP submission TLS port.
server = smtplib.SMTP_SSL(host)

View File

@ -6,11 +6,11 @@ if len(sys.argv) < 3:
sys.exit(1)
host, toaddr, fromaddr = sys.argv[1:4]
msg = """From: %s
To: %s
msg = f"""From: {fromaddr}
To: {toaddr}
Subject: SMTP server test
This is a test message.""" % (fromaddr, toaddr)
This is a test message."""
server = smtplib.SMTP(host, 25)
server.set_debuglevel(1)

View File

@ -88,14 +88,14 @@ def sslyze(opts, port, ok_ciphers):
try:
# Execute SSLyze.
out = subprocess.check_output([SSLYZE] + common_opts + opts + [connection_string])
out = subprocess.check_output([SSLYZE, *common_opts, *opts, connection_string])
out = out.decode("utf8")
# Trim output to make better for storing in git.
if "SCAN RESULTS FOR" not in out:
# Failed. Just output the error.
out = re.sub("[\w\W]*CHECKING HOST\(S\) AVAILABILITY\n\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("[\w\W]*SCAN RESULTS FOR.*\n\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("[\\w\\W]*CHECKING HOST\\(S\\) AVAILABILITY\n\\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("[\\w\\W]*SCAN RESULTS FOR.*\n\\s*-+\n", "", out) # chop off header that shows the host we queried
out = re.sub("SCAN COMPLETED IN .*", "", out)
out = out.rstrip(" \n-") + "\n"
@ -105,8 +105,8 @@ def sslyze(opts, port, ok_ciphers):
# Pull out the accepted ciphers list for each SSL/TLS protocol
# version outputted.
accepted_ciphers = set()
for ciphers in re.findall(" Accepted:([\w\W]*?)\n *\n", out):
accepted_ciphers |= set(re.findall("\n\s*(\S*)", ciphers))
for ciphers in re.findall(" Accepted:([\\w\\W]*?)\n *\n", out):
accepted_ciphers |= set(re.findall("\n\\s*(\\S*)", ciphers))
# Compare to what Mozilla recommends, for a given modernness-level.
print(" Should Not Offer: " + (", ".join(sorted(accepted_ciphers-set(ok_ciphers))) or "(none -- good)"))
@ -142,7 +142,7 @@ for cipher in csv.DictReader(io.StringIO(urllib.request.urlopen("https://raw.git
client_compatibility = json.loads(urllib.request.urlopen("https://raw.githubusercontent.com/mail-in-a-box/user-agent-tls-capabilities/master/clients.json").read().decode("utf8"))
cipher_clients = { }
for client in client_compatibility:
if len(set(client['protocols']) & set(["TLS 1.0", "TLS 1.1", "TLS 1.2"])) == 0: continue # does not support TLS
if len(set(client['protocols']) & {"TLS 1.0", "TLS 1.1", "TLS 1.2"}) == 0: continue # does not support TLS
for cipher in client['ciphers']:
cipher_clients.setdefault(cipher_names.get(cipher), set()).add("/".join(x for x in [client['client']['name'], client['client']['version'], client['client']['platform']] if x))

View File

@ -1,9 +1,10 @@
#!/bin/bash
# Use this script to make an archive of the contents of all
# of the configuration files we edit with editconf.py.
for fn in `grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq`; do
for fn in $(grep -hr editconf.py setup | sed "s/tools\/editconf.py //" | sed "s/ .*//" | sort | uniq); do
echo ======================================================================
echo $fn
echo "$fn"
echo ======================================================================
cat $fn
cat "$fn"
done

View File

@ -3,4 +3,4 @@ POSTDATA=dummy
if [ "$1" == "--force" ]; then
POSTDATA=force=1
fi
curl -s -d $POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/dns/update
curl -s -d $POSTDATA --user "$(</var/lib/mailinabox/api.key):" http://127.0.0.1:10222/dns/update

View File

@ -30,7 +30,7 @@ import sys, re
# sanity check
if len(sys.argv) < 3:
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
print("usage: python3 editconf.py /etc/file.conf [-e] [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
sys.exit(1)
# parse command line arguments
@ -76,7 +76,7 @@ for setting in settings:
found = set()
buf = ""
with open(filename, "r") as f:
with open(filename, encoding="utf-8") as f:
input_lines = list(f)
while len(input_lines) > 0:
@ -84,7 +84,7 @@ while len(input_lines) > 0:
# If this configuration file uses folded lines, append any folded lines
# into our input buffer.
if folded_lines and line[0] not in (comment_char, " ", ""):
if folded_lines and line[0] not in {comment_char, " ", ""}:
while len(input_lines) > 0 and input_lines[0][0] in " \t":
line += input_lines.pop(0)
@ -93,9 +93,9 @@ while len(input_lines) > 0:
# Check if this line contain this setting from the command-line arguments.
name, val = settings[i].split("=", 1)
m = re.match(
"(\s*)"
+ "(" + re.escape(comment_char) + "\s*)?"
+ re.escape(name) + delimiter_re + "(.*?)\s*$",
r"(\s*)"
"(" + re.escape(comment_char) + r"\s*)?"
+ re.escape(name) + delimiter_re + r"(.*?)\s*$",
line, re.S)
if not m: continue
indent, is_comment, existing_val = m.groups()
@ -144,7 +144,7 @@ for i in range(len(settings)):
if not testing:
# Write out the new file.
with open(filename, "w") as f:
with open(filename, "w", encoding="utf-8") as f:
f.write(buf)
else:
# Just print the new file to stdout.

View File

@ -14,13 +14,13 @@ if [ -z "$1" ]; then
echo
echo "Available backups:"
echo
find $STORAGE_ROOT/owncloud-backup/* -maxdepth 0 -type d
find "$STORAGE_ROOT/owncloud-backup/"* -maxdepth 0 -type d
echo
echo "Supply the directory that was created during the last installation as the only commandline argument"
exit
fi
if [ ! -f $1/config.php ]; then
if [ ! -f "$1/config.php" ]; then
echo "This isn't a valid backup location"
exit 1
fi
@ -39,14 +39,14 @@ cp -r "$1/owncloud-install" /usr/local/lib/owncloud
# restore access rights
chmod 750 /usr/local/lib/owncloud/{apps,config}
cp "$1/owncloud.db" $STORAGE_ROOT/owncloud/
cp "$1/config.php" $STORAGE_ROOT/owncloud/
cp "$1/owncloud.db" "$STORAGE_ROOT/owncloud/"
cp "$1/config.php" "$STORAGE_ROOT/owncloud/"
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
chown -f -R www-data:www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
chown www-data:www-data $STORAGE_ROOT/owncloud/config.php
ln -sf "$STORAGE_ROOT/owncloud/config.php" /usr/local/lib/owncloud/config/config.php
chown -f -R www-data:www-data "$STORAGE_ROOT/owncloud" /usr/local/lib/owncloud
chown www-data:www-data "$STORAGE_ROOT/owncloud/config.php"
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off
sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ maintenance:mode --off
service php$PHP_VER-fpm start
echo "Done"

View File

@ -11,13 +11,13 @@ source /etc/mailinabox.conf # load global vars
ADMIN=$(./mail.py user admins | head -n 1)
test -z "$1" || ADMIN=$1
echo I am going to unlock admin features for $ADMIN.
echo You can provide another user to unlock as the first argument of this script.
echo "I am going to unlock admin features for $ADMIN."
echo "You can provide another user to unlock as the first argument of this script."
echo
echo WARNING: you could break mail-in-a-box when fiddling around with Nextcloud\'s admin interface
echo If in doubt, press CTRL-C to cancel.
echo "WARNING: you could break mail-in-a-box when fiddling around with Nextcloud's admin interface"
echo "If in doubt, press CTRL-C to cancel."
echo
echo Press enter to continue.
echo "Press enter to continue."
read
PHP_VER=$(php_version)
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ group:adduser admin $ADMIN && echo Done.
sudo -u www-data "php$PHP_VER" /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo "Done."

View File

@ -38,7 +38,7 @@ for date, ip in accesses:
# Since logs are rotated, store the statistics permanently in a JSON file.
# Load in the stats from an existing file.
if os.path.exists(outfn):
with open(outfn, "r") as f:
with open(outfn, encoding="utf-8") as f:
existing_data = json.load(f)
for date, count in existing_data:
if date not in by_date:
@ -51,5 +51,5 @@ by_date = sorted(by_date.items())
by_date.pop(-1)
# Write out.
with open(outfn, "w") as f:
with open(outfn, "w", encoding="utf-8") as f:
json.dump(by_date, f, sort_keys=True, indent=True)

View File

@ -1,2 +1,2 @@
#!/bin/bash
curl -s -d POSTDATA --user $(</var/lib/mailinabox/api.key): http://127.0.0.1:10222/web/update
curl -s -d POSTDATA --user "$(</var/lib/mailinabox/api.key):" http://127.0.0.1:10222/web/update