Compare commits

..

31 commits
v60.2 ... main

Author SHA1 Message Date
beenull
4d68d1f08e Update setup/bootstrap.sh 2024-10-19 12:35:36 +00:00
David Duque
197aa5fdaa
Normalize line-endings 2023-02-12 18:08:25 +00:00
David Duque
f3994d8b89
Fixing the previous commit 2023-02-12 18:07:01 +00:00
David Duque
222ae0d960
For folks on Windows: Unix line-endings
Ensure that folks on windows Workspace still get Unix line-endings.
Might prevent cases where setup scripts will refuse to run.
2023-02-12 18:01:57 +00:00
David Duque
701e214929 v60.5 2022-11-21 02:21:31 +00:00
David Duque
1d857c7f00 Update SMTP Relay docs 2022-11-21 02:11:15 +00:00
David Duque
d5f327755c vagrant: Remove unneeded configuration step
* Local IPv6 is now assured by the setup script itself
2022-11-21 02:05:34 +00:00
David Duque
9193dbe56b Software Updates
* FontAwesome to v6.2.1
2022-11-21 02:04:28 +00:00
David Duque
289f311c4f Hide some unnecessary configuration output 2022-11-21 01:54:18 +00:00
David Duque
465bd4dc57 SMTP Relays: Allow the user to not configure DKIM
* Not all relays provide their own DKIM signatures (which is ok)
* Closes #64
2022-11-21 01:50:33 +00:00
David Duque
f9815662c1 S3: Fix display issue with current configuration
* Especially confusing when using non-AWS buckets
2022-11-20 22:36:08 +00:00
David Duque
74d88787c8 S3: Handle the bucket path not having separators
* Fixes #98
2022-11-20 21:40:21 +00:00
Dom
9215b011f8
Fix bug with quota field detection (#97)
* Fix bug with quota field detection

This is related to Issue #96 "Upgrade to v60.4 fails"

* Update setup/mail-users.sh

sqlite_schema isn't supported everywhere yet

Co-authored-by: David Duque <github@duqued.net>
2022-11-20 18:35:44 +00:00
David Duque
748adfebd9 v60.4 2022-11-07 22:07:35 +00:00
David Duque
1f778bc94c Hotfix 2022-11-07 22:07:24 +00:00
David Duque
6abed54756 Nextcloud: update to v24.0.7 2022-11-07 21:49:29 +00:00
David Duque
b5bc886561 Create a command alias for management/cli.py 2022-11-07 21:33:43 +00:00
David Duque
1af976a093 Move setup cli command to /usr/local/sbin 2022-11-07 21:28:04 +00:00
David Duque
3451dadde5
Roundcube: Use Mail-in-a-Box admin API to drive password changes (#92)
* Use Mail-in-a-Box driver
We're using the user's own credentials to authenticate themselves.
There are some issues if we release as-is:
* Only usable if the user in question is an admin
* Cannot be used if the user has 2FA enabled

* daemon: Add selective gatekeeper
* Allows us to give access to features for logged in, non-admin users

* Allow non-admins to change their own password

* Begin password management self service, frontend

* Allow all users to enable 2FA

* Password change front-end form

* Self password change front-end functionality

* Force logout after successful password change

* Clear fields after successful password change, also fix error modal
2022-11-07 21:07:37 +00:00
David Duque
b961a2b74a Hook version checking to the version router 2022-11-06 22:15:25 +00:00
David Duque
774ab4f1e8 "Recover" database if hit by #85 2022-11-06 22:14:35 +00:00
David Duque
26714cee49
Only ensure IPv6 is enabled at the loopback level
We shouldn't exactly try forcing IPv6 to be enabled everywhere.
2022-11-02 09:51:07 +00:00
David Duque
1efa0ed408 v60.3 2022-10-30 23:05:36 +00:00
David Duque
97683aa9ba Upgrading and version pinning quick start
* Fixes #84
2022-10-30 22:27:43 +00:00
David Duque
be678a126e Remove nextcloud config lock before upgrading
Fixes #86
2022-10-30 18:39:25 +00:00
David Duque
662639d905 Software updates
* Nextcloud: Calendar plugin to v3.5.2
* Webmail: CardDAV/CalDAV plugin to v4.4.4
2022-10-30 17:10:18 +00:00
David Duque
03b36c958c Merge v60.1 (from upstream) 2022-10-30 17:02:05 +00:00
Joshua Tauberer
3314c4f7de v60.1 2022-10-30 08:18:13 -04:00
Joshua Tauberer
1f60236985 Upgrade Nextcloud to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0)
This fixes the monthly view calendar items being in random order.
2022-10-30 08:16:54 -04:00
alento-group
32c68874c5
Fix NSD not restarting (#2182)
A previous commit (0a970f4bb2) broke nsd restarting. This fixes that change by reverting it.

Josh added: Use nsd-control with reconfig and reload if they succeed and only fall back to restarting nsd if they fail

Co-authored-by: Joshua Tauberer <jt@occams.info>
2022-10-30 08:16:03 -04:00
Joshua Tauberer
286a4bd9e7 Remove stray quote in bootstrap.sh
Reported at https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-released/9558/4.
2022-10-12 06:11:02 -04:00
21 changed files with 339 additions and 169 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
# 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,6 +1,12 @@
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)
-----------------------------
@ -17,7 +23,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.
* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0).
* Roundcube is upgraded to 1.6.0.
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
* fail2ban is upgraded to 0.11.2.

