Compare commits

..

No commits in common. "main" and "v60.2" have entirely different histories.
main ... v60.2

21 changed files with 169 additions and 339 deletions

5
.gitattributes vendored
View file

@ -1,5 +0,0 @@
# All text should use Unix-style Line-endings
* text eol=lf
# Except mta-sts.txt (RFC 8461)
mta-sts.txt text eol=crlf

View file

@ -1,12 +1,6 @@
CHANGELOG
=========
Version 60.1 (October 30, 2022)
-------------------------------
* A setup issue where the DNS server nsd isn't running at the end of setup is (hopefully) fixed.
* Nextcloud is updated to 23.0.10 (contacts to 4.2.2, calendar to 3.5.1).
Version 60 (October 11, 2022)
-----------------------------
@ -23,7 +17,7 @@ No major features of Mail-in-a-Box have changed in this release, although some m
With the newer version of Ubuntu the following software packages we use are updated:
* dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug).
* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0).
* Nextcloud is upgraded to 23.0.4.
* Roundcube is upgraded to 1.6.0.
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
* fail2ban is upgraded to 0.11.2.

View file

@ -1,6 +1,5 @@
# Power Mail-in-a-Box
## **[Installation](#installation)** (current version: v60.5)
## **[Upgrading Quick Start](#upgrading)**
**[Installation](#installation)** (current version: v60.2)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/davness)
@ -81,21 +80,3 @@ sudo dpkg-reconfigure locales
```
curl -L https://power-mailinabox.net/setup.sh | sudo bash
```
# Upgrading
To upgrade an existing box to the latest version, run the same command as you do to perform a new installation:
```
curl -L https://power-mailinabox.net/setup.sh | sudo bash
```
## Installing or upgrading to a different version
If for some reason you wish to install a different version (for example, an older version for a workaround, or a beta/release candidate version for testing), you can use the following command.
```
curl -L https://power-mailinabox.net/<VERSION>/setup.sh | sudo bash
```
Where `<VERSION>` is the version you want to install. (**Example:** `v60.0`).
> ⚠️ **Downgrading might not always be possible and is not supported!** Make sure you know what you're doing before doing so.

3
Vagrantfile vendored
View file

@ -49,6 +49,9 @@ Vagrant.configure("2") do |config|
m.vm.network "private_network", ip: "192.168.168.#{ip+n}"
m.vm.provision "shell", :inline => <<-SH
# Make sure we have IPv6 loopback (::1)
sysctl -w net.ipv6.conf.lo.disable_ipv6=0
echo -e "fs.inotify.max_user_instances=1024\nnet.ipv6.conf.lo.disable_ipv6=0" > /etc/sysctl.conf
git config --global --add safe.directory /vagrant
# Set environment variables so that the setup script does

View file

@ -15,7 +15,7 @@ info:
license:
name: CC0 1.0 Universal
url: https://creativecommons.org/publicdomain/zero/1.0/legalcode
version: 60.5
version: 60.2
x-logo:
url: https://mailinabox.email/static/logo.png
altText: Mail-in-a-Box logo

View file

@ -1,4 +1,4 @@
version: STSv1
mode: MODE
mx: PRIMARY_HOSTNAME
max_age: 604800
version: STSv1
mode: MODE
mx: PRIMARY_HOSTNAME
max_age: 604800

View file

@ -240,9 +240,7 @@ def get_duplicity_target_url(config):
# via get_duplicity_additional_args. Move the first part of the
# path (the bucket name) into the hostname URL component, and leave
# the rest for the path.
target_bucket = target[2].lstrip('/').split('/', 1)
target[1] = target_bucket[0]
target[2] = target_bucket[1] if len(target_bucket) > 1 else ''
target[1], target[2] = target[2].lstrip('/').split('/', 1)
target = urlunsplit(target)

View file

@ -56,70 +56,71 @@ app = Flask(__name__,
# Decorator to protect views that require a user with 'admin' privileges.
def authorized_personnel_only(admin = True):
def gatekeeper(viewfunc):
@wraps(viewfunc)
def newview(*args, **kwargs):
# Authenticate the passed credentials, which is either the API key or a username:password pair
# and an optional X-Auth-Token token.
error = None
privs = []
def authorized_personnel_only(viewfunc):
try:
email, privs = auth_service.authenticate(request, env)
@wraps(viewfunc)
def newview(*args, **kwargs):
# Authenticate the passed credentials, which is either the API key or a username:password pair
# and an optional X-Auth-Token token.
error = None
privs = []
# Store the email address of the logged in user so it can be accessed
# from the API methods that affect the calling user.
request.user_email = email
request.user_privs = privs
try:
email, privs = auth_service.authenticate(request, env)
except ValueError as e:
# Write a line in the log recording the failed login, unless no authorization header
# was given which can happen on an initial request before a 403 response.
if "Authorization" in request.headers:
log_failed_login(request)
if not admin or "admin" in privs:
return viewfunc(*args, **kwargs)
else:
error = "You are not an administrator."
except ValueError as e:
# Write a line in the log recording the failed login, unless no authorization header
# was given which can happen on an initial request before a 403 response.
if "Authorization" in request.headers:
log_failed_login(request)
# Authentication failed.
error = str(e)
# Authentication failed.
error = str(e)
# Authorized to access an API view?
if "admin" in privs:
# Store the email address of the logged in user so it can be accessed
# from the API methods that affect the calling user.
request.user_email = email
request.user_privs = privs
# 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),
'X-Reason': error,
}
# Call view func.
return viewfunc(*args, **kwargs)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# Don't issue a 401 to an AJAX request because the user will
# be prompted for credentials, which is not helpful.
status = 403
headers = None
if not error:
error = "You are not an administrator."
if request.headers.get('Accept') in (None, "", "*/*"):
# Return plain text output.
return Response(error + "\n",
status=status,
mimetype='text/plain',
headers=headers)
else:
# Return JSON output.
return Response(json.dumps({
"status": "error",
"reason": error,
}) + "\n",
status=status,
mimetype='application/json',
headers=headers)
# 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),
'X-Reason': error,
}
return newview
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# Don't issue a 401 to an AJAX request because the user will
# be prompted for credentials, which is not helpful.
status = 403
headers = None
return gatekeeper
if request.headers.get('Accept') in (None, "", "*/*"):
# Return plain text output.
return Response(error + "\n",
status=status,
mimetype='text/plain',
headers=headers)
else:
# Return JSON output.
return Response(json.dumps({
"status": "error",
"reason": error,
}) + "\n",
status=status,
mimetype='application/json',
headers=headers)
return newview
@app.errorhandler(401)
@ -212,7 +213,7 @@ def logout():
@app.route('/mail/users')
@authorized_personnel_only()
@authorized_personnel_only
def mail_users():
if request.args.get("format", "") == "json":
return json_response(get_mail_users_ex(env, with_archived=True))
@ -221,7 +222,7 @@ def mail_users():
@app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_users_add():
quota = request.form.get('quota', get_default_quota(env))
try:
@ -233,7 +234,7 @@ def mail_users_add():
@app.route('/mail/users/quota', methods=['GET'])
@authorized_personnel_only()
@authorized_personnel_only
def get_mail_users_quota():
email = request.values.get('email', '')
quota = get_mail_quota(email, env)
@ -245,7 +246,7 @@ def get_mail_users_quota():
@app.route('/mail/users/quota', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_users_quota():
try:
return set_mail_quota(request.form.get('email', ''),
@ -255,13 +256,8 @@ def mail_users_quota():
@app.route('/mail/users/password', methods=['POST'])
@authorized_personnel_only(admin = False)
@authorized_personnel_only
def mail_users_password():
if "admin" not in request.user_privs:
# Non-admins can only change their own password.
if request.form.get('email', '') != request.user_email:
return ("You are not an administrator; you can only change your own password!", 403)
try:
return set_mail_password(request.form.get('email', ''),
request.form.get('password', ''), env)
@ -270,13 +266,13 @@ def mail_users_password():
@app.route('/mail/users/remove', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_users_remove():
return remove_mail_user(request.form.get('email', ''), env)
@app.route('/mail/users/privileges')
@authorized_personnel_only()
@authorized_personnel_only
def mail_user_privs():
privs = get_mail_user_privileges(request.args.get('email', ''), env)
if isinstance(privs, tuple):
@ -285,7 +281,7 @@ def mail_user_privs():
@app.route('/mail/users/privileges/add', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_user_privs_add():
return add_remove_mail_user_privilege(request.form.get('email', ''),
request.form.get('privilege', ''),
@ -293,7 +289,7 @@ def mail_user_privs_add():
@app.route('/mail/users/privileges/remove', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_user_privs_remove():
return add_remove_mail_user_privilege(request.form.get('email', ''),
request.form.get('privilege', ''),
@ -301,7 +297,7 @@ def mail_user_privs_remove():
@app.route('/mail/aliases')
@authorized_personnel_only()
@authorized_personnel_only
def mail_aliases():
if request.args.get("format", "") == "json":
return json_response(get_mail_aliases_ex(env))
@ -312,7 +308,7 @@ def mail_aliases():
@app.route('/mail/aliases/add', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_aliases_add():
return add_mail_alias(request.form.get('address', ''),
request.form.get('forwards_to', ''),
@ -323,13 +319,13 @@ def mail_aliases_add():
@app.route('/mail/aliases/remove', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def mail_aliases_remove():
return remove_mail_alias(request.form.get('address', ''), env)
@app.route('/mail/domains')
@authorized_personnel_only()
@authorized_personnel_only
def mail_domains():
return "".join(x + "\n" for x in get_mail_domains(env))
@ -338,14 +334,14 @@ def mail_domains():
@app.route('/dns/zones')
@authorized_personnel_only()
@authorized_personnel_only
def dns_zones():
from dns_update import get_dns_zones
return json_response([z[0] for z in get_dns_zones(env)])
@app.route('/dns/update', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def dns_update():
from dns_update import do_dns_update
try:
@ -355,7 +351,7 @@ def dns_update():
@app.route('/dns/secondary-nameserver')
@authorized_personnel_only()
@authorized_personnel_only
def dns_get_secondary_nameserver():
from dns_update import get_custom_dns_config, get_secondary_dns
return json_response({
@ -365,7 +361,7 @@ def dns_get_secondary_nameserver():
@app.route('/dns/secondary-nameserver', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def dns_set_secondary_nameserver():
from dns_update import set_secondary_dns
try:
@ -379,7 +375,7 @@ def dns_set_secondary_nameserver():
@app.route('/dns/custom')
@authorized_personnel_only()
@authorized_personnel_only
def dns_get_records(qname=None, rtype=None):
# Get the current set of custom DNS records.
from dns_update import get_custom_dns_config, get_dns_zones
@ -435,7 +431,7 @@ def dns_get_records(qname=None, rtype=None):
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
@app.route('/dns/custom/<qname>/<rtype>',
methods=['GET', 'POST', 'PUT', 'DELETE'])
@authorized_personnel_only()
@authorized_personnel_only
def dns_set_record(qname, rtype="A"):
from dns_update import do_dns_update, set_custom_dns_record
try:
@ -502,14 +498,14 @@ def dns_set_record(qname, rtype="A"):
@app.route('/dns/dump')
@authorized_personnel_only()
@authorized_personnel_only
def dns_get_dump():
from dns_update import build_recommended_dns
return json_response(build_recommended_dns(env))
@app.route('/dns/zonefile/<zone>')
@authorized_personnel_only()
@authorized_personnel_only
def dns_get_zonefile(zone):
from dns_update import get_dns_zonefile
return Response(get_dns_zonefile(zone, env),
@ -521,7 +517,7 @@ def dns_get_zonefile(zone):
@app.route('/ssl/status')
@authorized_personnel_only()
@authorized_personnel_only
def ssl_get_status():
from ssl_certificates import get_certificates_to_provision
from web_update import get_web_domains_info, get_web_domains
@ -561,7 +557,7 @@ def ssl_get_status():
@app.route('/ssl/csr/<domain>', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def ssl_get_csr(domain):
from ssl_certificates import create_csr
ssl_private_key = os.path.join(
@ -571,7 +567,7 @@ def ssl_get_csr(domain):
@app.route('/ssl/install', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def ssl_install_cert():
from web_update import get_web_domains
from ssl_certificates import install_cert
@ -584,7 +580,7 @@ def ssl_install_cert():
@app.route('/ssl/provision', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def ssl_provision_certs():
from ssl_certificates import provision_certificates
requests = provision_certificates(env, limit_domains=None)
@ -595,7 +591,7 @@ def ssl_provision_certs():
@app.route('/mfa/status', methods=['POST'])
@authorized_personnel_only(admin = False)
@authorized_personnel_only
def mfa_get_status():
# Anyone accessing this route is an admin, and we permit them to
# see the MFA status for any user if they submit a 'user' form
@ -603,9 +599,6 @@ def mfa_get_status():
# only provision for themselves.
# user field if given, otherwise the user making the request
email = request.form.get('user', request.user_email)
if "admin" not in request.user_privs and email != request.user_email:
return ("You are not an administrator; you can only view your own MFA status!", 403)
try:
resp = {"enabled_mfa": get_public_mfa_state(email, env)}
if email == request.user_email:
@ -616,7 +609,7 @@ def mfa_get_status():
@app.route('/mfa/totp/enable', methods=['POST'])
@authorized_personnel_only(admin = False)
@authorized_personnel_only
def totp_post_enable():
secret = request.form.get('secret')
token = request.form.get('token')
@ -632,16 +625,13 @@ def totp_post_enable():
@app.route('/mfa/disable', methods=['POST'])
@authorized_personnel_only(admin = False)
@authorized_personnel_only
def totp_post_disable():
# Anyone accessing this route is an admin, and we permit them to
# disable the MFA status for any user if they submit a 'user' form
# field.
# user field if given, otherwise the user making the request
email = request.form.get('user', request.user_email)
if "admin" not in request.user_privs and email != request.user_email:
return ("You are not an administrator; you can only view your own MFA status!", 403)
try:
result = disable_mfa(email,
request.form.get('mfa-id') or None,
@ -658,14 +648,14 @@ def totp_post_disable():
@app.route('/web/domains')
@authorized_personnel_only()
@authorized_personnel_only
def web_get_domains():
from web_update import get_web_domains_info
return json_response(get_web_domains_info(env))
@app.route('/web/update', methods=['POST'])
@authorized_personnel_only()
@authorized_personnel_only
def web_update():
from web_update import do_web_update
try:
@ -678,7 +668,7 @@ def web_update():
@app.route('/system/version', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def system_version():
from status_checks import what_version_is_this
try:
@ -688,7 +678,7 @@ def system_version():
@app.route('/system/latest-upstream-version', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def system_latest_upstream_version():
from status_checks import get_latest_miab_version
try:
@ -698,7 +688,7 @@ def system_latest_upstream_version():
@app.route('/system/status', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def system_status():
from status_checks import run_checks
@ -746,7 +736,7 @@ def system_status():
@app.route('/system/updates')
@authorized_personnel_only()
@authorized_personnel_only
def show_updates():
from status_checks import list_apt_updates
return "".join("%s (%s)\n" % (p["package"], p["version"])
@ -754,7 +744,7 @@ def show_updates():
@app.route('/system/update-packages', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def do_updates():
utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"])
return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"],
@ -762,7 +752,7 @@ def do_updates():
@app.route('/system/reboot', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def needs_reboot():
from status_checks import is_reboot_needed_due_to_package_installation
if is_reboot_needed_due_to_package_installation():
@ -772,7 +762,7 @@ def needs_reboot():
@app.route('/system/reboot', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def do_reboot():
# To keep the attack surface low, we don't allow a remote reboot if one isn't necessary.
from status_checks import is_reboot_needed_due_to_package_installation
@ -784,7 +774,7 @@ def do_reboot():
@app.route('/system/backup/status')
@authorized_personnel_only()
@authorized_personnel_only
def backup_status():
from backup import backup_status
try:
@ -794,14 +784,14 @@ def backup_status():
@app.route('/system/backup/config', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def backup_get_custom():
from backup import get_backup_config
return json_response(get_backup_config(env, for_ui=True))
@app.route('/system/backup/config', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def backup_set_custom():
from backup import backup_set_custom
return json_response(
@ -813,7 +803,7 @@ def backup_set_custom():
@app.route('/system/backup/new', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def backup_new():
from backup import perform_backup, get_backup_config
@ -827,14 +817,14 @@ def backup_new():
@app.route('/system/privacy', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def privacy_status_get():
config = utils.load_settings(env)
return json_response(config.get("privacy", True))
@app.route('/system/privacy', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def privacy_status_set():
config = utils.load_settings(env)
config["privacy"] = (request.form.get('value') == "private")
@ -843,7 +833,7 @@ def privacy_status_set():
@app.route('/system/smtp/relay', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def smtp_relay_get():
config = utils.load_settings(env)
@ -874,7 +864,7 @@ def smtp_relay_get():
@app.route('/system/smtp/relay', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def smtp_relay_set():
from editconf import edit_conf
from os import chmod
@ -886,39 +876,30 @@ def smtp_relay_set():
newconf = request.form
# Is DKIM configured?
sel = newconf.get("dkim_selector", "")
rr = newconf.get("dkim_rr", "")
check_dkim = True
sel = newconf.get("dkim_selector")
if sel is None or sel.strip() == "":
config["SMTP_RELAY_DKIM_SELECTOR"] = None
# Check that the key RR doesn't exist either, otherwise we cannot be
# sure that the user wants to remove it.
if rr.strip() != "":
return ("Cannot publish a DKIM key without a selector!\n\
If you want to set up a relay without a DKIM record, both the selector and the key need to be empty.", 400)
config["SMTP_RELAY_DKIM_RR"] = None
check_dkim = False
elif re.fullmatch(r"[a-z\d\._][a-z\d\._\-]*", sel.strip()) is None:
return ("The DKIM selector is invalid!", 400)
if check_dkim:
# DKIM selector looks good, try processing the RR
if rr.strip() == "":
return ("Cannot publish a selector with an empty key!\n\
If you want to set up a relay without a DKIM record, both the selector and the key need to be empty.", 400)
# DKIM selector looks good, try processing the RR
rr = newconf.get("dkim_rr", "")
if rr.strip() == "":
return ("Cannot publish a selector with an empty key!", 400)
components = {}
for r in re.split(r"[;\s]+", rr):
sp = re.split(r"\=", r)
if len(sp) != 2:
return ("DKIM public key RR is malformed!", 400)
components[sp[0]] = sp[1]
components = {}
for r in re.split(r"[;\s]+", rr):
sp = re.split(r"\=", r)
if len(sp) != 2:
return ("DKIM public key RR is malformed!", 400)
components[sp[0]] = sp[1]
if not components.get("p"):
return ("The DKIM public key doesn't exist!", 400)
if not components.get("p"):
return ("The DKIM public key doesn't exist!", 400)
config["SMTP_RELAY_DKIM_SELECTOR"] = sel
config["SMTP_RELAY_DKIM_RR"] = components
config["SMTP_RELAY_DKIM_SELECTOR"] = sel
config["SMTP_RELAY_DKIM_RR"] = components
relay_on = False
implicit_tls = False
@ -937,7 +918,7 @@ def smtp_relay_set():
implicit_tls = True
except ssl.SSLError as sle:
# Couldn't connect via TLS, configure Postfix to send via STARTTLS
pass
print(sle.reason)
except (socket.herror, socket.gaierror) as he:
return (
f"Unable to resolve hostname (it probably is incorrect): {he.strerror}",
@ -1014,7 +995,7 @@ def smtp_relay_set():
@app.route('/system/pgp/', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def get_keys():
from pgp import get_daemon_key, get_imported_keys, key_representation
return {
@ -1024,7 +1005,7 @@ def get_keys():
@app.route('/system/pgp/<fpr>', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def get_key(fpr):
from pgp import get_key, key_representation
k = get_key(fpr)
@ -1034,7 +1015,7 @@ def get_key(fpr):
@app.route('/system/pgp/<fpr>', methods=["DELETE"])
@authorized_personnel_only()
@authorized_personnel_only
def delete_key(fpr):
from pgp import delete_key
from wkd import parse_wkd_list, build_wkd
@ -1049,7 +1030,7 @@ def delete_key(fpr):
@app.route('/system/pgp/<fpr>/export', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def export_key(fpr):
from pgp import export_key
exp = export_key(fpr)
@ -1059,7 +1040,7 @@ def export_key(fpr):
@app.route('/system/pgp/import', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def import_key():
from pgp import import_key
from wkd import build_wkd
@ -1084,7 +1065,7 @@ def import_key():
@app.route('/system/pgp/wkd', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def get_wkd_status():
from pgp import get_daemon_key, get_imported_keys, key_representation
from wkd import get_user_fpr_maps, get_wkd_config
@ -1118,7 +1099,7 @@ def get_wkd_status():
@app.route('/system/pgp/wkd', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def update_wkd():
from wkd import update_wkd_config, build_wkd
update_wkd_config(request.form)
@ -1127,7 +1108,7 @@ def update_wkd():
@app.route('/system/default-quota', methods=["GET"])
@authorized_personnel_only()
@authorized_personnel_only
def default_quota_get():
if request.values.get('text'):
return get_default_quota(env)
@ -1138,7 +1119,7 @@ def default_quota_get():
@app.route('/system/default-quota', methods=["POST"])
@authorized_personnel_only()
@authorized_personnel_only
def default_quota_set():
config = utils.load_settings(env)
try:
@ -1156,7 +1137,7 @@ def default_quota_set():
@app.route('/munin/')
@authorized_personnel_only()
@authorized_personnel_only
def munin_start():
# Munin pages, static images, and dynamically generated images are served
# outside of the AJAX API. We'll start with a 'start' API that sets a cookie

View file

@ -116,16 +116,7 @@ def do_dns_update(env, force=False):
# Tell nsd to reload changed zone files.
if len(updated_domains) > 0:
# 'reconfig' is needed if there are added or removed zones, but
# it may not reload existing zones, so we call 'reload' too. If
# nsd isn't running, nsd-control fails, so in that case revert
# to restarting nsd to make sure it is running. Restarting nsd
# should also refresh everything.
try:
shell('check_call', ["/usr/sbin/nsd-control", "reconfig"])
shell('check_call', ["/usr/sbin/nsd-control", "reload"])
except:
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
shell('check_call', ["/usr/sbin/nsd-control", "reload"])
# Write the OpenDKIM configuration tables for all of the mail domains.
from mailconfig import get_mail_domains

View file

@ -1425,7 +1425,7 @@ def get_latest_miab_version():
return re.search(
b'TAG=(.*)',
urlopen(
"https://power-mailinabox.net/setup.sh",
"https://raw.githubusercontent.com/ddavness/power-mailinabox/main/setup/bootstrap.sh",
timeout=5).read()).group(1).decode("utf8")
except (HTTPError, URLError, timeout):
return None

View file

@ -135,13 +135,6 @@
Monitoring</a></li>
</ul>
</li>
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-not-admin">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Your Account</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#manage-password" onclick="return show_panel(this);">Manage Password</a></li>
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
</ul>
</li>
<li class="nav-item me-1 me-xl-4 btn if-logged-in-not-admin" type="button" href="#mail-guide"
onclick="return show_panel(this);">
Mail Guide
@ -205,10 +198,6 @@
{% include "wkd.html" %}
</div>
<div id="panel_manage-password" class="admin_panel">
{% include "manage-password.html" %}
</div>
<div id="panel_mfa" class="admin_panel">
{% include "mfa.html" %}
</div>

View file

@ -1,57 +0,0 @@
<div>
<h2>Manage Password</h2>
<p>Here you can change your account password. The new password is then valid for both this panel and your email.</p>
<p>If you have client emails configured, you'll then need to update the configuration with the new password. See the <a href="#mail-guide" onclick="return show_panel(this);">Mail Guide</a> for more information about this.</p>
<form class="form-horizontal" role="form" onsubmit="set_password_self(); return false;">
<div class="col-lg-10 col-xl-8 mb-3">
<div class="input-group">
<label for="manage-password-new" class="input-group-text col-3">New Password</label>
<input type="password" placeholder="password" class="form-control" id="manage-password-new">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3">
<div class="input-group">
<label for="manage-password-confirm" class="input-group-text col-3">Confirm Password</label>
<input type="password" placeholder="password" class="form-control" id="manage-password-confirm">
</div>
</div>
<div class="mt-3">
<button id="manage-password-submit" type="submit" class="btn btn-primary">Save</button>
</div>
<small>After changing your password, you'll be logged out from the account and will need to log in again.</small>
</form>
</div>
<script>
function set_password_self() {
if ($('#manage-password-new').val() !== $('#manage-password-confirm').val()) {
show_modal_error("Set Password", 'Passwords do not match!');
return;
}
let password = $('#manage-password-new').val()
api(
"/mail/users/password",
"POST",
{
email: api_credentials.username,
password: password
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Set Password", $("<pre/>").text(r), () => {
do_logout()
$('#manage-password-new').val("")
$('#manage-password-confirm').val("")
});
},
function (r) {
show_modal_error("Set Password", r);
}
);
}
</script>

View file

@ -78,7 +78,7 @@
<h3>DKIM Configuration</h3>
<p>DKIM allows receivers to verify that the email was sent by the relay you configured (this is, somebody you
trust). <b>If your relay provider does not provide you with this information, it's probably safe to skip this step.</b></p>
trust). <b>Not doing so will have your email sent to spam.</b></p>
<div class="col-lg-6 col-md-8 col-12">
<div class="input-group">

View file

@ -304,15 +304,6 @@
$("#backup-target-type").val("s3");
var hostpath = r.target.substring(5).split('/');
var host = hostpath.shift();
let s3_options = $("#backup-target-s3-host-select option").map(function() {return this.value}).get()
$("#backup-target-s3-host-select").val("other")
for (let h of s3_options) {
console.log(h)
if (h == host) {
$("#backup-target-s3-host-select").val(host)
break
}
}
$("#backup-target-s3-host").val(host);
$("#backup-target-s3-path").val(hostpath.join('/'));
} else if (r.target.substring(0, 5) == "b2://") {
@ -374,18 +365,18 @@
}
function init_inputs(target_type) {
function set_host(host, overwrite_other) {
function set_host(host) {
if (host !== 'other') {
$("#backup-target-s3-host").val(host);
} else if (overwrite_other) {
} else {
$("#backup-target-s3-host").val('');
}
}
if (target_type == "s3") {
$('#backup-target-s3-host-select').off('change').on('change', function () {
set_host($('#backup-target-s3-host-select').val(), true);
set_host($('#backup-target-s3-host-select').val());
});
set_host($('#backup-target-s3-host-select').val(), false);
set_host($('#backup-target-s3-host-select').val());
}
}

View file

@ -36,7 +36,7 @@ if [ -z "$TAG" ]; then
[ "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ] ||
[ "$(echo $OS | grep -o 'Ubuntu 22.04')" == "Ubuntu 22.04" ]
then
TAG=v60.5
TAG=v60.2
elif [ "$OS" == "Debian GNU/Linux 10 (buster)" ]; then
echo "We are going to install the last version of Power Mail-in-a-Box supporting Debian 10 (buster)."
echo "IF THIS IS A NEW INSTALLATION, STOP NOW, AND USE A SUPPORTED DISTRIBUTION INSTEAD (ONE OF THESE):"
@ -86,7 +86,7 @@ if [ ! -d $HOME/mailinabox ]; then
echo Downloading Mail-in-a-Box $TAG. . .
git clone \
-b $TAG --depth 1 \
https://git.nibbletools.com/beenull/power-mailinabox \
https://github.com/ddavness/power-mailinabox \
$HOME/mailinabox \
< /dev/null 2> /dev/null

View file

@ -25,20 +25,10 @@ if [ ! -f $db_path ]; then
echo "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);" | 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;
else
sql=$(sqlite3 $db_path "SELECT sql FROM sqlite_master WHERE name = 'users'");
if echo $sql | grep --invert-match quota; then
echo "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';" | sqlite3 $db_path;
fi
elif sqlite3 $db_path ".schema users" | grep --invert-match quota; then
echo "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';" | sqlite3 $db_path;
fi
# Recover the database if it was hit by the Roundcube password changer "bug" (#85)
# If the journal_mode is set to wal, postfix cannot read it and we wouldn't
# be able to send or receive mail.
#
# This operation is idempotent so it's safe to run even in healthy databases, too.
echo "PRAGMA journal_mode=delete;" | sqlite3 $db_path > /dev/null
# ### User Authentication
# Have Dovecot query our database, and not system users, for authentication.

View file

@ -81,13 +81,12 @@ mkdir -p $assets_dir
# jQuery CDN URL
jquery_version=3.6.1
jquery_url=https://code.jquery.com # Check this link for new versions
jquery_url=https://code.jquery.com
# Get jQuery
wget_verify $jquery_url/jquery-$jquery_version.min.js ea61688671d0c3044f2c5b2f2c4af0a6620ac6c2 $assets_dir/jquery.min.js
# Bootstrap CDN URL
# See https://github.com/twbs/bootstrap/releases to check for new versions
bootstrap_version=5.2.2
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
@ -98,12 +97,11 @@ mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
rm -f /tmp/bootstrap.zip
# FontAwesome CDN URL
# See https://github.com/FortAwesome/Font-Awesome/releases to check for new versions
fontawesome_version=6.2.1
fontawesome_version=6.2.0
fontawesome_url=https://github.com/FortAwesome/Font-Awesome/releases/download/$fontawesome_version/fontawesome-free-$fontawesome_version-web.zip
# Get FontAwesome
wget_verify $fontawesome_url cd0f2bcc9653b56e3e2dd82d6598aa6bbca8d796 /tmp/fontawesome.zip
wget_verify $fontawesome_url cd6250deeb38ab707240200c573d2357eaf732a0 /tmp/fontawesome.zip
unzip -q /tmp/fontawesome.zip -d $assets_dir
mv $assets_dir/fontawesome-free-$fontawesome_version-web $assets_dir/fontawesome
rm -f /tmp/fontawesome.zip

View file

@ -21,8 +21,8 @@ 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=24.0.7
nextcloud_hash=7fb1afeb3c212bf5530c3d234b23bf314b47655a
nextcloud_ver=24.0.6
nextcloud_hash=68366ddf16966acf532b3d0349a78ac2ade8269c
# Nextcloud apps
# --------------
@ -35,8 +35,8 @@ nextcloud_hash=7fb1afeb3c212bf5530c3d234b23bf314b47655a
# copying it from the error message when it doesn't match what is below.
contacts_ver=4.2.2
contacts_hash=cbab9a7acdc11a9e2779c20b850bb21faec1c80f
calendar_ver=3.5.2
calendar_hash=dcf2cba6933dc8805ca4b4d04ed7b993ff4652a1
calendar_ver=3.5.0
calendar_hash=0938ffc4880cfdd74dd2e281eed96aa1f13fd065
user_external_ver=3.0.0
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
@ -168,28 +168,10 @@ InstallNextcloud() {
# $STORAGE_ROOT/owncloud is kept together even during a backup. It is better to rely on config.php than
# version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud
# application version than the database.
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
#
# Config unlocking, power-mailinabox#86
# If a configuration file already exists, remove the "readonly" tag before starting the upgrade. This is
# necessary (otherwise upgrades will fail).
#
# The lock will be re-applied further down the line when it's safe to do so.
CONFIG_TEMP=$(/bin/mktemp)
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
CURRENT_NEXTCLOUD_VER=$(php -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
# Unlock configuration directory for upgrades
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
<?php
include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['config_is_read_only'] = false;
echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG);
echo ";";
?>
EOF
else
CURRENT_NEXTCLOUD_VER=""
fi
@ -364,6 +346,7 @@ fi
# the correct domain name if the domain is being change from the previous setup.
# 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 <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
<?php
include("$STORAGE_ROOT/owncloud/config.php");

View file

@ -49,12 +49,12 @@ fi
# Put a start script in a global location. We tell the user to run 'mailinabox'
# in the first dialog prompt, so we should do this before that starts.
cat > /usr/local/sbin/mailinabox << EOF;
cat > /usr/local/bin/mailinabox << EOF;
#!/bin/bash
cd $(pwd)
source setup/start.sh
EOF
chmod 744 /usr/local/sbin/mailinabox
chmod +x /usr/local/bin/mailinabox
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,
# if values have not already been set in environment variables. When running
@ -129,14 +129,6 @@ source setup/zpush.sh
source setup/management.sh
source setup/munin.sh
# Create a shorthand alias for the cli interface
cat > /usr/local/sbin/miabadm << EOF;
#!/bin/bash
cd $(pwd)
/usr/bin/env python3 management/cli.py \$@
EOF
chmod 744 /usr/local/sbin/miabadm
# Wait for the management daemon to start...
until nc -z -w 4 127.0.0.1 10222
do

View file

@ -20,7 +20,7 @@ hostname $PRIMARY_HOSTNAME
# the loopback interface to also work on IPv6 (that is, we want :: to be available). This
# is required because apparently nsd expects this to exist.
management/editconf.py /etc/sysctl.conf "net.ipv6.conf.lo.disable_ipv6 = 0"
management/editconf.py /etc/sysctl.conf "net.ipv6.conf.all.disable_ipv6 = 0"
hide_output sysctl --system
# ### Fix permissions

View file

@ -30,8 +30,8 @@ apt_install \
# whether we have the latest version of everything.
# For the latest versions, see:
# https://github.com/roundcube/roundcubemail/releases
# https://github.com/mfreiholz/persistent_login/
# https://github.com/stremlau/html5_notifier/
# https://github.com/mfreiholz/persistent_login/commits/master
# https://github.com/stremlau/html5_notifier/commits/master
# 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.
@ -39,8 +39,8 @@ VERSION=1.6.0
HASH=fd84b4fac74419bb73e7a3bcae1978d5589c52de
PERSISTENT_LOGIN_VERSION=version-5.3.0
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
CARDDAV_VERSION=4.4.4
CARDDAV_HASH=743fd6925b775f821aa8860982d2bdeec05f5d7b
CARDDAV_VERSION=4.4.3
CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
@ -212,11 +212,12 @@ cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
${RCM_PLUGIN_DIR}/password/config.inc.php
management/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php -c "//" \
"\$config['password_driver'] = 'miab';" \
"\$config['password_minimum_length'] = 8;" \
"\$config['password_miab_url'] = 'http://127.0.0.1:10222/';" \
"\$config['password_miab_user'] = '';" \
"\$config['password_miab_pass'] = '';"
"\$config['password_db_dsn'] = 'sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
"\$config['password_query'] = 'UPDATE users SET password=%P WHERE email=%u';" \
"\$config['password_algorithm'] = 'sha512-crypt';" \
"\$config['password_algorithm_prefix'] = '{SHA512-CRYPT}';" \
"\$config['password_dovecotpw_with_method'] = false;"
# so PHP can use doveadm, for the password changing plugin
usermod -a -G dovecot www-data