View file

@ -1,5 +1,6 @@
# Power Mail-in-a-Box
**[Installation](#installation)** (current version: v60.2)
## **[Installation](#installation)** (current version: v60.5)
## **[Upgrading Quick Start](#upgrading)**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/davness)
@ -80,3 +81,21 @@ 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,9 +49,6 @@ 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.2
version: 60.5
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,7 +240,9 @@ 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[1], target[2] = target[2].lstrip('/').split('/', 1)
target_bucket = target[2].lstrip('/').split('/', 1)
target[1] = target_bucket[0]
target[2] = target_bucket[1] if len(target_bucket) > 1 else ''
target = urlunsplit(target)

View file

@ -56,71 +56,70 @@ app = Flask(__name__,
# Decorator to protect views that require a user with 'admin' privileges.
def authorized_personnel_only(admin = True):
def gatekeeper(viewfunc):
def authorized_personnel_only(viewfunc):
@wraps(viewfunc)
def newview(*args, **kwargs):
# Authenticate the passed credentials, which is either the API key or a username:password pair
# and an optional X-Auth-Token token.
error = None
privs = []
@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 = []
try:
email, privs = auth_service.authenticate(request, env)
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)
# 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
# Authentication failed.
error = str(e)
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)
# 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
# Authentication failed.
error = str(e)
# Call view func.
return viewfunc(*args, **kwargs)
# 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,
}
if not error:
error = "You are not an administrator."
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
# 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,
}
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)
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 newview
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
return gatekeeper
@app.errorhandler(401)
@ -213,7 +212,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))
@ -222,7 +221,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:
@ -234,7 +233,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)
@ -246,7 +245,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', ''),
@ -256,8 +255,13 @@ def mail_users_quota():
@app.route('/mail/users/password', methods=['POST'])
@authorized_personnel_only
@authorized_personnel_only(admin = False)
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)
@ -266,13 +270,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):
@ -281,7 +285,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', ''),
@ -289,7 +293,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', ''),
@ -297,7 +301,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))
@ -308,7 +312,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', ''),
@ -319,13 +323,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))
@ -334,14 +338,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:
@ -351,7 +355,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({
@ -361,7 +365,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:
@ -375,7 +379,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
@ -431,7 +435,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:
@ -498,14 +502,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),
@ -517,7 +521,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
@ -557,7 +561,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(
@ -567,7 +571,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
@ -580,7 +584,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)
@ -591,7 +595,7 @@ def ssl_provision_certs():
@app.route('/mfa/status', methods=['POST'])
@authorized_personnel_only
@authorized_personnel_only(admin = False)
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
@ -599,6 +603,9 @@ 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:
@ -609,7 +616,7 @@ def mfa_get_status():
@app.route('/mfa/totp/enable', methods=['POST'])
@authorized_personnel_only
@authorized_personnel_only(admin = False)
def totp_post_enable():
secret = request.form.get('secret')
token = request.form.get('token')
@ -625,13 +632,16 @@ def totp_post_enable():
@app.route('/mfa/disable', methods=['POST'])
@authorized_personnel_only
@authorized_personnel_only(admin = False)
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,
@ -648,14 +658,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:
@ -668,7 +678,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:
@ -678,7 +688,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:
@ -688,7 +698,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
@ -736,7 +746,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"])
@ -744,7 +754,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"],
@ -752,7 +762,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():
@ -762,7 +772,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
@ -774,7 +784,7 @@ def do_reboot():
@app.route('/system/backup/status')
@authorized_personnel_only
@authorized_personnel_only()
def backup_status():
from backup import backup_status
try:
@ -784,14 +794,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(
@ -803,7 +813,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
@ -817,14 +827,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")
@ -833,7 +843,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)
@ -864,7 +874,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
@ -876,30 +886,39 @@ def smtp_relay_set():
newconf = request.form
# Is DKIM configured?
sel = newconf.get("dkim_selector")
sel = newconf.get("dkim_selector", "")
rr = newconf.get("dkim_rr", "")
check_dkim = True
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)
# 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)
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)
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
@ -918,7 +937,7 @@ def smtp_relay_set():
implicit_tls = True
except ssl.SSLError as sle:
# Couldn't connect via TLS, configure Postfix to send via STARTTLS
print(sle.reason)
pass
except (socket.herror, socket.gaierror) as he:
return (
f"Unable to resolve hostname (it probably is incorrect): {he.strerror}",
@ -995,7 +1014,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 {
@ -1005,7 +1024,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)
@ -1015,7 +1034,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
@ -1030,7 +1049,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)
@ -1040,7 +1059,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
@ -1065,7 +1084,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
@ -1099,7 +1118,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)
@ -1108,7 +1127,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)
@ -1119,7 +1138,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:
@ -1137,7 +1156,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,7 +116,16 @@ def do_dns_update(env, force=False):
# Tell nsd to reload changed zone files.
if len(updated_domains) > 0:
shell('check_call', ["/usr/sbin/nsd-control", "reload"])
# 'reconfig' is needed if there are added or removed zones, but
# it may not reload existing zones, so we call 'reload' too. If
# nsd isn't running, nsd-control fails, so in that case revert
# to restarting nsd to make sure it is running. Restarting nsd
# should also refresh everything.
try:
shell('check_call', ["/usr/sbin/nsd-control", "reconfig"])
shell('check_call', ["/usr/sbin/nsd-control", "reload"])
except:
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
# Write the OpenDKIM configuration tables for all of the mail domains.
from mailconfig import get_mail_domains

View file

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

View file

@ -135,6 +135,13 @@
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
@ -198,6 +205,10 @@
{% 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

@ -0,0 +1,57 @@
<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>Not doing so will have your email sent to spam.</b></p>
trust). <b>If your relay provider does not provide you with this information, it's probably safe to skip this step.</b></p>
<div class="col-lg-6 col-md-8 col-12">
<div class="input-group">

View file

@ -304,6 +304,15 @@
$("#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://") {
@ -365,18 +374,18 @@
}
function init_inputs(target_type) {
function set_host(host) {
function set_host(host, overwrite_other) {
if (host !== 'other') {
$("#backup-target-s3-host").val(host);
} else {
} else if (overwrite_other) {
$("#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());
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);
}
}

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.2
TAG=v60.5
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://github.com/ddavness/power-mailinabox \
https://git.nibbletools.com/beenull/power-mailinabox \
$HOME/mailinabox \
< /dev/null 2> /dev/null

View file

@ -25,10 +25,20 @@ 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;
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;
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
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,12 +81,13 @@ mkdir -p $assets_dir
# jQuery CDN URL
jquery_version=3.6.1
jquery_url=https://code.jquery.com
jquery_url=https://code.jquery.com # Check this link for new versions
# 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
@ -97,11 +98,12 @@ mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
rm -f /tmp/bootstrap.zip
# FontAwesome CDN URL
fontawesome_version=6.2.0
# See https://github.com/FortAwesome/Font-Awesome/releases to check for new versions
fontawesome_version=6.2.1
fontawesome_url=https://github.com/FortAwesome/Font-Awesome/releases/download/$fontawesome_version/fontawesome-free-$fontawesome_version-web.zip
# Get FontAwesome
wget_verify $fontawesome_url cd6250deeb38ab707240200c573d2357eaf732a0 /tmp/fontawesome.zip
wget_verify $fontawesome_url cd0f2bcc9653b56e3e2dd82d6598aa6bbca8d796 /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.6
nextcloud_hash=68366ddf16966acf532b3d0349a78ac2ade8269c
nextcloud_ver=24.0.7
nextcloud_hash=7fb1afeb3c212bf5530c3d234b23bf314b47655a
# Nextcloud apps
# --------------
@ -35,8 +35,8 @@ nextcloud_hash=68366ddf16966acf532b3d0349a78ac2ade8269c
# 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.0
calendar_hash=0938ffc4880cfdd74dd2e281eed96aa1f13fd065
calendar_ver=3.5.2
calendar_hash=dcf2cba6933dc8805ca4b4d04ed7b993ff4652a1
user_external_ver=3.0.0
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
@ -168,10 +168,28 @@ 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
@ -346,7 +364,6 @@ 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/bin/mailinabox << EOF;
cat > /usr/local/sbin/mailinabox << EOF;
#!/bin/bash
cd $(pwd)
source setup/start.sh
EOF
chmod +x /usr/local/bin/mailinabox
chmod 744 /usr/local/sbin/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,6 +129,14 @@ 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.all.disable_ipv6 = 0"
management/editconf.py /etc/sysctl.conf "net.ipv6.conf.lo.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/commits/master
# https://github.com/stremlau/html5_notifier/commits/master
# https://github.com/mfreiholz/persistent_login/
# https://github.com/stremlau/html5_notifier/
# 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.3
CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644
CARDDAV_VERSION=4.4.4
CARDDAV_HASH=743fd6925b775f821aa8860982d2bdeec05f5d7b
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
@ -212,12 +212,11 @@ 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_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;"
"\$config['password_miab_url'] = 'http://127.0.0.1:10222/';" \
"\$config['password_miab_user'] = '';" \
"\$config['password_miab_pass'] = '';"
# so PHP can use doveadm, for the password changing plugin
usermod -a -G dovecot www-data