Compare commits

..

615 commits

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
David Duque
ed7859eab8 v60.2 2022-10-20 00:36:29 +01:00
David Duque
003ff10d07 Fix editconf issue
See mail-in-a-box/mailinabox#2137
2022-10-20 00:33:47 +01:00
David Duque
8b4a49597e v60.1 2022-10-18 21:58:46 +01:00
David Duque
01cf61deab gettext is a build dependency for duplicity
* Fixes #81
2022-10-18 20:36:18 +01:00
David Duque
619d3781f5 noreply addresses: Use correct db path
* Fixes #82
2022-10-18 20:35:25 +01:00
David Duque
025494ed9c v60.0 2022-10-17 03:41:20 +01:00
David Duque
824762d18d Last-minute setup patches 2022-10-17 03:40:08 +01:00
David Duque
9243ea9374 Software Updates
* Nextcloud to 24.0.6
* * Contacts to 4.2.2
* JQuery to 3.6.1
* Bootstrap to 5.2.2
* FontAwesome to 6.2.0
2022-10-17 02:56:03 +01:00
David Duque
132cfe6e32 Enable local ipv6 at kernel level
* We need :: for nsd
2022-10-17 02:45:31 +01:00
David Duque
bc72c58ae5 Move away from doveadm pw for password changes
* Fixes #77
2022-10-17 02:36:38 +01:00
David Duque
866c185a08 Fix #66
Make sure OOO notices are sent as if the recipients sent them
2022-10-16 23:26:00 +01:00
David Duque
97a84bc2b8 Editconf: merge from v60 2022-10-16 20:56:31 +01:00
David Duque
6c1a98dc4b Status Checks on packages
* Pending upgrades will be written as warnings instead of errors
* Removed Ubuntu Pro advertisment (hacky solution,
need to check for consequences)
2022-10-16 20:46:54 +01:00
David Duque
a03707e5f8 Confirm boto3 installation 2022-10-16 20:46:04 +01:00
David Duque
913a4a4585 Alternative duplicity installation (build from pip) 2022-10-16 19:21:07 +01:00
David Duque
c30a18a30b Import changes from upstream v60 2022-10-16 18:26:39 +01: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
Joshua Tauberer
ddf8e857fd
Support Ubuntu 22.04 Jammy Jellyfish (#2083) 2022-10-11 21:18:34 -04:00
Joshua Tauberer
4d5ff0210b Version 60 2022-10-11 21:14:31 -04:00
Joshua Tauberer
89cd9fb611 Increase gunicorn's worker timeout since some /admin commands take a long time 2022-10-08 08:23:48 -04:00
Joshua Tauberer
22a6270657 Remove old setup step to uninstall acme library 2022-10-08 08:23:48 -04:00
Joshua Tauberer
0a970f4bb2 Use nsd-control to refresh nsd after zone files are rewritten rather than 'service nsd restart'
I am not sure if this was the problem but nsd didn't serve updated zonefiles on my box and 'service nsd restart' must have been used, so maybe it doesn't reload zones.
2022-10-08 07:24:57 -04:00
Joshua Tauberer
9b111e2493 Update to Nextcloud 23.0.8 (contacts 4.2.0, calendar 3.5.0) 2022-10-08 07:23:21 -04:00
jvolkenant
b8feb77ef4
Move postgrey database under $STORAGE_ROOT (#2077) 2022-09-24 13:17:55 -04:00
Joshua Tauberer
3c44604316 Install 'file' package
The command is used in mailinabox-postgrey-whitelist. Reported missing (on systems that don't install it by default) in #2083.
2022-09-24 10:10:50 -04:00
Steve Hay
1e1a054686
BUGFIX: Correctly handle the multiprocessing for run_checks in the management daemon (#2163)
See discussion here: #2083

Co-authored-by: Steve Hay <hay.steve@gmail.com>
2022-09-24 09:56:27 -04:00
kiekerjan
d584a41e60
Update Roundcube to 1.6.0 (#2153) 2022-09-17 09:20:20 -04:00
downtownallday
56074ae035 Tighten roundcube session config (#2138)
Merges #2138.
2022-09-17 09:09:00 -04:00
downtownallday
30631b0fc5 Fix undefined variable 'val' in tools/editconf.py (#2137)
Merges #2137.
2022-09-17 09:09:00 -04:00
Steve Hay
84da4e6000 Update dovecot to use same DH parameters file as the other services
Originally from #2157.
2022-09-17 09:07:54 -04:00
Joshua Tauberer
58ded74181 Restore the backup S3 host select box if an S3 target has been set
Also remove unnecessary import added in 7cda439c. Was a mistake from edits during PR review.
2022-09-17 09:07:54 -04:00
Steve Hay
3fd2e3efa9
Replace Flask built-in WSGI server with gunicorn (#2158) 2022-09-17 08:03:16 -04:00
Steve Hay
7cda439c80
Port boto to boto3 and fix asyncio issue in the management daemon (#2156)
Co-authored-by: Steve Hay <hay.steve@gmail.com>
2022-09-17 07:57:12 -04:00
David Duque
9700b59cf8 Bump test machine RAM allocation
* Fixes kernel panics on Jammy Jellyfish
2022-09-10 16:07:37 +01:00
David Duque
e7ee4bc5b4 Fix Nextcloud installation step 2022-09-10 15:52:17 +01:00
David Duque
dc787b67b4 Handle the case where the raw spf record exists but is None
* Fixes #70
2022-09-09 16:29:01 +01:00
David Duque
09cf3b3755 Allow dashes in DKIM selectors
(when they're not the first character)
* Fixes #68
2022-09-08 16:00:08 +01:00
David Duque
fc04c8d723 Nextcloud: Implement old version safeguards 2022-09-08 13:05:41 +01:00
Johga
2aa4229e0b
fix error when user_external version under 3.0.0 (#69) 2022-09-08 13:03:51 +01:00
David Duque
e1be9a5eeb
Ubuntu 22.04 support (#59)
* Vagrantfile: Add Ubuntu 22.04 image

* Recognize Ubuntu 22.04 as supported

* Bump nextcloud to v24.0.0

* Bump Roundcube to 1.6-beta

Still waiting for the final release to come out

* Fix version checking functions

* NextCloud fixes

* Update Roundcube config

* Bump roundcube to 1.6-rc

* FIx nextcloud installation step

* rcm: Update CardDAV plugin to v4.4.0 (Guzzle v7)

* Fix STORAGE_ROOT permissions

* Update RC CardDAV plugin to v4.4.1

* Unpin b2sdk for Ubuntu 22.04

* Comment fix

* Drop support for Debian 10 from this point forward

* Software Updates
* Nextcloud: 24.0.2
* Nextcloud Calendar: 3.4.2
* Roundcube CardDAV: 4.4.2

* Update Roundcube to v1.6.0

* Update Nextcloud to v24.0.3
* Contacts to v4.2.0

* Upgrade Nextcloud to v24.0.4
* Calendar to v3.5.0

Webmail:
* CardDAV to v4.4.3
2022-09-08 12:26:39 +01:00
Joshua Tauberer
91fc74b408 Setup fixes for Ubuntu 22.04
Nextcloud:
* The Nextcloud user_external 1.0.0 package for Nextcloud 21.0.7 isn't available from Nextcloud's releases page, but it's not needed in an intermediate upgrade step (hopefully), so we can skip it.
* Nextcloud updgrade steps should not be elifs because multiple intermediate upgrades may be needed.
* Continue if the user_external backend migration fails. Maybe it's not necessary. It gives a scary error message though.
* Remove a line that removes an old file that hasn't been in use since 2019 and the expectation is that Ubuntu 22.04 installations are on fresh machines.

Backups:
* For duplicity, we now need boto3 for AWS.
2022-09-03 07:50:36 -04:00
Sudheesh Singanamalla
d7244ed920
Fixes #2149 Append ; in policy strings for DMARC settings (#2151)
Signed-off-by: Sudheesh Singanamalla <sudheesh@cloudflare.com>
2022-08-19 13:23:42 -04:00
David Duque
e0c0b5053c Upgrade Nextcloud External User Backend to v3.0.0
Co-Authored-By: Joshua Tauberer <jt@occams.info>
2022-07-28 14:42:51 -04:00
Joshua Tauberer
268b31685d Ensure STORAGE_ROOT has a+rx permission since processes run by different system users need to access files within it 2022-07-28 14:42:51 -04:00
Joshua Tauberer
ab71abbc7c Update to latest cryptography Python package, add missing source at top of management.sh so it can run standalone (needs STORAGE_ROOT) 2022-07-28 14:42:51 -04:00
Joshua Tauberer
87e6df9e28 Fix roundcube dependency missing imap and unneeded ldap 2022-07-28 14:42:51 -04:00
Felix Matouschek
558f2db31f system.sh: Remove no longer needed haveged (#2090)
Starting from kernels 5.6 haveged is obsolete. Therefore remove it in
Ubuntu 22.04.

See https://github.com/jirka-h/haveged/issues/57
2022-07-28 14:42:51 -04:00
Joshua Tauberer
c23dd701f0 Start changelog and instructions updates for version 60 supporting Ubuntu 22.04
To scan for updated apt packages in Ubuntu 22.04, I ran on Ubuntu 18.04 and 22.04 and compared the output:

```
for package in openssl openssh-client haveged pollinate fail2ban ufw bind9 nsd ldnsutils nginx dovecot-core postfix opendkim opendkim-tools opendmarc postgrey spampd razor pyzor dovecot-antispam sqlite3 duplicity certbot munin munin-node php python3; do
  echo -n "$package ";
  dpkg-query --showformat='${Version}' --show $package;
  echo
done
```
2022-07-28 14:42:51 -04:00
Joshua Tauberer
0a7b9d5089 Update dovecot, spampd settings for Ubuntu 22.04
* dovecot's ssl_protocols became ssl_min_protocol in 2.3
* spampd fixed a bug so we can remove lmtp_destination_recipient_limit=1 in postfix
2022-07-28 14:34:45 -04:00
Joshua Tauberer
1eddf9a220 Upgrade to Nextcloud 23.0.4
The first version supporting PHP 8.0 is Nextcloud 21. Therefore we can add migrations only to Nextcloud 21 forward, and so we only support migrating from Nextcloud 20 (Mail-in-a-Box versions v0.51+). Migration steps through Nextcloud 21 and 22 are added.

Also:

* Fix PHP APUc settings to be before Nextcloud tools are run.
2022-07-28 14:34:45 -04:00
Joshua Tauberer
78d71498fa Upgrade from PHP 7.2 to 8.0 for Ubuntu 22.04
* Add the PHP PPA.
* Specify the version when invoking the php CLI.
* Specify the version in package names.
* Update paths to 8.0 (using a variable in the setup scripts).
* Update z-push's php-xsl dependency to php8.0-xml.
* php-json is now built-into PHP.

Although PHP 8.1 is the stock version in Ubuntu 22.04, it's not supported by Nextcloud yet, and it likely will never be supported by the the version of Nextcloud that succeeds the last version of Nextcloud that supports PHP 7.2, and we have to install the next version so that an upgrade is permitted, so skipping to PHP 8.1 may not be easily possible.
2022-07-28 14:02:46 -04:00
Joshua Tauberer
b41a0ad80e Drop some hacks that we needed for Ubuntu 18.04
* certbot's PPA is no longer needed because a recent version is now included in the Ubuntu respository.
* Un-pin b2sdk (reverts 69d8fdef99 and d829d74048).
* Revert boto+s3 workaround for duplicity (partial revert of 99474b348f).
* Revert old "fix boto 2 conflict on Google Compute Engine instances" (cf33be4596) which is probably no longer needed.
2022-07-28 14:02:46 -04:00
Rauno Moisto
78569e9a88 Fix DeprecationWarning in dnspython query vs resolve method
The resolve method disables resolving relative names by default. This change probably makes a7710e90 unnecessary. @JoshData added some additional changes from query to resolve.
2022-07-28 14:02:46 -04:00
Daniel Mabbett
8cb360fe36 Configure nsd listening interfaces before installing nsd so that it does not interfere with bind9 2022-07-28 14:02:46 -04:00
Joshua Tauberer
f534a530d4 Update and drop some package and file names for Ubuntu 22.04
* Fix path to bind9 startup options file in Ubuntu 22.04.
* tinymce has not been a Roundcube requirement recently and is no longer a package in Ubuntu 22.04
* Upgrade Vagrant box to Ubuntu 22.04
2022-07-28 14:02:46 -04:00
Joshua Tauberer
2abcafd670 Update Ubuntu version checks from 18.04 to 22.04 2022-07-28 14:02:44 -04:00
Joshua Tauberer
3c3d62ac27 Version 57a 2022-06-19 08:58:09 -04:00
Joshua Tauberer
d829d74048 Pin b2sdk to version 1.14.1 in the virtualenv also
We install b2sdk in two places: Once globally for duplicity (see
9d8fdef9915127f016eb6424322a149cdff25d7 for #2125) and once in
a virtualenv used by our control panel. The latter wasn't pinned
when the former was but should be to fix new Python compatibility
issues.

Anyone who updated Python packages recently (so anyone who upgraded
Mail-in-a-Box) started encountering these issues.

Fixes #2131.

See https://discourse.mailinabox.email/t/backblaze-b2-backup-not-working-since-v57/9231.
2022-06-18 13:15:59 -04:00
Joshua Tauberer
2aca421415 Version 57 2022-06-12 08:18:42 -04:00
Joshua Tauberer
99474b348f Update backup to be compatible with duplicity 0.8.23
We were using duplicity 0.8.21-ppa202111091602~ubuntu1 from the duplicity PPA probably until June 5, which is when my box automatically updated to 0.8.23-ppa202205151528~ubuntu18.04.1. Starting with that version, two changes broke backups:

* The default s3 backend was changed to boto3. But boto3 depends on the AWS SDK which does not support Ubuntu 18.04, so we can't install it. Instead, we map s3: backup target URLs to the boto+s3 scheme which tells duplicity to use legacy boto. This should be reverted when we can switch to boto3.
* Contrary to the documentation, the s3 target no longer accepts a S3 hostname in the URL. It now reads the bucket from the hostname part of the URL. So we now drop the hostname from our target URL before passing it to duplicity and we pass the endpoint URL in a separate command-line argument. (The boto backend was dropped from duplicity's "uses_netloc" in 74d4cf44b1 (f5a07610d36bd242c3e5b98f8348879a468b866a_37_34), but other changes may be related.)

The change of target URL (due to both changes) seems to also cause duplicity to store cached data in a different directory within $STORAGE_ROOT/backup/cache, so on the next backup it will re-download cached manifest/signature files. Since the cache directory will still hold the prior data which is no longer needed, it might be a good idea to clear out the cache directory to save space. A system status checks message is added about that.

Fixes #2123
2022-06-12 08:17:48 -04:00
Joshua Tauberer
8bebaf6a48 Simplify duplicity command line by omitting rsync options if the backup target type is not rsync 2022-06-11 15:12:31 -04:00
David Duque
a0d44f3d05 v56.5 2022-06-07 11:38:10 +01:00
David Duque
55dbd9d93c Nextcloud user permission fixes 2022-06-07 11:24:12 +01:00
David Duque
3503b5a1f0 Update nextcloud to v23.0.5
* Contacts to v4.1.1
* Calendar to 3.3.1
2022-06-07 11:18:22 +01:00
David Duque
ddcc929a13 Status Checks: Fix "Show More" button not showing 2022-06-07 11:10:25 +01:00
jbandholz
9004bb6e8e
Add IPV6 addresses to fail2ban ignoreip (#2069)
Update jails.conf to include IPV6 localhost and external ip to ignoreip line.  Update system.sh to include IPV6 address in replacement.  See mail-in-a-box#2066 for details.
2022-06-05 09:40:54 -04:00
m-picc
69d8fdef99
Specify b2sdk version 1.14.1 (#2125)
pin b2sdk version to 1.14.1 to resolve exception that occurs when attempting to use backblaze backups. See https://github.com/mail-in-a-box/mailinabox/issues/2124 for details.
2022-06-05 09:24:32 -04:00
Austin Ewens
eeee712cf3
Switched to using tags over releases for NextCloud contacts/calendar (#2105)
See [mailinabox issue #2088](https://github.com/mail-in-a-box/mailinabox/issues/2088). This also updates the commit hashes to for anyone updating from NextCloud version 17 (as shown in the related issue) since a different hash is used for tags vs releases.

This was tested and verified to work on a setup previously running v0.44 and then updating to the latest version (v56).
2022-05-04 17:09:53 -04:00
Joshua Tauberer
8f42d97b54
Merge pull request #2109 from lamberete/main 2022-05-04 17:08:48 -04:00
David Duque
04f4910b51 Fix Nextcloud admin unlocking 2022-04-27 23:23:53 +01:00
David Duque
e5ffae7791 Upgrade Nextcloud to v23.0.4, External User Backend to v3.0.0 2022-04-27 23:22:55 +01:00
David Duque
7df762b46b v56.4 2022-03-29 17:29:20 +01:00
David Duque
c15c1d84d7 More assertive notice of unsupported versions 2022-03-29 17:22:58 +01:00
David Duque
f605e55997 Handle the absence of openssh-server
It is not strictly required for us to have sshd installed,
for example on baremetal machines where shell access
is physical-only.

Instead we'll skip certain tasks that depend on sshd if
it is not installed.
2022-03-29 17:14:06 +01:00
lamberete
6e40c69cb5
Error message using IPv4 instead of failing IPv6.
One of the error messages around IPv6 was using the IPv4 for the output, making the error message confusing.
2022-03-26 13:50:24 +01:00
lamberete
c0e54f87d7
Sorting ds records on report.
When building the part of the report about the current DS records founded, they are added in the same order as they were received when calling query_dns(), which can differ from run to run. This was making the difflib.SequenceMatcher() method to find the same line removed and added one line later, and sending an Status Checks Change Notice email with the same line added and removed when there was actually no real changes.
2022-03-26 13:45:49 +01:00
David Duque
aa0994b1d7
Update README 2022-03-22 18:32:35 +00:00
David Duque
1fcc1a489b
v56.3 2022-03-22 18:25:36 +00:00
David Duque
9defa6c54d
Add ko-fi button on the README 2022-03-22 18:24:47 +00:00
David Duque
53c0c3aec8
Create FUNDING.yml 2022-03-22 18:23:02 +00:00
David Duque
6bcf1169bd
Project home is now at power-mailinabox.net 2022-03-22 18:05:02 +00:00
David Duque
fece9355cf
Update NextCloud to v23.0.3 2022-03-22 18:01:51 +00:00
David Duque
83a109908e
Remove non-working copy button 2022-03-22 16:47:32 +00:00
David Duque
e4cbbccfde Dark mode - add a different color for disabled form elements 2022-03-22 16:43:25 +00:00
David Duque
a56bb235d7
Upgrade FontAwesome to v6.1.1, Persistent Login Plugin to v5.3.0 2022-03-22 16:41:54 +00:00
David Duque
6602d2c774
Merge from main 2022-02-20 20:45:14 +00:00
David Duque
2cee801002
v56.2 2022-02-18 01:07:01 +00:00
David Duque
d8d742d74d
Update contacts to v4.0.8, cal to v3.0.6 2022-02-18 00:56:26 +00:00
David Duque
d5069fc81e
README adjustments 2022-02-18 00:47:06 +00:00
David Duque
4da6f66b94
Update Postfix TLS configuration (#45)
* Update the list of very old ciphers that shouldn't be used at all
* Enforce cipher preference server side
2022-02-18 00:43:13 +00:00
David Duque
307c678066
Add a note post-configuration about DMARC configurations 2022-02-18 00:28:05 +00:00
David Duque
c111a8920c
Adjust the box's own DKIM selector when the relay provider wants the 'mail' selector 2022-02-18 00:25:03 +00:00
David Duque
115fee4212
SMTP Relays: Allow the user to paste an SPF record directly from their provider 2022-02-18 00:03:41 +00:00
David Duque
51fa2a6fd9
Change the SMTP banner as not to disclose the operating system (which was set to be always 'Debian') 2022-02-17 23:28:20 +00:00
David Duque
deaecbe0d2
Fix nextcloud issue piping stderr to a file 2022-02-17 23:27:45 +00:00
David Duque
56b0fc02da
Install libsasl2-modules (#44)
Required for SMTP relays to work
2022-02-17 23:25:01 +00:00
David Duque
a8f5a78518
Add some level of style guidance, reformat files 2022-02-04 23:26:24 +00:00
David Duque
14534fd0cb
Fix nextcloud cleanup typo 2022-01-31 16:10:28 +00:00
David Duque
a0abc9c0d2
Vagrant: bump nfs to v4 for all images 2022-01-31 00:54:29 +00:00
David Duque
6b4ee8f576
Merge from main 2022-01-31 00:53:59 +00:00
David Duque
ed886ee934
Admin panel refactoring (#41) 2022-01-31 00:52:22 +00:00
David Duque
16b521c2f0
v56.1 2022-01-24 01:52:48 +00:00
David Duque
6436bad483
Recreate Python env after an in-place OS upgrade
Fixes #42
2022-01-24 01:48:34 +00:00
David Duque
271b03a346
v56.0 2022-01-22 20:00:01 +00:00
David Duque
37a97a394f
Suppress complaints about owncloud not existing (legacy setup code) 2022-01-22 19:58:03 +00:00
David Duque
152d7c39bc
Band-aid fix for B2 backups on Debian 10 2022-01-22 18:53:49 +00:00
David Duque
b622df6b03
Update Software:
- Bootstrap to v4.6.1
- FontAwesome to v5.15.4
- NextCloud Calendar to v3.0.5
- Roundcube Persistent Login to latest commit
2022-01-20 15:49:06 +00:00
David Duque
cd9bd51ed0
Import changes from upstream (v56) 2022-01-20 15:02:16 +00:00
Joshua Tauberer
3a7de051ee Version 56 (January 19, 2022) 2022-01-19 16:59:34 -05:00
Darek Kowalski
f11cb04a72
Update Vagrant private IP address, fix issue #2062 (#2064) 2022-01-08 18:29:23 -05:00
Joshua Tauberer
cb564a130a Fix DNS secondary nameserver refesh failure retry period
Fixes #1979
2022-01-08 09:38:41 -05:00
Joshua Tauberer
d1d6318862 Set systemd journald log retention to 10 days (from no limit) to reduce disk usage 2022-01-08 09:11:48 -05:00
Joshua Tauberer
34b7a02f4f Update Roundcube to 1.5.2 2022-01-08 09:00:12 -05:00
Joshua Tauberer
a312acc3bc Update to Nextcloud 20.0.8 and update apps 2022-01-08 09:00:12 -05:00
Joshua Tauberer
aab1ec691c CHANGELOG entries 2022-01-08 07:46:24 -05:00
Erik Hennig
520caf6557
fix: typo in system backup template (#2081) 2022-01-02 08:11:41 -05:00
jvolkenant
c92fd02262
Don't die if column already exists on Nextcloud 18 upgrade (#2078) 2021-12-25 10:17:34 -05:00
Arno Hautala
a85c429a85
regex change to exclude comma from sasl_username (#2074)
as proposed in #2071 by @jvolkenant
2021-12-19 08:33:59 -05:00
Ilnahro
50a5cb90bc
Include rsync to the installed basic packages (#2067)
Some VPS providers strip this package from their Ubuntu 18.04 VM images. This will help avoid errors.
2021-11-30 19:50:01 -05:00
steadfasterX
aac878dce5
fix: key flag id for KSK, fix format (#2063)
as mentioned (https://github.com/mail-in-a-box/mailinabox/pull/2033#issuecomment-976365087) KSK is 257, not 256
2021-11-23 11:06:17 -05:00
David Duque
4812ffd6b3
v55.1 2021-11-17 22:58:33 +00:00
David Duque
2e3b113756
Move from 'master' to 'main' 2021-11-17 22:57:46 +00:00
David Duque
faacc1d674
Update to above: Bail only at the second failure. 2021-11-17 22:55:18 +00:00
David Duque
5e66af63f2
Don't bail if pollinate fails 2021-11-17 22:42:45 +00:00
David Duque
972c413b19
nginx: Overhaul upstream directives (#38)
* Add a way for users to specify custom upstream directives via .upstream.conf file;
* MiaB-managed applications and user applications will now use different sockets;
2021-11-17 22:41:36 +00:00
jvolkenant
58b0323b36
Update persistent_login for Roundcube 1.5 (#2055) 2021-11-04 18:59:10 -04:00
kiekerjan
646f971d8b
Update mailinabox.yml (#2054)
The examples for login and logout use GET instead of POST. GET gives me an error when using it, while POST seems to work.
2021-10-31 12:49:26 -04:00
Felix Spöttel
86067be142
fix(docs): set a schema for /logout responses (#2051)
* this remedies an OpenAPI syntax violation resulting in a redoc-cli crash
2021-10-27 12:27:54 -04:00
Joshua Tauberer
c67ff241c4
Updates to security.md 2021-10-23 08:57:05 -04:00
Joshua Tauberer
7b4cd443bf
How to report security issues 2021-10-22 18:49:16 -04:00
Joshua Tauberer
34017548d5 Don't crash if a custom DNS entry is not under a zone managed by the box, fixes #1961 2021-10-22 18:39:53 -04:00
David Duque
109267c7b0
v55.0 2021-10-19 21:12:17 +01:00
David Duque
cb7cbe419e
Update external software checksums 2021-10-19 21:08:24 +01:00
David Duque
dab920042e
Update external software 2021-10-19 21:05:19 +01:00
David Duque
402df4515b
Fix weird grammar
Fixes #33
2021-10-19 18:44:51 +01:00
David Duque
7caa4f5c4e
Disable dark mode CSS for now
It doesn't play nice with the current admin panel. I'll design a better dark mode in the future.
2021-10-19 17:26:05 +01:00
David
856260bf29
Check for lsb_release, and if it doesn't exist, install it 2021-10-19 14:43:01 +01:00
David
a6c8bbed20
Fix incorrect info about passwords 2021-10-19 14:42:36 +01:00
David
f6d7d5689c
Merge v55 from upstream 2021-10-19 14:42:07 +01:00
David
b757e4c339
(Admin Panel) Fix an issue where all TXT records from a given qname would be deleted 2021-10-19 13:07:34 +01:00
David Duque
8a0805dae8
Backups: When rsync option is selected, allow modifying the target port (#30) 2021-10-19 11:20:41 +01:00
Joshua Tauberer
65861c68b7 Version 55 2021-10-18 20:40:51 -04:00
Joshua Tauberer
71a7a3e201 Upgrade to Roundcube 1.5 2021-10-18 20:40:51 -04:00
Richard Willis
1c3bca53bb
Fix broken link in external-dns.html (#2045) 2021-10-18 07:36:48 -04:00
ukfhVp0zms
b643cb3478
Update calendar/contacts android app info (#2044)
DAVdroid has been renamed to DAVx⁵ and price increased from $3.69 to $5.99.
CardDAV-Sync free is no longer in beta.
CalDAV-Sync price increased from $2.89 to $2.99.
2021-10-13 19:09:05 -04:00
Joshua Tauberer
113b7bd827 Disable SMTPUTF8 in Postfix because Dovecot LMTP doesn't support it and bounces messages that require SMTPUTF8
By not advertising SMTPUTF8 support at the start, senders may opt to transmit recipient internationalized domain names in IDNA form instead, which will be deliverable.

Incoming mail with internationalized domains was probably working prior to our move to Ubuntu 18.04 when postfix's SMTPUTF8 support became enabled by default.

The previous commit is retained because Mail-in-a-Box users might prefer to keep SMTPUTF8 on for outbound mail, if they are not using internationalized domains for email, in which case the previous commit fixes the 'relay access denied' error even if the emails aren't deliverable.
2021-09-24 08:11:36 -04:00
Joshua Tauberer
3e19f85fad Add domain maps from Unicode forms of internationalized domains to their ASCII forms
When an email is received by Postfix using SMTPUTF8 and the recipient domain is a Unicode internationalized domain, it was failing to be delivered (bouncing with 'relay access denied') because our users and aliases tables only store ASCII (IDNA) forms of internationalized domains. In this commit, domain maps are added to the auto_aliases table from the Unicode form of each mail domain to its IDNA form, if those forms are different. The Postfix domains query is updated to look at the auto_aliases table now as well, since it is the only table with Unicode forms of the mail domains.

However, mail delivery is still not working since the Dovecot LMTP server does not support SMTPUTF8, and mail still bounces but with an error that SMTPUTF8 is not supported.
2021-09-24 08:11:36 -04:00
Joshua Tauberer
11e84d0d40 Move automatically generated aliases to a separate database table
They really should never have been conflated with the user-provided aliases.

Update the postfix alias map to query the automatically generated aliases with lowest priority.
2021-09-24 08:11:36 -04:00
Joshua Tauberer
79966e36e3 Set a cookie for /admin/munin pages to grant access to Munin reports
The /admin/munin routes used the same Authorization: header logic as the other API routes, but they are browsed directly in the browser because they are handled as static pages or as a proxy to a CGI script.

This required users to enter their email username/password for HTTP basic authentication in the standard browser auth prompt, which wasn't ideal (and may leak the password in browser storage). It also stopped working when MFA was enabled for user accounts.

A token is now set in a cookie when visiting /admin/munin which is then checked in the routes that proxy the Munin pages. The cookie's lifetime is kept limited to limit the opportunity for any unknown CSRF attacks via the Munin CGI script.
2021-09-24 08:11:36 -04:00
Joshua Tauberer
66b15d42a5 CHANGELOG entries 2021-09-24 08:11:36 -04:00
drpixie
df46e1311b
Include NSD config files from /etc/nsd/nsd.conf.d/*.conf (#2035)
And write MIAB dns zone config into /etc/nsd/nsd.conf.d/zones.conf. Delete lingering old zones.conf file.

Co-authored-by: Joshua Tauberer <jt@occams.info>
2021-09-24 08:07:40 -04:00
Elsie Hupp
353084ce67
Use "smart invert" for dark mode (#2038)
* Use "smart invert" for dark mode

Signed-off-by: Elsie Hupp <9206310+elsiehupp@users.noreply.github.com>

* Add more contrast to form controls

Co-authored-by: Joshua Tauberer <jt@occams.info>
2021-09-19 09:53:03 -04:00
David Duque
04e1c0647e
Ability to set custom TTL values for custom DNS records (#28) 2021-09-16 15:35:04 +01:00
mailinabox-contributor
91079ab934
add numeric flag value to DNSSEC DS status message (#2033)
Some registrars (e.g. Porkbun) accept Key Data when creating a DS RR,
but accept only a numeric flags value to indicate the key type (256 for KSK, 257 for ZSK).

https://datatracker.ietf.org/doc/html/rfc5910#section-4.3
2021-09-10 16:12:41 -04:00
David Duque
4c4cce836e
Merge changes from upstream 2021-09-07 17:08:05 +01:00
Joshua Tauberer
e5909a6287 Allow non-admin login to the control panel and show/hide menu items depending on the login state
* When logged out, no menu items are shown.
* When logged in, Log Out is shown.
* When logged in as an admin, the remaining menu items are also shown.
* When logged in as a non-admin, the mail and contacts/calendar instruction pages are shown.

Fixes #1987
2021-09-06 09:23:58 -04:00
Joshua Tauberer
26932ecb10 Add a 'welcome' panel to the control panel and make it the default page instead of the status checks which take too long to load
Fixes #2014
2021-09-06 09:23:58 -04:00
Joshua Tauberer
e884c4774f Replace HMAC-based session API keys with tokens stored in memory in the daemon process
Since the session cache clears keys after a period of time, this fixes #1821.

Based on https://github.com/mail-in-a-box/mailinabox/pull/2012, and so:

Co-Authored-By: NewbieOrange <NewbieOrange@users.noreply.github.com>

Also fixes #2029 by not revealing through the login failure error message whether a user exists or not.
2021-09-06 09:23:58 -04:00
Joshua Tauberer
53ec0f39cb Use 'secrets' to generate the system API key and remove some debugging-related code
* Rename the 'master' API key to be called the 'system' API key
* Generate the key using the Python secrets module which is meant for this
* Remove some debugging helper code which will be obsoleted by the upcoming changes for session keys
2021-09-06 09:23:58 -04:00
Joshua Tauberer
700188c443 Roundcube 1.5 RC 2021-09-06 09:23:58 -04:00
David Duque
09f635c1f9
v0.54.POWER.5 2021-08-26 17:17:56 +01:00
Greatest125
db1330795f
fixed spelling mistake (#26) 2021-08-26 16:59:19 +01:00
David Duque
ba80d9e72d
Show backup retention period form when configuring B2 backups (#2024) 2021-08-23 06:25:41 -04:00
David
aca49ea674
(vagrant) Permanently enable IPv6 on loopback
Required by NSD; The Ubuntu image we use comes with it disabled so we need to
enable it ourselves.
2021-08-23 02:10:34 +01:00
David Duque
d8c77527bd
Debian 11 support (#25) 2021-08-23 02:06:38 +01:00
David Duque
d557885aab
SMTP Relay feature rework (#23) 2021-08-23 02:06:10 +01:00
Joshua Tauberer
a71a58e816
Re-order DS record algorithms by digest type and revise warning message (#2002) 2021-08-22 14:45:56 -04:00
Joshua Tauberer
67b5711c68 Recommend that DS records be updated to not use SHA1 and exclude MUST NOT methods (SHA1) and the unlikely option RSASHA1-NSEC3-SHA1 (7) + SHA-384 (4) from the DS record suggestions 2021-08-22 14:43:46 -04:00
myfirstnameispaul
20ccda8710 Re-order DS record algorithms by digest type and revise warning message.
Note that 7, 4 is printed last in the status checks page but does not appear in the file, and I couldn't figure out why.
2021-08-22 14:29:36 -04:00
NewbieOrange
0ba841c7b6
fail2ban now supports ipv6 (#2015)
Since fail2ban 0.10.0, ipv6 support has been added. The current Ubuntu 18.04 repository has fail2ban 0.10.2, which does have ipv6 protection.
2021-08-22 14:13:58 -04:00
lamkin
daad122236
Ignore bad encoding in email addresses when parsing maillog files (#2017)
local/domain parts of email address should be standard ASCII or
UTF-8. Some email addresses contain extended ASCII, leading to
decode failure by the UTF-8 codec (and thus failure of the
Usage-Report script)

This change allows maillog parsing to continue over lines
containing such addresses
2021-08-16 11:46:32 -04:00
David
2c975e43cc
Add all supported deployments; adjust box sizes to reflect a very bad case scenario 2021-08-14 16:51:49 +01:00
David
a4c5f14237
Revert nsd changes 2021-08-02 15:02:54 +01:00
David
8b2f7f2e4c
v0.54.POWER.4 2021-07-29 01:42:55 +01:00
David
aae5fb4b74
nsd: Don't rely on private and public ip's being the same 2021-07-29 01:33:49 +01:00
David
fd15b28b3d
bind9: Configure on default/bind9 and default/named 2021-07-29 01:33:24 +01:00
NewbieOrange
21ad26e452
Disable auto-complete for 2FA code in the control panel login form (#2013) 2021-07-28 16:39:40 -04:00
David
e98d830f7f
v0.54.POWER.3 2021-07-04 11:24:11 +01:00
David
c171b6491a
Forgot to account that b2sdk had modules :c 2021-07-04 11:17:21 +01:00
David
9ab5733af4
v0.54.POWER.2 2021-07-03 22:55:12 +01:00
David
8cb60ecad7
Fix script warnings on Ubuntu 2021-07-03 22:50:49 +01:00
David
63081c647a
v0.54.POWER.1 2021-07-03 21:27:01 +01:00
David
b2854c3afb
duplicity on debian 10 runs on python 2 2021-07-03 21:26:07 +01:00
David
dc7bae04c7
Fix pgp.sh complaining about grep 2021-07-03 01:08:40 +01:00
David
b53add2798
Make output from setup scripts a little bit less noisy 2021-07-03 00:46:31 +01:00
David
e1aabc4504
Backups: Alternative imports for legacy path (debian 10) 2021-07-03 00:25:42 +01:00
David
f55c0a68b6
Management: Add get_os_code() function 2021-07-03 00:21:21 +01:00
David
c125f462e1
Add get_os_code helper function to setup scripts 2021-07-02 21:28:25 +01:00
David
3018cdd698
v0.54.POWER.0 2021-06-28 00:17:23 +01:00
David
edfb1cf623
Resolve dovecot deprecations 2021-06-27 23:11:24 +01:00
David
d0b5794588
New version notice - point to the correct page 2021-06-27 22:26:19 +01:00
David
afe7123f70
Merge v0.54 from upstream 2021-06-27 22:24:26 +01:00
Joshua Tauberer
4cb46ea465 v0.54 2021-06-20 15:50:04 -04:00
David Duque
9f9eb920b3
v0.53.POWER.2 2021-05-16 23:20:55 +01:00
David Duque
217b0b51ff
Bad bootstrap script, fixing! 2021-05-16 23:20:20 +01:00
David Duque
f382a55a0a
v0.53.POWER.1 2021-05-16 21:41:37 +01:00
David Duque
483817440e
Fetch updates from upstream 2021-05-16 21:18:40 +01:00
Joshua Tauberer
35fa3fe891 Changelog entries 2021-05-15 16:50:19 -04:00
Joshua Tauberer
d510c8ae2a Enable and recommend port 465 for mail submission instead of port 587 (fixes #1849)
Port 465 with "implicit" (i.e. always-on) TLS is a more secure approach than port 587 with explicit (i.e. optional and only on with STARTTLS). Although we reject credentials on port 587 without STARTTLS, by that point credentials have already been sent.
2021-05-15 16:42:14 -04:00
Joshua Tauberer
e283a12047 Add null SPF, DMARC, and MX records for automatically generated autoconfig, autodiscover, and mta-sts subdomains; add null MX records for custom A-record subdomains
All A/AAAA-resolvable domains that don't send or receive mail should have these null records.

This simplifies the handling of domains a bit by handling automatically generated subdomains more like other domains.
2021-05-15 16:42:14 -04:00
Joshua Tauberer
e421addf1c Pre-load domain purpopses when building DNS zonefiles rather than querying mail domains at each subdomain 2021-05-09 08:16:07 -04:00
Joshua Tauberer
354a774989 Remove a debug line added in 8cda58fb 2021-05-09 07:34:44 -04:00
Joshua Tauberer
aaa81ec879 Fix indentation issue in bc4ae51c2d 2021-05-08 09:06:18 -04:00
Joshua Tauberer
dbd6dae5ce Fix exit status issue cased by 69fc2fdd 2021-05-08 09:02:48 -04:00
John @ S4
d4c5872547
Make clear that non-AWS S3 backups are supported (#1947)
Just a few wording changes to show that it is possible to make S3 backups to other services than AWS - prompted by a thread on MIAB discourse.
2021-05-08 08:32:58 -04:00
Thomas Urban
3701e05d92
Rewrite envelope from address in sieve forwards (#1949)
Fixes #1946.
2021-05-08 08:30:53 -04:00
Hala Alajlan
bc4ae51c2d
Handle query dns timeout unhandled error (#1950)
Co-authored-by: hala alajlan <halalajlan@gmail.com>
2021-05-08 08:26:40 -04:00
Jawad Seddar
12aaebfc54
custom.yaml: add support for X-Frame-Options header and proxy_redirect off (#1954) 2021-05-08 08:25:33 -04:00
jvolkenant
49813534bd
Updated Nextcloud to 20.0.8, contacts to 3.5.1, calendar to 2.2.0 (#1960) 2021-05-08 08:24:04 -04:00
jvolkenant
16e81e1439
Fix to allow for non forced "enforce" MTA_STS_MODE (#1970) 2021-05-08 08:18:49 -04:00
Joshua Tauberer
b7b67e31b7 Merged point release branch for v0.53a
Changed the Z-Push download URL.
2021-05-08 08:14:39 -04:00
Joshua Tauberer
2e7f2835e7 v0.53a 2021-05-08 08:13:37 -04:00
Joshua Tauberer
8a5f9f464a Download Z-Push from alternate site
The old server has been down for a few days.

Solution from https://discourse.mailinabox.email/t/temporary-fix-for-failed-wget-o-tmp-z-push-zip-https-stash-z-hub-io/8028. Fixes #1974.
2021-05-08 07:59:53 -04:00
Joshua Tauberer
69fc2fdd3a Hide spurrious Nextcloud setup output 2021-05-03 19:41:00 -04:00
Joshua Tauberer
9b07d86bf7 Use $(...) notation instead of legacy backtick notation for embedded shell commands
shellcheck reported

    SC2006: Use $(...) notation instead of legacy backticked `...`.

Fixed by applying shellcheck's diff output as a patch.
2021-05-03 19:28:23 -04:00
Joshua Tauberer
ae3feebd80 Fix warnings reported by shellcheck
* SC2068: Double quote array expansions to avoid re-splitting elements.
* SC2186: tempfile is deprecated. Use mktemp instead.
* SC2124: Assigning an array to a string! Assign as array, or use * instead of @ to concatenate.
* SC2102: Ranges can only match single chars (mentioned due to duplicates).
* SC2005: Useless echo? Instead of 'echo $(cmd)', just use 'cmd'.
2021-05-03 19:25:09 -04:00
Joshua Tauberer
2c295bcafd Upgrade the Roundcube persistent login cookie encryption to AES-256-CBC and increase the key length accordingly
This change will force everyone to be logged out of Roundcube since the encryption key and cipher won't match anyone's already-set cookie, but this happens anyway after every Mail-in-a-Box update since we generate a new key each time already.

Fixes #1968.
2021-04-23 17:04:56 -04:00
David Duque
9f13ee6d55
v0.53.POWER.0 2021-04-13 23:02:08 +01:00
David Duque
40babe3e03
do_web_update: Order the domains in some way before writing to the nginx local.conf 2021-04-13 23:01:18 +01:00
David Duque
b9bdf50628
Merge v0.53 from upstream 2021-04-13 16:35:02 +01:00
Joshua Tauberer
8cda58fb22 Speed up status checks a bit by removing a redundant check if the PRIMARY_HOSTNAME certificate is signed and valid 2021-04-12 19:42:12 -04:00
Joshua Tauberer
178c587654 Migrate to the ECDSAP256SHA256 (13) DNSSEC algorithm
* Stop generating RSASHA1-NSEC3-SHA1 keys on new installs since it is no longer recommended, but preserve the key on existing installs so that we continue to sign zones with existing keys to retain the chain of trust with existing DS records.
* Start generating ECDSAP256SHA256 keys during setup, the current best practice (in addition to RSASHA256 which is also ok). See https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml#dns-sec-alg-numbers-1 and https://www.cloudflare.com/dns/dnssec/ecdsa-and-dnssec/.
* Sign zones using all available keys rather than choosing just one based on the TLD to enable rotation/migration to the new key and to give the user some options since not every registrar/TLD supports every algorithm.
* Allow a user to drop a key from signing specific domains using DOMAINS= in our key configuration file. Signing the zones with extraneous keys may increase the size of DNS responses, which isn't ideal, although I don't know if this is a problem in practice. (Although a user can delete the RSASHA1-NSEC3-SHA1 key file, the other keys will be re-generated on upgrade.)
* When generating zonefiles, add a hash of all of the DNSSEC signing keys so that when the keys change the zone is definitely regenerated and re-signed.
* In status checks, if DNSSEC is not active (or not valid), offer to use all of the keys that have been generated (for RSASHA1-NSEC3-SHA1 on existing installs, RSASHA256, and now ECDSAP256SHA256) with all digest types, since not all registers support everything, but list them in an order that guides users to the best practice.
* In status checks, if the deployed DS record doesn't use a ECDSAP256SHA256 key, prompt the user to update their DS record.
* In status checks, if multiple DS records are set, only fail if none are valid. If some use ECDSAP256SHA256 and some don't, remind the user to delete the DS records that don't.
* Don't fail if the DS record uses the SHA384 digest (by pre-generating a DS record with that digest type) but don't recommend it because it is not in the IANA mandatory list yet (https://www.iana.org/assignments/ds-rr-types/ds-rr-types.xhtml).

See #1953
2021-04-12 19:42:12 -04:00
Joshua Tauberer
34569d24a9 v0.53 2021-04-11 12:45:37 -04:00
David Duque
a3851bd6be
v0.52.POWER.3 2021-03-30 20:34:27 +01:00
David Duque
f4df9a5e1a
When updating, kickoff the database again so that the noreply address is added 2021-03-30 20:32:56 +01:00
David Duque
c2f627ea82
update_wkd(): Return 'OK' at the end 2021-03-30 20:32:01 +01:00
David Duque
5e20a00e25
v0.52.POWER.2 2021-03-30 20:09:01 +01:00
David Duque
103ff39500
get_web_domains(): Only return www redirects when asked to (include_www_redirects) 2021-03-30 20:01:46 +01:00
David Duque
aa41702825
When creating reports, do not output the number of days until the key expires 2021-03-30 16:23:08 +01:00
David Duque
a2193289e2
Merge jrsupplee's quota fork 2021-03-30 13:09:35 +01:00
David Duque
aa0da22614
Add a nice welcome page 2021-03-30 01:35:16 +01:00
David Duque
071002b755
Changes to the noreply-daemon key 2021-03-30 00:14:00 +01:00
David Duque
127629611b
Force WKD rebuild when importing or deleting keys 2021-03-29 17:54:58 +01:00
David Duque
c260e164eb
Remove print() calls 2021-03-29 17:54:24 +01:00
David Duque
c25a935f2b
strip_and_export(): Parse key contents to determine id's to exclude 2021-03-29 17:14:44 +01:00
David Duque
013f6f2ed1
Don't calculate uidlists when parsing the list, as they're not ordered accurately 2021-03-29 16:32:47 +01:00
David Duque
0f5a5bfbb1
Handle 'KEYEXPIRED' warnings 2021-03-29 16:25:50 +01:00
David Duque
200aefee00
Add data sink to the fork_context decorator 2021-03-29 16:23:42 +01:00
David Duque
353645f8db
Sync with master 2021-03-24 11:05:03 +00:00
David Duque
2ac999fca4
v0.52.POWER.1 2021-03-24 10:48:45 +00:00
David Duque
e8aee7483e
Update vendor software:
- Nextcloud to 20.0.8
- - Contacts to 3.5.1
- JQuery to 3.6.0
- FontAwesome to 5.15.3
- Roundcube to 1.4.11
2021-03-24 10:44:37 +00:00
David Duque
676f3aaf24 Do not disclose the distro in the admin login page (#9) 2021-03-24 10:06:45 +00:00
David Duque
531e67bcdb Change some status check types to 'Not Applicable' as they do not really qualify as warnings 2021-03-24 10:04:41 +00:00
David Duque
d3cf70db27 Add 'not applicable' status check type 2021-03-24 10:01:05 +00:00
David Duque
13b1b31fb1
Fix email_administrator.py script 2021-03-08 23:54:31 +00:00
David Duque
2827d33597
wkd: Sort by domain (web implementation) 2021-03-08 23:48:40 +00:00
David Duque
046dac6f8f
wkd: Sort the email addresses by domain 2021-03-08 23:39:50 +00:00
David Duque
0085dfbbe2
wkd: Use get_all_mail_addresses() 2021-03-07 23:39:31 +00:00
David Duque
b570cca23c
Final bug-fixing 2021-03-07 23:38:34 +00:00
David Duque
c575aea958
get_all_mail_addresses() umbrella function 2021-03-07 23:28:19 +00:00
David Duque
b3a3136859
remove_noreply_address() - use sanitized address 2021-03-07 23:05:46 +00:00
David Duque
6078a994f2
remove_noreply_address() 2021-03-07 23:05:10 +00:00
David Duque
dca9cc09cd
Do not forget to commit at the end 2021-03-07 23:00:41 +00:00
David Duque
96dec08fca
Noreply backend stub 2021-03-07 22:57:41 +00:00
David Duque
2e47d84972
Add noreply table migration step 2021-03-07 20:42:11 +00:00
David Duque
e5eb47b765
Do not use naked domains on the 'From' field
Decreases the chances of spamassassin yeeting the message
to the Spam folder
2021-03-07 20:37:43 +00:00
David Duque
c663363668
Do not disclose the distro in the admin login page (#9) 2021-03-07 20:28:33 +00:00
David Duque
0a7d7c41d1
Refactor PGP key generation
Extract new fingerprint from generation command output;
Verify that the generation has suceeded;
Verify that the current key fingerprint actually exists;
2021-03-07 18:32:21 +00:00
Joshua Tauberer
6653dbb2e2 Sort the Custom DNS by zone and qname, and add an option to go back to the old sort order (creation order)
Update the zone grouping style on the users and aliases page to match.

Fixes #1927
2021-02-28 09:40:32 -05:00
Joshua Tauberer
5fc1162355 Other CHANGELOG entries 2021-02-28 08:22:30 -05:00
Paul
a839602cba
Enable sending DMARC failure reports (#1929)
Configures opendmarc to send failure reports for domains that request them, including when p=none.

The emails are sent as the package default of package name and user@hostname: OpenDMARC Filter <opendmarc@box.example.com>

Note I have been running this for several months with a configuration I did not include in the PR to have reports BCC'd to me (FailureReportsBcc postmaster@example.com). Very low load for my personal server of rarely more than a dozen emails sent out per day.

I am not familiar with editing scripts, so apologies in advance and please feel free to correct me.
2021-02-28 08:21:15 -05:00
Joshua Tauberer
f21a41dc84 Merge #1932, with some edits 2021-02-28 08:16:50 -05:00
davDevOps
055ac07663 Update roundcube to 1.4.11
roundcube Bug Fixes:

Fix for Cross-Site Scripting (XSS) via HTML messages with malicious CSS content
General Improvements from roundcube's Issue Tracker
2021-02-28 08:14:17 -05:00
davDevOps
c7b295f403 Update zpush to 2.6.2 2021-02-28 08:05:40 -05:00
Joshua Tauberer
d36a2cc938 Enable Backblaze B2 backups
This reverts commit b1d703a5e7 and adds python3-setuptools per the first version of #1899 which fixes an installation error for the b2sdk Python package.
2021-02-28 08:04:14 -05:00
jeremitu
82ca54df96
Fixed #1894 log date over year change, START_DATE < END_DATE now. (#1905)
* Fixed #1894 log date over year change, START_DATE < END_DATE now.

* Corrected mail_log.py argument help and message.

Co-authored-by: Jarek <jarek@box.jurasz.de>
2021-02-28 07:59:26 -05:00
David Duque
966ded762e
vagrant: Add on-start provisioner
Allows for faster deployments/start times
2021-02-17 12:17:10 +00:00
David Duque
8567011f9d
Add noreply table to database
Incoming email to addresses in this table will be automatically rejected
(because it doesn't have a mailbox)
2021-02-17 02:55:50 +00:00
David Duque
3862892568
Do not overwrite domain flags 2021-02-14 00:42:16 +00:00
David Duque
8764a69ea2
Change some status check types to 'Not Applicable' as they do not really qualify as warnings 2021-02-13 02:47:22 +00:00
David Duque
d11d0a1708
Add 'not applicable' status check type 2021-02-13 02:43:03 +00:00
David Duque
a5d7ffe29d
Include 'openpgpkey.domain' on status checks 2021-02-13 02:09:16 +00:00
David Duque
36dd640610
Fix WKD folder permissions and 'touch' file before reading it 2021-02-13 01:42:04 +00:00
David Duque
9b50d05751
Resync with master branch 2021-02-13 01:00:37 +00:00
David Duque
708e53adf0 Merge branch 'dev-wkd' into development 2021-02-13 00:57:16 +00:00
David Duque
ca96f5f5ba
Fix bugs 2021-02-13 00:53:10 +00:00
David Duque
b165e6157d
Implement flags into nginx building 2021-02-13 00:43:13 +00:00
David Duque
40f73985ea
(WIP) web domain flags 2021-02-12 16:23:26 +00:00
David Duque
06d210fdc3
Move wkd nginx configuration to a separate template 2021-02-11 23:19:58 +00:00
David Duque
4e6aa8f0d2
web: Optimize get_web_domains function. Will use something similar but different internally 2021-02-11 23:19:35 +00:00
David Duque
7e4325afd8
dns: Use A/AAAA records instead of CNAME's 2021-02-11 23:18:52 +00:00
David Duque
75fa24c8e9
Less debugging messages 2021-02-11 01:00:44 +00:00
David Duque
ff71bd2c93
Fix typo 2021-02-11 01:00:32 +00:00
David Duque
824b368891
Client-side POST'ing 2021-02-11 00:56:21 +00:00
David Duque
2fe8522585
Always overwrite the .wkdlist.yml file 2021-02-11 00:55:56 +00:00
David Duque
4f3002090e
Fix PGP forking issues (don't copy pipes/sockets/etc.) 2021-02-11 00:55:10 +00:00
David Duque
d218f771a9
Fix WKD building issues (SHA1 backend, hash encoding) 2021-02-11 00:38:23 +00:00
David Duque
7a2edb11a0
Refactor POST config mechanics 2021-02-11 00:34:36 +00:00
David Duque
4641ec112e
Dirty configuration mechanisms 2021-02-11 00:04:52 +00:00
David Duque
49394741d3
Don't display buttons/dropdowns for emails with no keys 2021-02-10 23:49:32 +00:00
David Duque
704335dc1c
Update button 2021-02-10 23:39:23 +00:00
David Duque
3d777d86d2
Don't wrap email addresses 2021-02-10 15:27:32 +00:00
David Duque
f942be515f
Process key emails 2021-02-10 02:56:00 +00:00
David Duque
867be9ba37
Options field is a set 2021-02-10 02:21:36 +00:00
David Duque
108f619880
WKD Maps: Ignore user id's that contain email addresses not hosted by our box 2021-02-10 02:06:00 +00:00
David Duque
1ea90a5dbb
Include unique set of UID emails on key representations 2021-02-10 01:54:45 +00:00
John Supplee
bd38f5bcca Merge tag 'v0.52' of https://github.com/mail-in-a-box/mailinabox
v0.52 (January 31, 2021)
------------------------

Software updates:

* Upgraded Roundcube to version 1.4.10.
* Upgraded zpush to 2.6.1.

Mail:

* Incoming emails with SPF/DKIM/DMARC failures now get a higher spam score, and these messages are more likely to appear in the junk folder, since they are often spam/phishing.
* Fixed the MTA-STS policy file's line endings.

Control panel:

* A new Download button in the control panel's External DNS page can be used to download the required DNS records in zonefile format.
* Fixed the problem when the control panel would report DNS entries as Not Set by increasing a bind query limit.
* Fixed a control panel startup bug on some systems.
* Improved an error message on a DNS lookup timeout.
* A typo was fixed.

DNS:

* The TTL for NS records has been increased to 1 day to comply with some registrar requirements.

System:

* Nextcloud's photos, dashboard, and activity apps are disabled since we only support contacts and calendar.

# Conflicts:
#	setup/bootstrap.sh
2021-02-08 23:51:32 +02:00
David Duque
a4675b821a
Front-end implementation: Purpose/expiration info 2021-02-07 17:34:12 +00:00
David Duque
cd0aae6bbf
Front-end implementation: Change selection according to user 2021-02-07 17:13:24 +00:00
David Duque
77bd9e1ab5
Front-end implementation: Display current selection 2021-02-07 17:06:27 +00:00
David Duque
4d055e38b3
Front-end implementation: Start creating dropdown items 2021-02-07 17:03:08 +00:00
David Duque
4ce497deb7
Front-end implementation: Do email, key dropdown left 2021-02-07 15:42:27 +00:00
David Duque
18237029a0
Begin front-end implementation 2021-02-07 15:24:34 +00:00
David Duque
25bd251d10
Pretty "long id" function
The long id is simply the last 16 characters (64 bits) of the
key's fingerprint.
2021-02-07 13:16:51 +00:00
David Duque
1d657191eb
Change /system/pgp/wkd endpoint so that keys are able to be indexed by their fingerprint 2021-02-07 12:34:05 +00:00
David Duque
378ebcf9a4
Start WKD management front-end 2021-02-07 12:33:10 +00:00
David Duque
1ad7ad855a
Document /system/pgp/wkd endpoint in ReDoc 2021-02-07 03:23:14 +00:00
David Duque
071f1581c0
Implement /system/pgp/wkd to return WKD status 2021-02-07 02:42:16 +00:00
David Duque
4bdd4c7478
Add an explicit notice that WKD is experimental
Because the underlying documents are still drafts (and not set-in-stone
internet standards), the feature may be modified, or even retired, as the
drafts are updated and/or expire.
2021-02-06 23:34:17 +00:00
jvolkenant
af62e7a99b
Fixes unbound variable when upgrading from Nextcloud 13 (#1913) 2021-02-06 16:49:43 -05:00
David Duque
a002ee9355
Add WKD dropdown template 2021-02-06 16:41:01 +00:00
David Duque
f41eeb37c1
Release v0.52.POWER.0 2021-02-01 02:22:15 +00:00
David Duque
ba68bd9941
Automatically import existing local CA cerificates 2021-02-01 02:20:38 +00:00
David Duque
e6f22c53e5
Update admin panel dependencies 2021-02-01 01:57:38 +00:00
David Duque
18d36831dc
Update NextCloud components 2021-02-01 01:49:05 +00:00
David Duque
f47cdbaee1
External DNS: Add some margin between dropdown and buttons 2021-02-01 01:29:23 +00:00
David Duque
4829e687ff
Merge changes from master 2021-01-31 16:20:15 +00:00
Joshua Tauberer
90d63fd208 v0.52 2021-01-31 08:48:14 -05:00
Joshua Tauberer
e81963e585 Remove the instructions for checking that release tags are signed by me since I am not going to do that anymore 2021-01-31 08:47:59 -05:00
Joshua Tauberer
b1d703a5e7 Disable Backblaze B2 backups until #1899 is resolved 2021-01-31 08:33:56 -05:00
David Duque
f036389645
Add full Bootstrap bundle (Contains Popper.js); Add more info to the WKD page 2021-01-31 00:54:39 +00:00
Felix Spöttel
e3d98b781e
Warn when connection to Spamhaus times out (#1817) 2021-01-28 18:22:43 -05:00
jvolkenant
50d50ba653
Update zpush to 2.6.1 (#1908) 2021-01-28 18:20:19 -05:00
Josh Brown
879467d358
Fix typo in users.html (#1895)
lettters -> letters
fixes #1888
2021-01-05 21:12:01 -05:00
Nicolas North
8025c41ee4
Bump TTL for NS records to 1800 (30 min) to 86400 (1 day) as some registries require this (#1892)
Co-authored-by: Nicolas North [norðurljósahviða] <nz@tillverka.xyz>
2021-01-03 17:57:54 -05:00
Josh Brown
7a5d729a53
Fix misspelling (#1893)
Change Blackblaze to Backblaze. Include B2 as the integration name.
2021-01-03 17:54:31 -05:00
jcm-shove-it
e2f9cd845a
Update roundcube to 1.4.10 (#1891) 2020-12-28 08:11:33 -05:00
David Duque
8085fe149b
Fix WKD DNS update 2020-12-27 01:47:21 +00:00
David Duque
52be85d0cc
WKD Management menu 2020-12-27 00:46:56 +00:00
David Duque
012605b5b2
User-key maps for WKD management 2020-12-26 22:34:19 +00:00
Joshua Tauberer
e26cf4512c Update CHANGELOG 2020-12-25 17:28:34 -05:00
jvolkenant
c7280055a8
Implement SPF/DMARC checks, add spam weight to those mails (#1836) 2020-12-25 17:22:24 -05:00
Hilko
003e8b7bb1
Adjust max-recursion-queries to fix alternating rdns status (#1876) 2020-12-25 17:19:16 -05:00
David Duque
492cfd0309
Add openpgpkey CNAME records on DNS zones 2020-12-21 00:21:03 +00:00
David Duque
7744b33e2b
WKD Building 2020-12-20 23:49:36 +00:00
David Duque
b9445f61f4
parse_wkd_list: Always return full key fingerprints 2020-12-20 23:18:02 +00:00
David Duque
9be5d2b454
Add public nginx endpoints for WKD 2020-12-20 23:11:28 +00:00
Hilko
3422cc61ce
Include en_US.UTF-8 locale in daemon startup (#1883)
Fixes #1881.
2020-12-19 19:11:58 -05:00
David Duque
6a08551402
Deepcopy writeable config file 2020-12-08 22:30:43 +00:00
David Duque
05172328d5
Advances in WKD building 2020-12-08 22:18:59 +00:00
David Duque
3e489f59f2
Clarify on docs that aliases also count 2020-12-08 20:05:21 +00:00
David Duque
ef58b897d6
Refactor configuration functions 2020-12-08 19:54:59 +00:00
David Duque
15ac7988f3
Implement key stripping 2020-12-06 02:32:28 +00:00
David Duque
7f2c43219e
pgp: @fork_context decorator 2020-12-06 01:37:20 +00:00
David Duque
36794269b4
pgp: Allow other contexts to be used. Default to our main working context 2020-12-06 00:35:53 +00:00
John Supplee
682550f6c5 Update README 2020-12-03 23:25:22 +02:00
John Supplee
19e2066969 v0.51 (November 14, 2020)
Software updates:
 
 * Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
 * Upgraded Roundcube to version 1.4.9.
 
 Mail:
 
 * The MTA-STA max_age value was increased to the normal one week.
 
 Control Panel:
 
 * Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
 * TLS certificate expiry dates are now shown in ISO8601 format for clarity.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl+v8k4PHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BMYUIAJTD1iKzY1SoDNSp8JMPn2sWusOnJNrnvYEV
 vsrBM4AzwJv3DIZKSkYCitbTQW2FsTcjF6Jl5PCavEmAGe55AIKAPM/52Uq6jqDE
 aR8EZvI9ca1i7yR7DOHEI043QSPmp/iCFD48vvmKgN/LZy67TaHaOlGJbc3nfpk0
 y7ejMpF/6RP6ik4snnRQoWTFShaOpB9WcEVnUO7CHZdWcpSCZ55c9yi6A6ExGk7e
 97R5+JN1MgOdZ6rzWZuMWiz7EZ/Ew4jYLZpOwg8qJm0HNbYJ6+/xxsQBwaQzyBw3
 TsTl4GmunNPfoNrmKdJeLy0sBwiVBv/rysjWjim5v8jAYBoKoUQ=
 =2oRU
 -----END PGP SIGNATURE-----

Merge tag 'v0.51' of https://github.com/mail-in-a-box/mailinabox

v0.51 (November 14, 2020)

Software updates:

* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
* Upgraded Roundcube to version 1.4.9.

Mail:

* The MTA-STA max_age value was increased to the normal one week.

Control Panel:

* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
* TLS certificate expiry dates are now shown in ISO8601 format for clarity.

# gpg verification failed.

# Conflicts:
#	management/daemon.py
#	setup/bootstrap.sh
#	setup/mail-users.sh
#	tools/mail.py
2020-12-03 23:21:37 +02:00
Hilko
8664afa997
Implement Backblaze for Backup (#1812)
* Installing b2sdk for b2 support
* Added Duplicity PPA so the most recent version is used
* Implemented list_target_files for b2
* Implemented b2 in frontend
* removed python2 boto package
2020-11-26 07:13:31 -05:00
Joshua Tauberer
82229ce04b Document how to start the control panel from the command line and in debugging use a stable API key 2020-11-26 07:11:49 -05:00
Richard Willis
f66e609d3f
Api spec cleanup (#1869)
* Fix indentation

* Add parameter definition and remove unused model

* Update version

* Quote example string
2020-11-26 06:56:04 -05:00
David Duque
e519cea350
PGP Keyring: Display expiry dates in ISO format 2020-11-21 15:29:21 +00:00
David Duque
822cc9957f Merge branch 'development' into dev-wkd 2020-11-21 03:26:46 +00:00
David Duque
392f05b975
PGP Keyring: Display expiry dates in ISO format 2020-11-21 03:26:27 +00:00
David Duque
fbd0df6fad Merge branch 'development' into dev-wkd 2020-11-21 03:12:23 +00:00
David Duque
570a22903a Merge branch 'master' into development 2020-11-21 03:12:00 +00:00
David Duque
ef282fc7d0
Version bump - v0.51.POWER.1 2020-11-21 02:42:26 +00:00
David Duque
ef116f13de
system-status: Make system status cover the whole page width 2020-11-21 02:41:37 +00:00
David Duque
a35b885fac
Replace dead glyphicons (with FontAwesome alternative) 2020-11-21 02:27:52 +00:00
David Duque
4fcde9223e
modal: Reorder header positions 2020-11-21 02:02:43 +00:00
Victor
b85b86e6de
Add download zonefile button to external DNS page (#1853)
Co-authored-by: Joshua Tauberer <jt@occams.info>
2020-11-16 06:03:41 -05:00
Joshua Tauberer
7fd35bbd11 Disable default Nextcloud apps that we don't support
Contacts and calendar are the only supported apps in Mail-in-a-Box.

Files can't be disabled.

Fixes #1864
2020-11-15 17:17:58 -05:00
David Duque
3d58edacdb Merge branch 'development' into dev-wkd 2020-11-15 19:20:00 +00:00
David Duque
13ef53de71
Merge changes from master 2020-11-15 19:15:20 +00:00
David Duque
2f50c5e6c2
Version bump 2020-11-15 18:45:13 +00:00
David Duque
c767f9eebe
Update Bootstrap to version 4.5.3 2020-11-15 18:33:46 +00:00
David Duque
a67a57913d v0.51 (November 14, 2020)
Software updates:
 
 * Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
 * Upgraded Roundcube to version 1.4.9.
 
 Mail:
 
 * The MTA-STA max_age value was increased to the normal one week.
 
 Control Panel:
 
 * Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
 * TLS certificate expiry dates are now shown in ISO8601 format for clarity.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl+v8k4PHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BMYUIAJTD1iKzY1SoDNSp8JMPn2sWusOnJNrnvYEV
 vsrBM4AzwJv3DIZKSkYCitbTQW2FsTcjF6Jl5PCavEmAGe55AIKAPM/52Uq6jqDE
 aR8EZvI9ca1i7yR7DOHEI043QSPmp/iCFD48vvmKgN/LZy67TaHaOlGJbc3nfpk0
 y7ejMpF/6RP6ik4snnRQoWTFShaOpB9WcEVnUO7CHZdWcpSCZ55c9yi6A6ExGk7e
 97R5+JN1MgOdZ6rzWZuMWiz7EZ/Ew4jYLZpOwg8qJm0HNbYJ6+/xxsQBwaQzyBw3
 TsTl4GmunNPfoNrmKdJeLy0sBwiVBv/rysjWjim5v8jAYBoKoUQ=
 =2oRU
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEAKK/toPAcMkE+dinLzJ3OKPArjoFAl+xc7sACgkQLzJ3OKPA
 rjo6Zw//eYyTBlfQfFHIsLYKxJbwh6fDrIG6/Za6898cPhkJ/ugBeJlNEyT/EjpU
 MvtIgEU9xbG/tjsnQXsgAXJ6s7ZWm1QB5D+wqUIEeAFUn5IkCnXo0wPZJhSTNZhD
 4InnWsicYZj/ByuSH179xHyTAx2uYDBbPT4HjUlzIsaopvWOKLvAfzY3r42AiNvZ
 e79MhKbtOs9kDkrB2LULRzz6WzJDKb11fJccf7UaBerwFvOarMr8hSpOysK0ocHk
 H0wbrGxjb8iBjczVP4OFh36satQ5l4B1W+QVIxZG9ufVAOe3dhv8HngaHqAVyUgF
 gWjDYTnL/anoMMew+kbn2sjeKH6m2ZA+u9g+mDyMGSECVVYhkpOpcbPjZlmlNAQN
 C5BHmHltIg90uicrhzEEPFDBR1JF7JrYO42EwnOWMwjhzRkH2cepVw86lDr+pbrH
 s3hvoWiFFt7cs5ShCpgZDL20ey1e+9wL6b72Qlo7ls7MK3vfZvLPxJLpTi+bnymD
 CNt82Mjpu3BrhjCIGp+px9E2JU/7wUwqyUbgWFtyqxCdJOZXA4ZXVtDs5pQFzhug
 G+Z1HxFmhxck17SD0uHhXJKRD8IRttnO5sBESJaLNB4Ws/KspHVPePNskB/1XSfr
 pFOqikZsoKOICZnpd/eTnUlciqFygqvB0WuFsJNttQN2dBpJViA=
 =ZMFZ
 -----END PGP SIGNATURE-----

Merge upstream v0.51
2020-11-15 18:30:19 +00:00
gumida
7ce41e3865
Changed mta-sts.txt end of line from LF to CRLF per RFC 8461 (#1863) 2020-11-15 07:54:34 -05:00
Joshua Tauberer
92221f9efb v0.51 2020-11-14 10:05:20 -05:00
David Duque
69e6299662
PGP module: clean up code 2020-11-01 20:07:40 +00:00
Joshua Tauberer
0bd3977cde CHANGELOG updates 2020-10-31 10:36:40 -04:00
Joshua Tauberer
6a979f4f52
Add TOTP two-factor authentication to admin panel login (#1814)
* add user interface for managing 2fa

* update user schema with 2fa columns

* implement two factor check during login

* Use pyotp for validating TOTP codes

* also implements resynchronisation support via `pyotp`'s `valid_window option

* Update API route naming, update setup page

* Rename /two-factor-auth/ => /2fa/
* Nest totp routes under /2fa/totp/
* Update ids and methods in panel to allow for different setup types

* Autofocus otp input when logging in, update layout

* Extract TOTPStrategy class to totp.py

* this decouples `TOTP` validation and storage logic from `auth` and moves it to `totp`
* reduce `pyotp.validate#valid_window` from `2` to `1`

* Update OpenApi docs, rename /2fa/ => /mfa/

* Decouple totp from users table by moving to totp_credentials table

* this allows implementation of other mfa schemes in the future (webauthn)
* also makes key management easier and enforces one totp credentials per user on db-level

* Add sqlite migration

* Rename internal validate_two_factor_secret => validate_two_factor_secret

* conn.close() if mru_token update can't .commit()

* Address review feedback, thanks @hija

* Use hmac.compare_digest() to compare mru_token

* Safeguard against empty mru_token column

* hmac.compare_digest() expects arguments of type string, make sure we don't pass None
 * Currently, this cannot happen but we might not want to store `mru_token` during setup

* Do not log failed login attempts for MissingToken errors

* Due to the way that the /login UI works, this persists at least one failed login each time a user logs into the admin panel. This in turn triggers fail2ban at some point.

* Add TOTP secret to user_key hash

thanks @downtownallday
* this invalidates all user_keys after TOTP status is changed for user
* after changing TOTP state, a login is required
* due to the forced login, we can't and don't need to store the code used for setup in `mru_code`

* Typo

* Reorganize the MFA backend methods

* Reorganize MFA front-end and add label column

* Fix handling of bad input when enabling mfa

* Update openAPI docs

* Remove unique key constraint on foreign key user_id in mfa table

* Don't expose mru_token and secret for enabled mfas over HTTP

* Only update mru_token for matched mfa row

* Exclude mru_token in user key hash

* Rename tools/mail.py to management/cli.py

* Add MFA list/disable to the management CLI so admins can restore access if MFA device is lost

Co-authored-by: Joshua Tauberer <jt@occams.info>
2020-10-31 10:27:38 -04:00
Joshua Tauberer
545e7a52e4 Add MFA list/disable to the management CLI so admins can restore access if MFA device is lost 2020-10-31 10:23:43 -04:00
David Duque
48c233ebe5
Update Roundcube to version 1.4.9 (#1830) 2020-10-31 10:01:14 -04:00
Michael Kroes
9a588de754
Upgrade Nextcloud to version 20.0.1 (#1848) 2020-10-31 09:58:26 -04:00
Joshua Tauberer
ac9ecc3bd3 Rename tools/mail.py to management/cli.py 2020-10-29 15:41:54 -04:00
David Duque
c4050964fe
Implement mapping primitives 2020-10-17 22:12:40 +01:00
David Duque
a2ead96bf3
Begin WKD manager 2020-10-17 20:41:58 +01:00
David Duque
8b166f3041
Display certificate expiry dates in ISO format (#1841) 2020-10-16 16:22:36 -04:00
Joshua Tauberer
5509420637 s/Days/Retention Days/ on the backup settings page 2020-10-15 14:11:43 -04:00
John Supplee
38ac127344 v0.50 (September 25, 2020)
--------------------------
 
 Setup:
 
 * When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.
 
 Mail:
 
 * An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
 * The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.
 
 DNS:
 
 * autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
 * IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.
 
 TLS:
 
 * TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.
 
 Control Panel:
 
 * The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
 * User passwords can now have spaces.
 * Status checks for automatic subdomains have been moved into the section for the parent domain.
 * Typo fixed.
 
 Web:
 
 * The default web page served on fresh installations now adds the `noindex` meta tag.
 * The HSTS header is revised to also be sent on non-success responses.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl9t2AgPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BZNkH/1jIGoWTz0xlS+e+TeXpHoCp/7zYAvQq/a/y
 vj9t1N1+bBg6Ywbd8UxyvOHwuL/UQU/5LTq6hk3gD+2ARfJUvDRbb047Xzlisg3N
 LhNoVhVbsxqKP1X2ZjeIBq9DgzMavuB64Bwd5UNdceM0Addi8KuCDOMF+FNY2t8k
 xytGjYdBi1/BG6SLBX+FAm5yrJghmkUJs2FnJjebSyyeV2HP3L1iBrk2N8UBd6PU
 fVjde534lgygFZK/8yXJpY2olfLMYJv7CaOMxvaW6RpbMI8VeLwDLfRt5LcrQZqq
 YXkuEnUI0eygbQYkeK/Vr1Vey6uQAWzIfbImEglHfvOXsZSYFXs=
 =SJNM
 -----END PGP SIGNATURE-----

Merge tag 'v0.50' of https://github.com/mail-in-a-box/mailinabox into master

v0.50 (September 25, 2020)
--------------------------

Setup:

* When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.

Mail:

* An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
* The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.

DNS:

* autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
* IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.

TLS:

* TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.

Control Panel:

* The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
* User passwords can now have spaces.
* Status checks for automatic subdomains have been moved into the section for the parent domain.
* Typo fixed.

Web:

* The default web page served on fresh installations now adds the `noindex` meta tag.
* The HSTS header is revised to also be sent on non-success responses.

# gpg verification failed.

# Conflicts:
#	.gitignore
#	setup/bootstrap.sh
2020-10-11 18:16:36 +02:00
John R. Supplee
c13343ec7f v0.48
Roundcube XSS vulnerability fixed.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl9GpkcPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BoYAH/2NjdfN2d6f45uPq/X32bBAc6wfI7Cs9yCKp
 LOrAfPlmE0jRSm9ThATfZvaWci2r2IFhsFzQ9bWHpbIP5YD7mDD50I2uTnZa9BV4
 MsI40VXoh0BAgkWRqK60rTw0lQ9YGT+1TNLDEs1Y7vBjfTCOh4MMn4jUXkIEHDQg
 2pSHY1RUq7T0wRaHS+rTPDccotS/xCGg6uZJ+gSlvhRdxakAe9mo8139KD/4fjT8
 HK6igpwHsn3POg7mmJoSYXtScmWRYfnSV9kyfYyVyjhu5/uIowdICwFOzX7G7ruM
 yA/azBlyMs898e5jYFR1tQqQ1rVYVy/nqCQOiyJa34ngHGSi41U=
 =a9fn
 -----END PGP SIGNATURE-----

Merge tag 'v0.48' of https://github.com/mail-in-a-box/mailinabox

v0.48

Roundcube XSS vulnerability fixed.
2020-10-07 14:33:26 +02:00
David Duque
a210261e34
Be consistent with notation 2020-10-04 22:20:17 +01:00
David Duque
d46b98dcda
Update api documentation file 2020-10-04 22:10:53 +01:00
David Duque
295fd32ade
Cover case where import is invalid 2020-10-04 22:10:30 +01:00
David Duque
f04a0da39f Merge branch 'master' into development 2020-10-04 16:38:10 +01:00
David Duque
dcbc320502
Just a little hotfix 2020-10-04 16:37:40 +01:00
David Duque
8519b7fc0e
Add pgp keyring management (#5)
This PR adds into the admin panel a front-end to manage PGP keys. Possibilities are many.
2020-10-04 16:35:59 +01:00
Felix Spöttel
7d6c7b6610
Increase mta-sts max_age to one week (#1829)
This aligns the policy with the example policy found in the  spec
see https://tools.ietf.org/html/rfc8461#section-3.2
2020-10-02 21:27:21 -04:00
Felix Spöttel
1f0e493b8c Exclude mru_token in user key hash 2020-09-30 12:34:26 +02:00
Felix Spöttel
ada2167d08 Only update mru_token for matched mfa row 2020-09-29 20:05:58 +02:00
Felix Spöttel
be5032ffbe Don't expose mru_token and secret for enabled mfas over HTTP 2020-09-29 19:46:02 +02:00
Felix Spöttel
00b3a3b0a9 Remove unique key constraint on foreign key user_id in mfa table 2020-09-29 19:39:40 +02:00
Felix Spöttel
6d82c0035a Update openAPI docs 2020-09-28 21:27:24 +02:00
Felix Spöttel
4dced10a3f Fix handling of bad input when enabling mfa 2020-09-28 21:06:59 +02:00
Joshua Tauberer
b80f225691 Reorganize MFA front-end and add label column 2020-09-27 08:31:23 -04:00
0pis
7f0f28f8e3
Use tabs instead of spaces in nginx conf (#1827)
* conf/nginx-primaryonly.conf: Use tabs instead of spaces
* management/web_update.py: Includes the tabs so they display with the correct indentation when added to the local.conf

Co-authored-by: 0pis <0pis>
2020-09-27 07:13:33 -04:00
David Duque
59f36b4dd6
Release v0.50.POWER.1 2020-09-27 02:36:03 +01:00
David Duque
d3b5ddf891
Update Bootstrap: 4.5.2 2020-09-27 02:34:37 +01:00
David Duque
5caaf4fd98
Update Nextcloud: 19.0.3 2020-09-27 02:23:45 +01:00
David Duque
2bfa65329a
Keep root in actual local.conf file 2020-09-27 02:17:49 +01:00
David Duque
81c64f0a8f
Re-add webmail roundcube (to primaryonly) 2020-09-27 02:05:16 +01:00
David Duque
4b7f6e20da
Update nginx files to discard non-essential locations for non-primary domains 2020-09-27 02:01:17 +01:00
David Duque
7725e6efe6
Revert .nginx.conf file features 2020-09-27 01:31:51 +01:00
David Duque
689df9cff5
Version bump 2020-09-27 00:38:53 +01:00
David Duque
6c608ea868
Update API docs 2020-09-27 00:36:33 +01:00
Joshua Tauberer
a8ea456b49 Reorganize the MFA backend methods 2020-09-26 09:58:25 -04:00
David Duque
7de99aa690 v0.50 (September 25, 2020)
--------------------------
 
 Setup:
 
 * When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.
 
 Mail:
 
 * An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
 * The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.
 
 DNS:
 
 * autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
 * IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.
 
 TLS:
 
 * TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.
 
 Control Panel:
 
 * The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
 * User passwords can now have spaces.
 * Status checks for automatic subdomains have been moved into the section for the parent domain.
 * Typo fixed.
 
 Web:
 
 * The default web page served on fresh installations now adds the `noindex` meta tag.
 * The HSTS header is revised to also be sent on non-success responses.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl9t2AgPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BZNkH/1jIGoWTz0xlS+e+TeXpHoCp/7zYAvQq/a/y
 vj9t1N1+bBg6Ywbd8UxyvOHwuL/UQU/5LTq6hk3gD+2ARfJUvDRbb047Xzlisg3N
 LhNoVhVbsxqKP1X2ZjeIBq9DgzMavuB64Bwd5UNdceM0Addi8KuCDOMF+FNY2t8k
 xytGjYdBi1/BG6SLBX+FAm5yrJghmkUJs2FnJjebSyyeV2HP3L1iBrk2N8UBd6PU
 fVjde534lgygFZK/8yXJpY2olfLMYJv7CaOMxvaW6RpbMI8VeLwDLfRt5LcrQZqq
 YXkuEnUI0eygbQYkeK/Vr1Vey6uQAWzIfbImEglHfvOXsZSYFXs=
 =SJNM
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEAKK/toPAcMkE+dinLzJ3OKPArjoFAl9vB/0ACgkQLzJ3OKPA
 rjpXTg/+L2W6LXtqJcDdPiLb7uRJ1a+R7DAPPLhZOXT8alFt6g2nAJHHI3NxKWVM
 KsrSGlL+XSw744tfEzw21WsDuoME2F536/q4V4iprQx0LSJ61EQtqFYABbHT7lSc
 EyJellcIBxvK9ZTrHhJy3jVJL5eEkrHr4YpaRd68CZGneziMbxZusrlD23OfOn+U
 Pi6O39+Xh9lB4nxMfzkjYwCPEyNsTaCieKforPE+7TYh6d5NFHp22e2/yNEwYHhv
 90txul+/ByeT6UNFsVQ+QXCpMr/m06W9zbCDgrArol12MlgeAg4bL2trgDUV2D9j
 Dpfo1SYo/VUYetlT98adxW7BK2JuGe3SsFDrgjNPDyMBZRoybLY/l1X5TF5d7dq/
 bhgDcHXSJ6iBmhZ8nGDuBWhiEld9orn/9vfj/nHmleurXxgDwMcGKn0eINDuX8Xd
 NauJdhyOiZLfy8+Rha9ltLlFC/sX8nq0o6iM1Xr+4UOTFVVxlVadkPTMOxuRIQfD
 +JaMRCoXLfbAknoGdKfAcxEAzzyylO6z4Ztj/fVp9SHjQgby1paLpJMHEVUaQzEZ
 VYqdOzmz7vrV1H5OHOIy6mthQrTw+Mg4KubJs7w99e3pZKJBpvp55+DLvA0JhKLD
 dVXqr7rBTkLk/tg4u2SWlj3aZOnkzMz0Iwiu5X+hx3kLl0f3Zgk=
 =VgsY
 -----END PGP SIGNATURE-----

Merge v0.50 from upstream
2020-09-26 10:21:01 +01:00
Joshua Tauberer
03bff5292b v0.50
v0.50 (September 25, 2020)
--------------------------

Setup:

* When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.

Mail:

* An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
* The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.

DNS:

* autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
* IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.

TLS:

* TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.

Control Panel:

* The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
* User passwords can now have spaces.
* Status checks for automatic subdomains have been moved into the section for the parent domain.
* Typo fixed.

Web:

* The default web page served on fresh installations now adds the `noindex` meta tag.
* The HSTS header is revised to also be sent on non-success responses.
2020-09-25 07:43:30 -04:00
Joshua Tauberer
e891a9a3f3 Update CHANGELOG 2020-09-21 15:59:38 -04:00
Joshua Tauberer
51aedcf6c3 Drop the MTA-STS TLSRPT record unless set explicitly 2020-09-21 15:57:17 -04:00
b-k
853008ddcc
Be more forgiving of people who missed the train on upgrading NextCloud (#1813)
Co-authored-by: B <ben@klemens.org>
2020-09-21 15:45:58 -04:00
Felix Spöttel
7d6427904f Typo 2020-09-12 16:38:44 +02:00
Felix Spöttel
dcb93d071c Add TOTP secret to user_key hash
thanks @downtownallday
* this invalidates all user_keys after TOTP status is changed for user
* after changing TOTP state, a login is required
* due to the forced login, we can't and don't need to store the code used for setup in `mru_code`
2020-09-12 16:34:06 +02:00
Felix Spöttel
2ea97f0643 Do not log failed login attempts for MissingToken errors
* Due to the way that the /login UI works, this persists at least one failed login each time a user logs into the admin panel. This in turn triggers fail2ban at some point.
2020-09-06 13:08:44 +02:00
Felix Spöttel
4791c2fc62 Safeguard against empty mru_token column
* hmac.compare_digest() expects arguments of type string, make sure we don't pass None
 * Currently, this cannot happen but we might not want to store `mru_token` during setup
2020-09-06 13:03:54 +02:00
Felix Spöttel
49c333221a Use hmac.compare_digest() to compare mru_token 2020-09-06 12:54:45 +02:00
Felix Spöttel
481a333dc0 Address review feedback, thanks @hija 2020-09-04 20:28:15 +02:00
Felix Spöttel
b0df35eba0 conn.close() if mru_token update can't .commit() 2020-09-03 20:39:03 +02:00
Felix Spöttel
08ae3d2b7f Rename internal validate_two_factor_secret => validate_two_factor_secret 2020-09-03 19:48:54 +02:00
Felix Spöttel
7c4eb0fb70 Add sqlite migration 2020-09-03 19:39:29 +02:00
Felix Spöttel
ee01eae55e Decouple totp from users table by moving to totp_credentials table
* this allows implementation of other mfa schemes in the future (webauthn)
* also makes key management easier and enforces one totp credentials per user on db-level
2020-09-03 19:07:21 +02:00
Felix Spöttel
89b301afc7 Update OpenApi docs, rename /2fa/ => /mfa/ 2020-09-03 13:54:28 +02:00
Felix Spöttel
ce70f44c58 Extract TOTPStrategy class to totp.py
* this decouples `TOTP` validation and storage logic from `auth` and moves it to `totp`
* reduce `pyotp.validate#valid_window` from `2` to `1`
2020-09-03 11:19:19 +02:00
Felix Spöttel
6594e19a1f Autofocus otp input when logging in, update layout 2020-09-02 20:30:08 +02:00
Felix Spöttel
8597646a12 Update API route naming, update setup page
* Rename /two-factor-auth/ => /2fa/
* Nest totp routes under /2fa/totp/
* Update ids and methods in panel to allow for different setup types
2020-09-02 19:41:06 +02:00
Felix Spöttel
f205c48564 Use pyotp for validating TOTP codes
* also implements resynchronisation support via `pyotp`'s `valid_window option
2020-09-02 19:12:15 +02:00
Felix Spöttel
3c3683429b implement two factor check during login 2020-09-02 17:23:32 +02:00
Felix Spöttel
a7a66929aa add user interface for managing 2fa
* update user schema with 2fa columns
2020-09-02 16:48:23 +02:00
David Ferreira de Sousa Duque
a336931c95
Version release: v0.48.POWER.0 2020-08-26 23:13:41 +01:00
David Ferreira de Sousa Duque
d7d3561768 v0.48
Roundcube XSS vulnerability fixed.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl9GpkcPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BoYAH/2NjdfN2d6f45uPq/X32bBAc6wfI7Cs9yCKp
 LOrAfPlmE0jRSm9ThATfZvaWci2r2IFhsFzQ9bWHpbIP5YD7mDD50I2uTnZa9BV4
 MsI40VXoh0BAgkWRqK60rTw0lQ9YGT+1TNLDEs1Y7vBjfTCOh4MMn4jUXkIEHDQg
 2pSHY1RUq7T0wRaHS+rTPDccotS/xCGg6uZJ+gSlvhRdxakAe9mo8139KD/4fjT8
 HK6igpwHsn3POg7mmJoSYXtScmWRYfnSV9kyfYyVyjhu5/uIowdICwFOzX7G7ruM
 yA/azBlyMs898e5jYFR1tQqQ1rVYVy/nqCQOiyJa34ngHGSi41U=
 =a9fn
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEAKK/toPAcMkE+dinLzJ3OKPArjoFAl9G3ZEACgkQLzJ3OKPA
 rjpEvQ//cG844Wv3samnABlRv2ZIjg6OXtjsE9OuN/O2exGhJ7wNhlJ5F3VyXP5Z
 Tufm2HNc3sg9lQUVmyvFbSx03f/tgRdykH2HDS55Q3Q9YdpmZJBGlqsoMN/GIfjU
 PDcLpN30XBt6S8qB/7d/U37ZW69OuFZLDRlwQZ66N/shxXkZSOp9U/9nH1Vf5OEU
 /L5RVtsi/baDnauXDJWpyNsLKKEB4jCrlEiVI437cN2Yr3Y/d1u4pf3zQOEQmAGV
 /A6fu2a9Kkc7IvcAKeoIvlyt+2gYw1zUDkBHf+LuSXGkxTt07L9Bjc7I6SY3DGo2
 QiEVOMDPiKWl+0UQ73w/lwCUS7FbzaG/Mj2+c1pJ85UaZYbU5ovjJLesk+UPIG8Q
 LmVCAHTw6QctZZi0BwP5epPk01zbeSBmRosT6b4l95G2sqh91CMNUjNRc7963yzB
 Z36hpaWSqpGKyEjma9XFvGi9Tfkg1JxblLjmPVqbyez7bpAMgSw5FBWU1zjxjOmi
 XOvGu/Fdu+gCre6IHfl8nTJNRc27UtJWTZjbQVl1OlbRx/h2QS/tKMpgN9VOrSV5
 koi4TuX+T4kSE2S+atzlPOQuDIOHfdlxaU2mlwtqVfHBjlwE7FEcdfIGa5Pl6Blj
 fFsDI7T6VGw2zup40vk/tRn5GY2KCPdp6rHhuCMA1PY6gerhhLw=
 =3X2X
 -----END PGP SIGNATURE-----

Merge upstream v0.48
2020-08-26 23:09:14 +01:00
Joshua Tauberer
0d72566c99 Merge v0.48 point release branch 2020-08-26 14:11:56 -04:00
Joshua Tauberer
62db58eaaf v0.48 2020-08-26 14:11:01 -04:00
Joshua Tauberer
891de8d6c3 Upgrade Roundcube to 1.4.8
Merges #1809
2020-08-26 14:10:04 -04:00
Richard Willis
62b9b1f15f
Add OpenAPI HTTP spec (#1804) 2020-08-22 15:44:19 -04:00
David Duque
03a1e57de6
Merge vagrant changes 2020-08-10 03:09:23 +01:00
David Duque
24c5d54f49
start.sh: Generate locales properly 2020-08-10 03:07:45 +01:00
David Duque
3d9f0e2135
Vagrant: Use libvirt/debian 2020-08-10 03:06:59 +01:00
David Duque
94da7bb088
status_checks.py: Properly terminate the process pools (#1795)
* Only spawn a thread pool when strictly needed

For --check-primary-hostname, the pool is not used.
When exiting, the other processes are left alive and will hang.

* Acquire pools with the 'with' statement
2020-08-09 11:42:39 -04:00
Joshua Tauberer
65983b8ac7 Merge v0.47 point release branch 2020-07-29 10:27:06 -04:00
Joshua Tauberer
4bbe4af377 Update CHANGELOG 2020-07-29 10:23:02 -04:00
Hilko
1098e2b48e
Add noindex to www_default meta tags (#1791) 2020-07-29 10:03:33 -04:00
Richard Willis
c50170b816
Update "Remove Alias" modal title (#1800) 2020-07-29 10:01:20 -04:00
Marcus Bointon
cd518e6820
Raise Dovecot per user connection limit (#1799) 2020-07-27 06:37:52 -04:00
David Duque
967409b157
Drop requirement for passwords to have no spaces (#1789) 2020-07-16 07:23:11 -04:00
David Duque
1b2711fc42
Add 'always' modifier to the HSTS add_header directive (#1790)
This will make it so that the HSTS header is sent regardless of the request status code (until this point it would only be sent if "the response code equals 200, 201, 206, 301, 302, 303, 307, or 308." - according to thttp://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header)
2020-07-16 07:21:14 -04:00
David Duque
e6102eacfb
AXFR Transfers (for secondary DNS servers): Allow IPv6 addresses (#1787) 2020-07-08 18:26:47 -04:00
John R. Supplee
e346ccfb02 v0.46
v0.46 (June 11, 2020)
 ---------------------
 
 Security fixes:
 
 * Roundcube is updated to version 1.4.6 (https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12).
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl7iW4MPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92B8awIAITrRfkfOFIfk7mbHY1giz/CJWP6+5EOV7J/
 a/1JOwRTccEy9Pjs1Tf/3VdamFnG6A5dzTNI/tPtqvVCT5cwy84mdCHgPFuZaLCs
 xBGdW8lf98a4sIP21GOXNi2mJlDqKog5ocGjgNJBw5mhCtY4A2IkjJ9S2X/bs1q0
 vAa1aBcNxaniTjLdTr80jVrzGzz9K6JtRowGDLz3YoJBxYCgXo/9CUTk3tOK+My2
 OnKVQCGX/23XoF5dYNtDmQBB3AwUfortBA30WNeipJ4VzgikcUQCyhrR3/wPr6P9
 wmVYmzv4Qw1bv/9ygijM4XLvgXx86LZPaFv+M82V7+Z3eIDW6QY=
 =8Sy4
 -----END PGP SIGNATURE-----

Merge tag 'v0.46' of https://github.com/mail-in-a-box/mailinabox

v0.46

v0.46 (June 11, 2020)
---------------------

Security fixes:

* Roundcube is updated to version 1.4.6
(https://roundcube.net/news/2020/06/02/security-updates-1.4.5-and-1.3.12).
2020-07-07 10:31:25 +02:00
John R. Supplee
332d2c3feb Add changes for new version to README 2020-05-17 18:19:11 +02:00
John R. Supplee
c152fe6312 v0.45 (May 16, 2020)
Security fixes:
 
 * Fix missing brute force login protection for Roundcube logins.
 
 Software updates:
 
 * Upgraded Roundcube from 1.4.2 to 1.4.4.
 * Upgraded Nextcloud from 17.0.2 to 17.0.6 (with Contacts from 3.1.6 to 3.3.0 and Calendar from 1.7.1 to v2.0.3)
 * Upgraded Z-Push to 2.5.2.
 
 System:
 
 * Nightly backups now occur on a random minute in the 3am hour (in the system time zone). The minute is chosen during Mail-in-a-Box installation/upgrade and remains the same until the next upgrade.
 * Fix for mail log statistics report on leap days.
 * Fix Mozilla autoconfig useGlobalPreferredServer setting.
 
 Web:
 
 * Add a new hidden feature to set nginx alias in www/custom.yaml.
 
 Setup:
 
 * Improved error handling.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl7AbCoPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BjbEIAIwmIpgNCT+age/SsUhDY8pjnFQWXBCl1nwa
 RFN40Ev73DoBXUP+za4RE0eyCLIw5/laCwCjaESobiBTuc6boC1QU4abFUV5NfJQ
 P3AnQ2qXkrtcmIQX42ge4AGsL3vMVRtjZWb+bvut2SmLB8BI5w/9XsQAS59lqSz0
 kK6ShlDmFaToMgTQqwl0CW8a0vdjRca5Mq011xUZrvqTAm7ACQIvS6np4UYBGSNy
 bU8O1xWMJb0HlO7f+bWCDYr1I+nRS1xXMW9pKsE08YFwcRLa+C42QkDXDuS/o/zj
 EXBLGwYcB0DEu4wLLbih8xdbED2ZiMO2t6IHtbXPcoLtHo3Tv6I=
 =RUkr
 -----END PGP SIGNATURE-----

Merge tag 'v0.45' of https://github.com/mail-in-a-box/mailinabox

v0.45 (May 16, 2020)

Security fixes:

* Fix missing brute force login protection for Roundcube logins.

Software updates:

* Upgraded Roundcube from 1.4.2 to 1.4.4.
* Upgraded Nextcloud from 17.0.2 to 17.0.6 (with Contacts from 3.1.6 to 3.3.0 and Calendar from 1.7.1 to v2.0.3)
* Upgraded Z-Push to 2.5.2.

System:

* Nightly backups now occur on a random minute in the 3am hour (in the system time zone). The minute is chosen during Mail-in-a-Box installation/upgrade and remains the same until the next upgrade.
* Fix for mail log statistics report on leap days.
* Fix Mozilla autoconfig useGlobalPreferredServer setting.

Web:

* Add a new hidden feature to set nginx alias in www/custom.yaml.

Setup:

* Improved error handling.
2020-05-17 18:17:44 +02:00
John R. Supplee
9b96b93260 Merge v0.44
# Conflicts:
#	setup/bootstrap.sh
2020-03-02 21:54:27 +02:00
John Supplee
8e94402282 Fix bug in displaying users when there is an archived user 2019-11-22 17:13:57 +02:00
John Supplee
0860a93e84 New release to remove extra features from the master branch 2019-10-11 12:43:32 +02:00
John Supplee
e04f358cc4 remove extra features from master branch 2019-10-11 12:40:50 +02:00
John Supplee
473d4616f2 update README for new release 2019-10-10 17:09:33 +02:00
John Supplee
3a23c8f7cf only show 'set quota' for non-archived mail boxes 2019-10-10 16:54:31 +02:00
John Supplee
e0626a4304 Merge branch 'master' of supplee.net:mailinabox-quota 2019-10-10 16:44:27 +02:00
John Supplee
a05a33051e Increment quota version to 0.19-beta 2019-10-04 17:46:10 +02:00
John Supplee
0bef62743c Merge branch 'master' of github.com:jrsupplee/mailinabox 2019-10-04 17:37:18 +02:00
John Supplee
dbf29cf71a Update HTML API docs for mail users 2019-10-04 17:32:50 +02:00
John R. Supplee
105a07e276 Update README 2019-09-02 18:40:52 -04:00
John R. Supplee
4686c8cd25 v0.43 (September 1, 2019)
-------------------------
 
 Security fixes:
 
 * A security issue was discovered in rsync backups. If you have enabled rsync backups, the file `id_rsa_miab` may have been copied to your backup destination. This file can be used to access your backup destination. If the file was copied to your backup destination, we recommend that you delete the file on your backup destination, delete `/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run Mail-in-a-Box setup, and re-configure your SSH public key at your backup destination according to the instructions in the Mail-in-a-Box control panel.
 * Brute force attack prevention was missing for the managesieve service.
 
 Setup:
 
 * Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier.
 
 Mail:
 
 * Upgraded Roundcube to 1.3.10.
 * Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails.
 
 Control panel:
 
 * When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option.
 * Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses.
 * Fix a bug in how a backup configuration error is shown.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl1rrwIPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BgckIALFnDFxhQ18MtClpi79+rnl1aA5DqbToCuI2
 MHIAOmxIVSavnd5MZZ3efXWAzIniEpbq0X+6Rlzas5lkreT1mHoJsKdkt0bOqy1a
 ZF2vT5UnUM9cwPHkU1ak/TaD9v97wbHpWWGwAK+/zTL6w1ReCVfQ2QzCzoDaY7xh
 OZFXE+YsaI7qZeG3Q4jfFr0IYDowLgjgBpdWvO71QKzWjIIvBNX1ZGt2r+cuKmQ5
 JOXIAR4fdri0p8dMd2sqq0FatBBCfjHDBykA/+GzJJDBX7MNoZsQT3bowrhj8XPS
 f5cKUKm7zlDsm02bfCtDD6nvYYUxvOdQx7yfdL8RYSdy71Chs20=
 =7M/i
 -----END PGP SIGNATURE-----

Merge tag 'v0.43' of https://github.com/mail-in-a-box/mailinabox

v0.43 (September 1, 2019)
-------------------------

Security fixes:

* A security issue was discovered in rsync backups. If you have enabled rsync backups, the file `id_rsa_miab` may have been copied to your backup destination. This file can be used to access your backup destination. If the file was copied to your backup destination, we recommend that you delete the file on your backup destination, delete `/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run Mail-in-a-Box setup, and re-configure your SSH public key at your backup destination according to the instructions in the Mail-in-a-Box control panel.
* Brute force attack prevention was missing for the managesieve service.

Setup:

* Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier.

Mail:

* Upgraded Roundcube to 1.3.10.
* Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails.

Control panel:

* When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option.
* Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses.
* Fix a bug in how a backup configuration error is shown.
2019-09-02 18:39:23 -04:00
John R. Supplee
fa3c3236d8 v0.43 (September 1, 2019)
-------------------------
 
 Security fixes:
 
 * A security issue was discovered in rsync backups. If you have enabled rsync backups, the file `id_rsa_miab` may have been copied to your backup destination. This file can be used to access your backup destination. If the file was copied to your backup destination, we recommend that you delete the file on your backup destination, delete `/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run Mail-in-a-Box setup, and re-configure your SSH public key at your backup destination according to the instructions in the Mail-in-a-Box control panel.
 * Brute force attack prevention was missing for the managesieve service.
 
 Setup:
 
 * Nextcloud was not upgraded properly after restoring Mail-in-a-Box from a backup from v0.40 or earlier.
 
 Mail:
 
 * Upgraded Roundcube to 1.3.10.
 * Fetch an updated whitelist for greylisting on a monthly basis to reduce the number of delayed incoming emails.
 
 Control panel:
 
 * When using secondary DNS, it is now possible to specify a subnet range with the `xfr:` option.
 * Fixed an issue when the secondary DNS option is used and the secondary DNS hostname resolves to multiple IP addresses.
 * Fix a bug in how a backup configuration error is shown.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl1rrwIPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BgckIALFnDFxhQ18MtClpi79+rnl1aA5DqbToCuI2
 MHIAOmxIVSavnd5MZZ3efXWAzIniEpbq0X+6Rlzas5lkreT1mHoJsKdkt0bOqy1a
 ZF2vT5UnUM9cwPHkU1ak/TaD9v97wbHpWWGwAK+/zTL6w1ReCVfQ2QzCzoDaY7xh
 OZFXE+YsaI7qZeG3Q4jfFr0IYDowLgjgBpdWvO71QKzWjIIvBNX1ZGt2r+cuKmQ5
 JOXIAR4fdri0p8dMd2sqq0FatBBCfjHDBykA/+GzJJDBX7MNoZsQT3bowrhj8XPS
 f5cKUKm7zlDsm02bfCtDD6nvYYUxvOdQx7yfdL8RYSdy71Chs20=
 =7M/i
 -----END PGP SIGNATURE-----

Merge tag 'v0.43' of https://github.com/mail-in-a-box/mailinabox

v0.43 (September 1, 2019)
-------------------------

Security fixes:

* A security issue was discovered in rsync backups. If you have enabled
rsync backups, the file `id_rsa_miab` may have been copied to your
backup destination. This file can be used to access your backup
destination. If the file was copied to your backup destination, we
recommend that you delete the file on your backup destination, delete
`/root/.ssh/id_rsa_miab` on your Mail-in-a-Box, then re-run
Mail-in-a-Box setup, and re-configure your SSH public key at your backup
destination according to the instructions in the Mail-in-a-Box control
panel.
* Brute force attack prevention was missing for the managesieve service.

Setup:

* Nextcloud was not upgraded properly after restoring Mail-in-a-Box from
a backup from v0.40 or earlier.

Mail:

* Upgraded Roundcube to 1.3.10.
* Fetch an updated whitelist for greylisting on a monthly basis to
reduce the number of delayed incoming emails.

Control panel:

* When using secondary DNS, it is now possible to specify a subnet range
with the `xfr:` option.
* Fixed an issue when the secondary DNS option is used and the secondary
DNS hostname resolves to multiple IP addresses.
* Fix a bug in how a backup configuration error is shown.

5F4C0E7313CCD744693B2AEAB92041F4C10BDD81
2019-09-02 18:33:26 -04:00
John R. Supplee
7636a7b40b Update README for v0.42b 2019-08-28 17:50:38 -04:00
John R. Supplee
32a0b0467f v0.42b (August 3, 2019)
-----------------------
 
 Changes:
 
 * Decreased the minimum supported RAM to 502 Mb.
 * Improved mail client autoconfiguration.
 * 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.
 
 Software updates:
 
 * Upgraded Roundcube from 1.3.8 to 1.3.9.
 * Upgraded Nextcloud from 14.0.6 to 15.0.8 (with Contacts from 2.1.8 to 3.1.1 and Calendar from 1.6.4 to 1.6.5).
 * Upgraded Z-Push from 2.4.4 to 2.5.0.
 
 Note that v0.42 (July 4, 2019) was pulled shortly after it was released to fix a Nextcloud upgrade issue.
 -----BEGIN PGP SIGNATURE-----
 
 iQFDBAABCgAtFiEEX0wOcxPM10RpOyrquSBB9MEL3YEFAl1FrScPHGp0QG9jY2Ft
 cy5pbmZvAAoJELkgQfTBC92BCvgH/AieqUcy/ujFML4YeIKY/ThQNFdI6VUO0b4b
 QBzR9ixc9ctp6qV+wVfnL22xO4V2rcO/eMAKMc6AzOw6E97iWHtK/4L2fmRYXclw
 arln7LokFTczSp1J29ldRjCYMn3dOS1IvBRJe8JWvFNdQY6gGaGrhqV/nCIECOWP
 sJsJ/sPv2d4ZapbQtfqBh2WiLADNf1CSmDpkCXwS9Va+XstVeprIk1Kbsy8z5L8K
 0cFZWxiWTB/hgHD/BqUNxrzB5mS4fCyr9c9g+OIjZFeoF/olYPp4SsEFHPQ604YG
 qRupfjdG1bWHUpTT5lvG1AVIIPJPnvFk/Ctk1iQiYSU5BzbvKz8=
 =4QdN
 -----END PGP SIGNATURE-----

Merge tag 'v0.42b' of https://github.com/mail-in-a-box/mailinabox

v0.42b (August 3, 2019)
-----------------------

Changes:

* Decreased the minimum supported RAM to 502 Mb.
* Improved mail client autoconfiguration.
* 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.

Software updates:

* Upgraded Roundcube from 1.3.8 to 1.3.9.
* Upgraded Nextcloud from 14.0.6 to 15.0.8 (with Contacts from 2.1.8 to
3.1.1 and Calendar from 1.6.4 to 1.6.5).
* Upgraded Z-Push from 2.4.4 to 2.5.0.

Note that v0.42 (July 4, 2019) was pulled shortly after it was released
to fix a Nextcloud upgrade issue.

5F4C0E7313CCD744693B2AEAB92041F4C10BDD81
2019-08-28 17:26:00 -04:00
John Supplee
e10bc30b4f Merge branch 'extended' into extended-fts 2019-04-30 13:59:21 +02:00
John Supplee
da3fd95b15 Merge branch 'extended' of supplee.net:mailinabox-quota into extended 2019-04-30 13:58:53 +02:00
John Supplee
9f59c056b8 go back to tomcat9.service.d 2019-04-20 13:18:54 +02:00
John Supplee
27f5085af7 fix path for tomcat9 solr conf 2019-04-20 12:45:33 +02:00
John Supplee
4e8e106ca4 Merge branch 'ubuntu_bionic_solr_fts' of https://github.com/jvolkenant/mailinabox into extended-fts 2019-04-20 12:42:42 +02:00
John Supplee
9e2d82b78f 18.04 moved to tomcat9 2019-04-20 11:59:24 +02:00
Jeff Volkenant
9386b4280c updated for tomcat9 and systemctl permission override 2019-04-18 09:29:42 -07:00
Jeff Volkenant
173aece6cd Merge branch 'master' of https://github.com/jvolkenant/mailinabox into ubuntu_bionic_solr_fts 2019-04-18 09:24:25 -07:00
John Supplee
f3572e51cf Merge branch 'extended' into extended-fts 2019-04-17 11:25:49 +02:00
John Supplee
102a6f6403 Merge branch 'master' of github.com:jrsupplee/mailinabox 2019-04-17 11:25:32 +02:00
John Supplee
c44eb9aaf5 Merge branch 'master' of github.com:jrsupplee/mailinabox into extended 2019-04-17 11:25:01 +02:00
John Supplee
3320432d59 Merge branch 'extended' into extended-fts 2019-04-17 11:23:44 +02:00
John Supplee
eb9cea03d6 Merge branch 'master' into extended 2019-04-17 11:23:30 +02:00
John Supplee
bac849bde8 Add check that IMAP quota service is running 2019-04-17 10:57:56 +02:00
John Supplee
0f7bbe0dde
Update README.md 2019-04-15 16:34:56 +02:00
John Supplee
a1536c7386 Merge branch 'extended' of supplee.net:mailinabox-quota into extended 2019-04-05 23:44:52 +02:00
John Supplee
57473ef39c fix problem with SSL certificate provisioning 2019-04-05 23:44:18 +02:00
John Supplee
39d55f2770 Merge branch 'extended' into extended-fts 2019-04-04 01:03:12 +02:00
John Supplee
67b2c0c0a4 Merge branch 'master' into extended 2019-04-04 01:02:35 +02:00
John Supplee
ac42628a94 Merge branch 'fts' into extended-fts 2019-04-04 00:01:44 +02:00
John Supplee
5d5a68f097 Merge branch 'ubuntu_bionic_solr_fts' of https://github.com/jvolkenant/mailinabox into fts 2019-04-04 00:00:40 +02:00
John Supplee
a6b9808e2e Merge branch 'master' into extended 2019-03-30 14:25:44 +02:00
John Supplee
e64595fc67 New version to tag with annotated tag 2019-03-30 14:23:38 +02:00
John Supplee
8fb4b092be Merge branch 'master' into extended 2019-03-30 14:04:00 +02:00
John Supplee
4c9548edaa change project status to beta 2019-03-30 13:58:54 +02:00
John Supplee
e8cb17c586 Fix problem postgrey configuration 2019-03-24 18:48:53 +02:00
John Supplee
0100801a24 bug fixes for options program 2019-03-09 16:05:17 +02:00
John Supplee
773276f4fe Fix variable name for POSTGREY 2019-03-09 14:53:26 +02:00
John Supplee
761bb054ce Merge branch 'extended' of supplee.net:mailinabox-quota into extended 2019-03-09 14:51:36 +02:00
John Supplee
6b207b6bb4 add python requirements 2019-03-09 14:45:31 +02:00
John Supplee
48f167876c Options dialog updates 2019-03-09 12:29:37 +02:00
John Supplee
99776c1513 Disable Postgrey service if it is not being used 2019-03-09 12:11:41 +02:00
John Supplee
bb96ee8269 Merge branch 'miab-config' into extended 2019-03-09 11:37:25 +02:00
John Supplee
d2d8586f21 Merge branch 'mailgraph' into extended 2019-03-09 11:11:13 +02:00
John Supplee
7a0c5ea910 Merge branch 'miab-config' into extended 2019-03-09 11:05:41 +02:00
John Supplee
075bdc7063 Merge branch 'miab-config' of supplee.net:mailinabox-quota into miab-config 2019-03-09 10:59:47 +02:00
John Supplee
2c50c1876e Merge branch 'master' into extended 2019-03-09 10:51:47 +02:00
John Supplee
a7f62f281b Initial work on dialog screen for options 2019-03-09 10:05:46 +02:00
John Supplee
36101208fe Merge branch 'spf-srs' of supplee.net:mailinabox-quota into miab-config 2019-03-08 22:06:20 +02:00
John Supplee
5167b3a623 Change variable name for Postgrey setup and add others 2019-03-08 17:18:24 +02:00
John Supplee
77143e6b24 Merge branch 'miab' into miab-config 2019-03-08 17:05:29 +02:00
John Supplee
ed68f6d64a fix variable reference in callback 2019-03-03 21:21:34 +02:00
John Supplee
7600e727c1 debugging of image display 2019-03-03 21:15:16 +02:00
John Supplee
e29e3a5cba fix QUERY_STRING to only have the image request 2019-03-03 19:23:58 +02:00
John Supplee
6883a60f5d load images as base64 2019-03-03 19:15:59 +02:00
John Supplee
dce4058705 process images returned from mailgraph 2019-03-03 00:34:41 +02:00
John Supplee
5b5087c9dd fix query params for mailgraph images 2019-03-02 23:33:45 +02:00
John Supplee
8967758413 Only load mailgraph images when the page is viewed 2019-03-02 23:18:44 +02:00
John Supplee
5ffa71999a work on the daemon for mailgraph 2019-03-02 20:47:54 +02:00
John Supplee
d41cdb844c remove nginx config for Mailgraph 2019-03-02 20:31:24 +02:00
John Supplee
fd239db7c1 integrate template 2019-03-02 20:22:08 +02:00
John Supplee
fefb5ebc33 more work on control panel integration 2019-03-02 20:16:39 +02:00
John Supplee
5615031ef8 initial work to integrate with control panel 2019-03-02 17:23:58 +02:00
John Supplee
8180707cc6 add missing semicolon to nginx conf 2019-03-02 15:43:30 +02:00
John Supplee
69a50ed954 fix error in nginx configuration 2019-03-02 15:39:19 +02:00
John Supplee
887e292748 Add changes for mailgraph webserver 2019-03-02 15:30:08 +02:00
John Supplee
68f4d1c426 add SRS support 2019-02-27 17:30:59 +02:00
John Supplee
176e8272a0 Merge branch 'spf' into spf-srs 2019-02-27 17:08:18 +02:00
John Supplee
adf7a31866 Update README and bump the version 2019-02-27 15:02:11 +02:00
John Supplee
4cbf05187c Merge branch 'master' of https://github.com/mail-in-a-box/mailinabox into devel 2019-02-27 12:52:41 +02:00
John Supplee
aa234a504e changes for SPF on incoming email 2019-02-27 12:48:34 +02:00
John Supplee
2b70277a35 Update README instructions for installing and upgrading 2019-02-15 16:08:06 +02:00
John Supplee
7f8336e459 Fix bug with quota input that prevented adding users 2019-02-11 16:10:09 +02:00
John Supplee
514619b44a setup dns authentication for letsencrypt 2019-02-11 16:05:50 +02:00
John Supplee
fc1f211af5 initial work on extended configuration 2019-02-10 23:39:38 +02:00
John Supplee
ae6394c879 Fix instructions for quotas on the users page in control panel 2019-02-06 14:47:30 +02:00
John Supplee
53d1c1e4e9 Release new version v0.40-quota-0.15-alpha 2019-02-06 12:58:05 +02:00
John Supplee
4bed222162 Fix bug where quotas are not being recalculated 2019-02-06 12:53:46 +02:00
John Supplee
44a31733ac update latest version and update README 2019-02-05 13:36:53 +02:00
John Supplee
e44dd93778 mail setup fixes
* check for quota column before trying to add it
* force recalculation of quotas
2019-02-05 13:34:37 +02:00
John Supplee
19f204a9af Do not execute a dovecot reload on every quota update 2019-02-05 13:32:43 +02:00
John Supplee
d1378a5512 Add a thousands separator for message count 2019-02-05 01:48:38 +02:00
John Supplee
a60e1c274a Do not allow decimal points in quota sizes 2019-02-05 01:47:39 +02:00
John Supplee
686a074fab Force recalculation of a user's quota after setting a new value 2019-02-05 01:47:09 +02:00
John Supplee
937ef47c2a Remove default quota support from the TODOs 2019-02-01 21:51:49 +02:00
John Supplee
6964ed238a Show correct reference for updating versions 2019-02-01 19:01:59 +02:00
John Supplee
d51a32bcfe update latest version to v0.40-quota-0.13-alpha 2019-02-01 18:39:47 +02:00
John Supplee
2c6aea24cb Update README to reflect the latest changes 2019-02-01 18:38:00 +02:00
John Supplee
7874683618 Add percentage used and update tools/mail.py to set quotas 2019-02-01 15:36:27 +02:00
John Supplee
fa66585b6b fix typo 2019-02-01 01:35:55 +02:00
John Supplee
51f60c15bb Make the quota field narrower 2019-02-01 01:32:26 +02:00
John Supplee
9139b917cf Finally put the default quota load code in the right place 2019-02-01 01:21:44 +02:00
John Supplee
f64fffe6b8 move jQuery load back to original place and put quota load code in index.html 2019-02-01 01:05:28 +02:00
John Supplee
1a67c94db0 fix problem with jQuery being loaded too late 2019-02-01 00:56:25 +02:00
John Supplee
70c607e256 more default quota work 2019-01-31 23:58:10 +02:00
John Supplee
d1906bd055 Add support for a default quota value and allow setting quota when adding user 2019-01-31 22:57:04 +02:00
John Supplee
6de34b0464 Update README to have section for installation and upgrading 2019-01-31 12:28:32 +02:00
John Supplee
9a9e0116a1 Update to version v0.40-quota-0.12-alpha 2019-01-31 10:23:42 +02:00
John Supplee
f58745c04d Update version to v0.40-quota-0.11-alpha 2019-01-31 09:49:07 +02:00
Jeff Volkenant
1dee849498 Removed extra blankline 2019-01-30 11:33:43 -08:00
John Supplee
863e8895a0 Add the quota column to the users table 2019-01-30 17:14:48 +02:00
John Supplee
e40566967e Change up to date message 2019-01-30 15:35:17 +02:00
John Supplee
ede5e09ad0 make version checks from this repository 2019-01-30 15:31:15 +02:00
John Supplee
98e04bb11f Update README for first alpha release 2019-01-30 12:52:02 +02:00
John Supplee
ebb5896301 Merge remote-tracking branch 'origin/postfix-quota' 2019-01-30 12:38:01 +02:00
John Supplee
2bebd727c2 Fix README 2019-01-30 10:47:07 +02:00
John Supplee
74665d09e8 Merge branch 'master' into postfix-quota 2019-01-30 09:44:49 +02:00
John Supplee
fbf990092c Add progress to README 2019-01-30 09:44:23 +02:00
John Supplee
a23f186c65 initial test config 2019-01-30 08:48:04 +02:00
John Supplee
a8f02c1eb0 Fix problems with users that do not have maildirsize file 2019-01-30 00:01:16 +02:00
Jeff Volkenant
c302606de4 Extended timeout for php/roundcube for text searches that take a long time 2019-01-29 13:46:35 -08:00
John Supplee
8bd9cf38ab Use tabs for indentation 2019-01-29 23:31:56 +02:00
John Supplee
b3f9063ae7 comment out code generating errors
When the `maildirsize` file does not exist it causes the script to fail.
The IOError is not caught by the execpt
2019-01-29 19:41:54 +02:00
John Supplee
f68ef70b94 Calculate and display mailbox sizes in user list 2019-01-29 19:18:48 +02:00
John Supplee
c5c7de8b2b allow decimal points in quotas 2019-01-29 17:42:17 +02:00
John Supplee
3840443159 enable setting quotas from the command line 2019-01-29 13:51:18 +02:00
John Supplee
832d2ca375 Update README to note support for control panel 2019-01-28 23:38:04 +02:00
John Supplee
ccad47937e Add components to user interface for setting quotas 2019-01-28 23:27:03 +02:00
John Supplee
8b95fac8c5 remove unnecessary configuration files 2019-01-28 19:25:26 +02:00
John Supplee
e2d71d4636
Fix typo 2019-01-28 17:30:21 +02:00
John Supplee
b557e69313 display unlimited when quota is 0 2019-01-28 17:24:11 +02:00
John Supplee
dad22f7261 bug fix 2019-01-28 17:17:43 +02:00
John Supplee
587f33b6c0 bug fix for displaying quotas 2019-01-28 16:34:34 +02:00
John Supplee
3cd14bd4b8 Add display of quotas 2019-01-28 16:19:36 +02:00
John Supplee
d443135155 changes to users.sqlite
* add quota column
* modify users_query to return quota_rule
2019-01-28 11:23:49 +02:00
John Supplee
0b68bf8760 put roundcube config change in the right place 2019-01-28 09:38:32 +02:00
John Supplee
907c05299c Add quota support 2019-01-28 09:24:09 +02:00
Jeff Volkenant
2fce29d775 Added Solr (Tomcat) to status_checks.py 2019-01-16 20:46:52 -08:00
Jeff Volkenant
2303ac3394 Force kickoff of Solr indexing at install time 2019-01-16 11:32:16 -08:00
Jeff Volkenant
4a23a522e1 added solr.sh to start.sh 2019-01-16 10:29:21 -08:00
Jeff Volkenant
a3d7e0dfae Adapted MIAB Solr install script from https://github.com/jkaberg/ for Ubuntu Bionic 2019-01-16 10:21:19 -08:00
90 changed files with 14132 additions and 5077 deletions

View file

@ -4,30 +4,23 @@
root = true
[*]
[Vagrantfile]
indent_style = space
indent_size = 4
indent_size = 2
[*.rb]
indent_style = space
indent_size = 2
[**.html]
indent_style = tab
[**.py]
indent_style = tab
[**]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.html]
indent_style = tab
[Makefile]
indent_style = tab
indent_size = 4
[Vagrantfile]
indent_size = 2
[*.rb]
indent_size = 2
[*.py]
indent_style = tab
[*.js]
indent_size = 2

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

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
ko_fi: davness

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ tools/__pycache__/
externals/
.env
.vagrant
api/docs/api-docs.html
mailinabox-ca.crt

3
.style.yapf Normal file
View file

@ -0,0 +1,3 @@
[style]
based_on_style = pep8
use_tabs = True

View file

@ -1,6 +1,252 @@
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)
-----------------------------
This is the first release for Ubuntu 22.04.
**Before upgrading**, you must **first upgrade your existing Ubuntu 18.04 box to Mail-in-a-Box v0.51 or later**, if you haven't already done so. That may not be possible after Ubuntu 18.04 reaches its end of life in April 2023, so please complete the upgrade well before then. (If you are not using Nextcloud's contacts or calendar, you can migrate to the latest version of Mail-in-a-Box from any previous version.)
For complete upgrade instructions, see:
https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558
No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made.
With the newer version of Ubuntu the following software packages we use are updated:
* dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug).
* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0).
* Roundcube is upgraded to 1.6.0.
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
* fail2ban is upgraded to 0.11.2.
* nginx is upgraded to 1.18.
* PHP is upgraded from 7.2 to 8.0.
Also:
* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout.
* Moved Postgrey's database under $STORAGE_ROOT.
Version 57a (June 19, 2022)
---------------------------
* The Backblaze backups fix posted in Version 57 was incomplete. It's now fixed.
Version 57 (June 12, 2022)
--------------------------
Setup:
* Fixed issue upgrading from Mail-in-a-Box v0.40-v0.50 because of a changed URL that Nextcloud is downloaded from.
Backups:
* Fixed S3 backups which broke with duplicity 0.8.23.
* Fixed Backblaze backups which broke with latest b2sdk package by rolling back its version.
Control panel:
* Fixed spurious changes in system status checks messages by sorting DNSSEC DS records.
* Fixed fail2ban lockout over IPv6 from excessive loads of the system status checks.
* Fixed an incorrect IPv6 system status check message.
Version 56 (January 19, 2022)
-----------------------------
Software updates:
* Roundcube updated to 1.5.2 (from 1.5.0), and the persistent_login and CardDAV (to 4.3.0 from 3.0.3) plugins are updated.
* Nextcloud updated to 20.0.14 (from 20.0.8), contacts to 4.0.7 (from 3.5.1), and calendar to 3.0.4 (from 2.2.0).
Setup:
* Fixed failed setup if a previous attempt failed while updating Nextcloud.
Control panel:
* Fixed a crash if a custom DNS entry is not under a zone managed by the box.
* Fix DNSSEC instructions typo.
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.
Version 55 (October 18, 2021)
-----------------------------
Mail:
* "SMTPUTF8" is now disabled in Postfix. Because Dovecot still does not support SMTPUTF8, incoming mail to internationalized addresses was bouncing. This fixes incoming mail to internationalized domains (which was probably working prior to v0.40), but it will prevent sending outbound mail to addresses with internationalized local-parts.
* Upgraded to Roundcube 1.5.
Control panel:
* The control panel menus are now hidden before login, but now non-admins can log in to access the mail and contacts/calendar instruction pages.
* The login form now disables browser autocomplete in the two-factor authentication code field.
* After logging in, the default page is now a fast-loading welcome page rather than the slow-loading system status checks page.
* The backup retention period option now displays for B2 backup targets.
* The DNSSEC DS record recommendations are cleaned up and now recommend changing records that use SHA1.
* The Munin monitoring pages no longer require a separate HTTP basic authentication login and can be used if two-factor authentication is turned on.
* Control panel logins are now tied to a session backend that allows true logouts (rather than an encrypted cookie).
* Failed logins no longer directly reveal whether the email address corresponds to a user account.
* Browser dark mode now inverts the color scheme.
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.
* Additional nsd.conf files can be placed in /etc/nsd.conf.d.
v0.54 (June 20, 2021)
---------------------
Mail:
* Forwarded mail using mail filter rules (in Roundcube; "sieve" rules) stopped re-writing the envelope address at some point, causing forwarded mail to often be marked as spam by the final recipient. These forwards will now re-write the envelope as the Mail-in-a-Box user receiving the mail to comply with SPF/DMARC rules.
* Sending mail is now possible on port 465 with the "SSL" or "TLS" option in mail clients, and this is now the recommended setting. Port 587 with STARTTLS remains available but should be avoided when configuring new mail clients.
* Roundcube's login cookie is updated to use a new encryption algorithm (AES-256-CBC instead of DES-EDE-CBC).
DNS:
* The ECDSAP256SHA256 DNSSEC algorithm is now available. If a DS record is set for any of your domain names that have DNS hosted on your box, you will be prompted by status checks to update the DS record at your convenience.
* Null MX records are added for domains that do not serve mail.
Contacts/calendar:
* Updated Nextcloud to 20.0.8, contacts to 3.5.1, calendar to 2.2.0 (#1960).
Control panel:
* Fixed a crash in the status checks.
* Small wording improvements.
Setup:
* Minor improvements to the setup scripts.
v0.53a (May 8, 2021)
--------------------
The download URL for Z-Push has been revised becaue the old URL stopped working.
v0.53 (April 12, 2021)
----------------------
Software updates:
* Upgraded Roundcube to version 1.4.11 addressing a security issue, and its desktop notifications plugin.
* Upgraded Z-Push (for Exchange/ActiveSync) to version 2.6.2.
Control panel:
* Backblaze B2 is now a supported backup protocol.
* Fixed an issue in the daily mail reports.
* Sort the Custom DNS by zone and qname, and add an option to go back to the old sort order (creation order).
Mail:
* Enable sending DMARC failure reports to senders that request them.
Setup:
* Fixed error when upgrading from Nextcloud 13.
v0.52 (January 31, 2021)
------------------------
Software updates:
* Upgraded Roundcube to version 1.4.10.
* Upgraded Z-Push to 2.6.1.
Mail:
* Incoming emails with SPF/DKIM/DMARC failures now get a higher spam score, and these messages are more likely to appear in the junk folder, since they are often spam/phishing.
* Fixed the MTA-STS policy file's line endings.
Control panel:
* A new Download button in the control panel's External DNS page can be used to download the required DNS records in zonefile format.
* Fixed the problem when the control panel would report DNS entries as Not Set by increasing a bind query limit.
* Fixed a control panel startup bug on some systems.
* Improved an error message on a DNS lookup timeout.
* A typo was fixed.
DNS:
* The TTL for NS records has been increased to 1 day to comply with some registrar requirements.
System:
* Nextcloud's photos, dashboard, and activity apps are disabled since we only support contacts and calendar.
v0.51 (November 14, 2020)
-------------------------
Software updates:
* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
* Upgraded Roundcube to version 1.4.9.
Mail:
* The MTA-STA max_age value was increased to the normal one week.
Control panel:
* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
* TLS certificate expiry dates are now shown in ISO8601 format for clarity.
v0.50 (September 25, 2020)
--------------------------
Setup:
* When upgrading from versions before v0.40, setup will now warn that ownCloud/Nextcloud data cannot be migrated rather than failing the installation.
Mail:
* An MTA-STS policy for incoming mail is now published (in DNS and over HTTPS) when the primary hostname and email address domain both have a signed TLS certificate installed, allowing senders to know that an encrypted connection should be enforced.
* The per-IP connection limit to the IMAP server has been doubled to allow more devices to connect at once, especially with multiple users behind a NAT.
DNS:
* autoconfig and autodiscover subdomains and CalDAV/CardDAV SRV records are no longer generated for domains that don't have user accounts since they are unnecessary.
* IPv6 addresses can now be specified for secondary DNS nameservers in the control panel.
TLS:
* TLS certificates are now provisioned in groups by parent domain to limit easy domain enumeration and make provisioning more resilient to errors for particular domains.
Control panel:
* The control panel API is now fully documented at https://mailinabox.email/api-docs.html.
* User passwords can now have spaces.
* Status checks for automatic subdomains have been moved into the section for the parent domain.
* Typo fixed.
Web:
* The default web page served on fresh installations now adds the `noindex` meta tag.
* The HSTS header is revised to also be sent on non-success responses.
v0.48 (August 26, 2020)
-----------------------
Security fixes:
* Roundcube is updated to version 1.4.8 fixing additional cross-site scripting (XSS) vulnerabilities.
v0.47 (July 29, 2020)
---------------------

View file

@ -20,9 +20,9 @@ _If you're seeing an error message about your *IP address being listed in the Sp
### Modifying your `hosts` file
After a while, Mail-in-a-Box will be available at `192.168.50.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file:
After a while, Mail-in-a-Box will be available at `192.168.56.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file:
$ echo "192.168.50.4 mailinabox.lan" | sudo tee -a /etc/hosts
$ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts
You should now be able to navigate to https://mailinabox.lan/admin using your browser. There should be an initial admin user with the name `me@mailinabox.lan` and the password `12345678`.

267
README.md
View file

@ -1,192 +1,101 @@
(Power) Mail-in-a-Box
=====================
# Power Mail-in-a-Box
## **[Installation](#installation)** (current version: v60.5)
## **[Upgrading Quick Start](#upgrading)**
## Installation
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/davness)
- **PRE-REQUISITES:** Debian 10 (Buster) or Ubuntu 20.04 LTS fresh installation
Power Mail-in-a-Box (a fork of [Mail-in-a-Box](https://mailinabox.email/)) is a complete pre-configured mail appliance, quickly deployable in a matter of minutes.
Update packages:
```sh
sudo apt update
sudo apt full-upgrade
It's main difference to the main project is focused on ad-hoc, advanced features. While Mail-in-a-Box caters to beginners by providing sane configurations, Power Mail-in-a-Box also attempts to cater to advanced users that want deeper levels of customization.
## Features
- Configure Power Mail-in-a-Box to use an external SMTP relay;
- Brand new admin panel (with up-to-date libraries);
- Perform backups right away from the admin panel;
- Account quotas support (thanks to **@[jrsupplee](https://github.com/jrsupplee/mailinabox)**!)
- Customize TTL's for custom DNS records;
- Publish OpenPGP keys authoritatively via a WKD server;
- - **In the future:** Allow usage of OpenPGP keys to encrypt backups;
- Per-domain nginx configuration;
## Goals
- **Easy of use** - deployment shouldn't take too many technical details to understand. Power Mail-in-a-Box already comes with default configurations which should be good for most users.
- **Privacy, security and independence** - keeping your mail and data safe from advertisers and prying eyes.
- **Accessible customizability** - bring the features closer to the people instead of tucking them away in configuration files.
- **Customizability potential** - allow for deep customization by power users.
- **Concentration** - all the services you need in just one box.
- **Support** - support a wide range of operating systems when possible, without compromising the codebase as a whole.
- **Lightweight** - should be able to run even with very limited resources.
## Non-goals
- **Scalability** - this appliance is geared towards individuals and small/mid-sized organizations. If your use case is mission-critical it probably is a better idea to shop for a product that provides support.
- **Portability** - I didn't figure out yet a way to easily transition from Mail-in-a-Box to Power Mail-in-a-Box.
# Minimum Pre-requisites
The machine this appliance will be installed on needs to have the following specs (or better). Most cloud providers are able to provide VM's that satisfy these specs at relatively low cost.
<small>_These specs depend on the number of users being served and/or amount of traffic_</small>
- 1 CPU core;
- 512MB of RAM (**at least 1GB** is recommended);
- 10GB of disk;
- **One of the following operating systems:**
- - Debian GNU/Linux 11 (Bullseye)
- - Ubuntu LTS 20.04 (Focal Fossa)
- - Ubuntu LTS 22.04 (Jammy Jellyfish)
## Legacy Support
The following distributions are no longer supported for the latest version, but they used to be supported at a earlier time:
- **Debian 10 (Buster)** <= **v56.5**
<small>_These network requirements are usually not provided by residential ISP's. They are not **strictly required** for Power Mail-in-a-Box to install, but it will take more work to get it running as intended._</small>
- Static, public IPv4 (most residential connections **do not** provide static addresses);
- - If the machine is behind a NAT, manual configuration might be required.
- Reverse DNS for that IPv4 address (**Caution:** some cloud providers do not provide this);
- You should be able to edit the firewall for that address. **In particular, outbound port 25 should not be blocked.**
# Firewall
If the machine is behind an external firewall or NAT, the following **inbound ports SHOULD** be open to external traffic:
- `25/tcp`
- `53/tcp`
- `53/udp`
- `80/tcp`
- `443/tcp`
- `465/tcp`
- `587/tcp`
- `993/tcp`
- `995/tcp`
- `4190/tcp`
# Installation
1. Power Mail-in-a-Box uses `ufw` to configure it's internal firewall. If your cloud provider requires you to use another tool (usually it does not, but <small>\*cough\* _Oracle Cloud_ \*cough\*</small>), you can follow [these instructions](https://github.com/ddavness/power-mailinabox/discussions/21).
2. Make sure `curl` is installed and locales are configured correctly - you'll want to make sure the primary locale is set to `en_US.UTF-8`:
```
Make sure that the `en_US.UTF-8` locale exists and is set as primary (this depends on the image you use)
```sh
sudo apt install locales
sudo apt install curl locales
sudo dpkg-reconfigure locales
```
Install Power-Mail-in-a-Box (short link)
```sh
curl -L https://dvn.pt/powermiab | sudo bash
3. Run the following command, and then follow the instructions that appear on the screen:
```
curl -L https://power-mailinabox.net/setup.sh | sudo bash
```
If that doesn't work:
```sh
curl https://raw.githubusercontent.com/ddavness/power-mailinabox/master/setup/bootstrap.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
```
## Current Version: v0.47.POWER.0 (Tracking v0.47)
## 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.
This is a fork of MiaB (duh), hacked and tuned to my needs:
```
curl -L https://power-mailinabox.net/<VERSION>/setup.sh | sudo bash
```
Where `<VERSION>` is the version you want to install. (**Example:** `v60.0`).
✅ - **Done**
👨‍💻 - **Not there yet, but soon!**
💤 - **I did not begin this part yet!**
- ✅ Proper support for Debian (I recommend Debian Buster or later, but if it works on your machine, it works!) AND Ubuntu 20.04 LTS;
- ✅ Native support for SMTP relays (For example: SendGrid);
- ✅ Bumped the bootstrap and jQuery dependencies' versions - and we've got a brand new admin panel now!
- ✅ Per-domain `nginx` configuration support. This will allow you to:
- - Use PHP (e.g. host a domain shortener);
- - Custom pages will no longer have their pages defaulting to the MiaB services (`/admin`, `/mail`, etc.);
- ✅ Updated NextCloud to the latest version available;
- ✅ Performing backups immediately from the admin panel (independently from the daily schedule);
- 💤 Encrypting backups using user-provided PGP keys;
- 💤 Ability to download the backups from the admin panel;
### Ideas section:
- 💤 Possibility of making some services optional (if they require more software to be installed) on setup?
- - For example, one might simply not use NextCloud/Munin at all, and they're there... just wasting resources.
- 💤 Restricting access to the admin panel to certain IP's?
- 💤 Customizing MTA names? (because privacy)
- 💤 AXFR Transfers (for secondary DNS) using TSIG?
- 💤 Expand DNS record options?
- 💤 More complete webmail configuration via the admin panel/plugin management?
- 💤 Optional TOTP Two-Factor-Authentication for the admin panel/webmail?
- - Maybe U2F one day, too, but I don't have a capable device for this just yet...
- 💤 Anything else I might need to use;
All in all, I think I should rename this to something like "Central [Clown Computing](https://www.urbandictionary.com/define.php?term=clown%20computing)", since I'm trying to cram as many services as possible into that poor machine (Spending 5$ is better than spending 10$)
Original Documentation
======================
By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).
Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box.
**Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**
* * *
Our goals are to:
* Make deploying a good mail server easy.
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
* Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
* **Not** make a totally unhackable, NSA-proof server.
* ~~**Not** make something customizable by power users.~~
Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.
The Box
-------
Mail-in-a-Box turns a fresh ~~Ubuntu 18.04 LTS~~ Debian 10 (Buster) 64-bit machine into a working mail server by installing and configuring various components.
It is a one-click email appliance. There are no user-configurable setup options. It "just works".
The components installed are:
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers
* Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (also using dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and greylisting ([postgrey](http://postgrey.schweikert.ch/))
* DNS ([nsd4](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set
* HTTPS TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) (needed for webmail, CardDAV/CalDAV, ActiveSync, MTA-STS policy, etc.).
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/))
It also includes system management tools:
* Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct
* A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.
* An API for all of the actions on the control panel
It also supports static website hosting since the box is serving HTTPS anyway.
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
Installation
------------
See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.
For experts, start with a completely fresh (really, I mean it) Ubuntu 18.04 LTS 64-bit machine. On the machine...
Clone this repository:
$ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox
_Optional:_ Download Josh's PGP key and then verify that the sources were signed
by him:
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
$ git verify-tag v0.47
gpg: Signature made ..... using RSA key ID C10BDD81
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 5F4C 0E73 13CC D744 693B 2AEA B920 41F4 C10B DD81
You'll get a lot of warnings, but that's OK. Check that the primary key fingerprint matches the
fingerprint in the key details at [https://keybase.io/joshdata](https://keybase.io/joshdata)
and on his [personal homepage](https://razor.occams.info/). (Of course, if this repository has been compromised you can't trust these instructions.)
Checkout the tag corresponding to the most recent release:
$ git checkout v0.47
Begin the installation.
$ sudo setup/start.sh
For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).
Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you.
Contributing and Development
----------------------------
Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started.
The Acknowledgements
--------------------
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/sovereign/sovereign) by Alex Payne, and conversations with <a href="https://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
The History
-----------
* In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf).
* In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts.
* Mail-in-a-Box was a semifinalist in the 2014 [Knight News Challenge](https://www.newschallenge.org/challenge/2014/submissions/mail-in-a-box), but it was not selected as a winner.
* Mail-in-a-Box hit the front page of Hacker News in [April](https://news.ycombinator.com/item?id=7634514) 2014, [September](https://news.ycombinator.com/item?id=8276171) 2014, [May](https://news.ycombinator.com/item?id=9624267) 2015, and [November](https://news.ycombinator.com/item?id=13050500) 2016.
* FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.
> ⚠️ **Downgrading might not always be possible and is not supported!** Make sure you know what you're doing before doing so.

82
Vagrantfile vendored
View file

@ -1,31 +1,75 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
ip = 2
machines = [
{
'iso' => "debian/buster64",
'host' => "buster"
},
{
'iso' => "generic/ubuntu2004",
'host' => "focal"
},
{
'iso' => "debian/bullseye64",
'host' => "bullseye"
},
{
'iso' => "generic/ubuntu2204",
'host' => "jammy"
},
]
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/focal64"
config.vm.provider :virtualbox do |vb|
vb.customize ["modifyvm", :id, "--cpus", 1, "--memory", 1024]
vb.customize ["modifyvm", :id, "--cpus", 1, "--memory", 768]
end
config.vm.provider :libvirt do |v|
v.memory = 768
v.cpus = 1
v.nested = true
end
config.vm.provider :kvm do |kvm|
kvm.memory_size = '768m'
end
# Network config: Since it's a mail server, the machine must be connected
# to the public web. However, we currently don't want to expose SSH since
# the machine's box will let anyone log into it. So instead we'll put the
# machine on a private network.
config.vm.hostname = "mailinabox.lan"
config.vm.network "private_network", ip: "192.168.50.4"
config.vm.synced_folder ".", "/vagrant", nfs_version: "4"
config.vm.provision :shell, :inline => <<-SH
# Set environment variables so that the setup script does
# not ask any questions during provisioning. We'll let the
# machine figure out its own public IP.
export NONINTERACTIVE=1
export PUBLIC_IP=auto
export PUBLIC_IPV6=auto
export PRIMARY_HOSTNAME=auto
export SKIP_NETWORK_CHECKS=1
# Start the setup script.
cd /vagrant
setup/start.sh
SH
end
(0..(machines.size - 1)).each do |n|
node = machines[n]
config.vm.define node['host'] do |m|
m.vm.box = node['iso']
m.vm.hostname = "#{node['host']}.mailinabox.lan"
m.vm.network "private_network", ip: "192.168.168.#{ip+n}"
m.vm.provision "shell", :inline => <<-SH
git config --global --add safe.directory /vagrant
# Set environment variables so that the setup script does
# not ask any questions during provisioning. We'll let the
# machine figure out its own public IP.
export NONINTERACTIVE=1
export PUBLIC_IP=192.168.168.#{ip+n}
export PUBLIC_IPV6=auto
export PRIVATE_IP=192.168.168.#{ip+n}
export PRIMARY_HOSTNAME=\"#{node['host']}.mailinabox.lan\"
export SKIP_NETWORK_CHECKS=1
# Start the setup script.
cd /vagrant
setup/start.sh
# After setup is done, fully open the ssh ports again
ufw allow ssh
SH
m.vm.provision "shell", run: "always", :inline => <<-SH
service mailinabox restart
SH
end
end
end

23
api/docs/generate-docs.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env sh
# Requirements:
# - Node.js
# - redoc-cli (`npm install redoc-cli -g`)
redoc-cli bundle ../mailinabox.yml \
-t template.hbs \
-o api-docs.html \
--templateOptions.metaDescription="Mail-in-a-Box HTTP API" \
--title="Mail-in-a-Box HTTP API" \
--options.expandSingleSchemaField \
--options.hideSingleRequestSampleTab \
--options.jsonSampleExpandLevel=10 \
--options.hideDownloadButton \
--options.theme.logo.maxHeight=180px \
--options.theme.logo.maxWidth=180px \
--options.theme.colors.primary.main="#C52" \
--options.theme.typography.fontSize=16px \
--options.theme.typography.fontFamily="Raleway, sans-serif" \
--options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \
--options.theme.typography.code.fontSize=15px \
--options.theme.typography.code.fontFamily='"Source Code Pro", monospace'

31
api/docs/template.hbs Normal file
View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<title>{{title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{templateOptions.metaDescription}}" />
<link rel="icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
<link rel="apple-touch-icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
<link href="https://fonts.googleapis.com/css?family=Raleway:400,700" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:500" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
}
h1 {
color: #000 !important;
}
</style>
{{{redocHead}}}
</head>
<body>
{{{redocHTML}}}
</body>
</html>

3424
api/mailinabox.yml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,94 @@
##
## IMAP specific settings
##
# If nothing happens for this long while client is IDLEing, move the connection
# to imap-hibernate process and close the old imap process. This saves memory,
# because connections use very little memory in imap-hibernate process. The
# downside is that recreating the imap process back uses some resources.
#imap_hibernate_timeout = 0
# Maximum IMAP command line length. Some clients generate very long command
# lines with huge mailboxes, so you may need to raise this if you get
# "Too long argument" or "IMAP command line too large" errors often.
#imap_max_line_length = 64k
# IMAP logout format string:
# %i - total number of bytes read from client
# %o - total number of bytes sent to client
# %{fetch_hdr_count} - Number of mails with mail header data sent to client
# %{fetch_hdr_bytes} - Number of bytes with mail header data sent to client
# %{fetch_body_count} - Number of mails with mail body data sent to client
# %{fetch_body_bytes} - Number of bytes with mail body data sent to client
# %{deleted} - Number of mails where client added \Deleted flag
# %{expunged} - Number of mails that client expunged, which does not
# include automatically expunged mails
# %{autoexpunged} - Number of mails that were automatically expunged after
# client disconnected
# %{trashed} - Number of mails that client copied/moved to the
# special_use=\Trash mailbox.
# %{appended} - Number of mails saved during the session
#imap_logout_format = in=%i out=%o
# Override the IMAP CAPABILITY response. If the value begins with '+',
# add the given capabilities on top of the defaults (e.g. +XFOO XBAR).
#imap_capability =
# How long to wait between "OK Still here" notifications when client is
# IDLEing.
#imap_idle_notify_interval = 2 mins
imap_idle_notify_interval=4 mins
# ID field names and values to send to clients. Using * as the value makes
# Dovecot use the default value. The following fields have default values
# currently: name, version, os, os-version, support-url, support-email.
#imap_id_send =
# ID fields sent by client to log. * means everything.
#imap_id_log =
# Workarounds for various client bugs:
# delay-newmail:
# Send EXISTS/RECENT new mail notifications only when replying to NOOP
# and CHECK commands. Some clients ignore them otherwise, for example OSX
# Mail (<v2.1). Outlook Express breaks more badly though, without this it
# may show user "Message no longer in server" errors. Note that OE6 still
# breaks even with this workaround if synchronization is set to
# "Headers Only".
# tb-extra-mailbox-sep:
# Thunderbird gets somehow confused with LAYOUT=fs (mbox and dbox) and
# adds extra '/' suffixes to mailbox names. This option causes Dovecot to
# ignore the extra '/' instead of treating it as invalid mailbox name.
# tb-lsub-flags:
# Show \Noselect flags for LSUB replies with LAYOUT=fs (e.g. mbox).
# This makes Thunderbird realize they aren't selectable and show them
# greyed out, instead of only later giving "not selectable" popup error.
#
# The list is space-separated.
#imap_client_workarounds =
# Host allowed in URLAUTH URLs sent by client. "*" allows all.
#imap_urlauth_host =
# What happens when FETCH fails due to some internal error:
# disconnect-immediately:
# The FETCH is aborted immediately and the IMAP client is disconnected.
# disconnect-after:
# The FETCH runs for all the requested mails returning as much data as
# possible. The client is finally disconnected without a tagged reply.
# no-after:
# Same as disconnect-after, but tagged NO reply is sent instead of
# disconnecting the client. If the client attempts to FETCH the same failed
# mail more than once, the client is disconnected. This is to avoid clients
# from going into infinite loops trying to FETCH a broken mail.
#imap_fetch_failure = disconnect-immediately
mail_plugins = $mail_plugins quota
protocol imap {
# Space separated list of plugins to load (default is global mail_plugins).
mail_plugins = $mail_plugins antispam imap_quota
# Maximum number of IMAP connections allowed for a user from each IP address.
# NOTE: The username is compared case-sensitively.
#mail_max_userip_connections = 10
}

View file

@ -0,0 +1,98 @@
##
## Quota configuration.
##
# Note that you also have to enable quota plugin in mail_plugins setting.
# <doc/wiki/Quota.txt>
##
## Quota limits
##
# Quota limits are set using "quota_rule" parameters. To get per-user quota
# limits, you can set/override them by returning "quota_rule" extra field
# from userdb. It's also possible to give mailbox-specific limits, for example
# to give additional 100 MB when saving to Trash:
plugin {
#quota_rule = *:storage=1G
#quota_rule2 = Trash:storage=+100M
# LDA/LMTP allows saving the last mail to bring user from under quota to
# over quota, if the quota doesn't grow too high. Default is to allow as
# long as quota will stay under 10% above the limit. Also allowed e.g. 10M.
#quota_grace = 10%%
# Quota plugin can also limit the maximum accepted mail size.
#quota_max_mail_size = 100M
}
##
## Quota warnings
##
# You can execute a given command when user exceeds a specified quota limit.
# Each quota root has separate limits. Only the command for the first
# exceeded limit is excecuted, so put the highest limit first.
# The commands are executed via script service by connecting to the named
# UNIX socket (quota-warning below).
# Note that % needs to be escaped as %%, otherwise "% " expands to empty.
plugin {
#quota_warning = storage=95%% quota-warning 95 %u
#quota_warning2 = storage=80%% quota-warning 80 %u
}
# Example quota-warning service. The unix listener's permissions should be
# set in a way that mail processes can connect to it. Below example assumes
# that mail processes run as vmail user. If you use mode=0666, all system users
# can generate quota warnings to anyone.
#service quota-warning {
# executable = script /usr/local/bin/quota-warning.sh
# user = dovecot
# unix_listener quota-warning {
# user = vmail
# }
#}
##
## Quota backends
##
# Multiple backends are supported:
# dirsize: Find and sum all the files found from mail directory.
# Extremely SLOW with Maildir. It'll eat your CPU and disk I/O.
# dict: Keep quota stored in dictionary (eg. SQL)
# maildir: Maildir++ quota
# fs: Read-only support for filesystem quota
plugin {
quota = maildir
quota_grace = 10%
quota_status_success = DUNNO
quota_status_nouser = DUNNO
quota_status_overquota = "522 5.2.2 Mailbox is full"
#quota = dirsize:User quota
#quota = maildir:User quota
#quota = dict:User quota::proxy::quota
#quota = fs:User quota
}
service quota-status {
executable = quota-status -p postfix
inet_listener {
port = 12340
}
}
# Multiple quota roots are also possible, for example this gives each user
# their own 100MB quota and one shared 1GB quota within the domain:
plugin {
#quota = dict:user::proxy::quota
#quota2 = dict:domain:%d:proxy::quota_domain
#quota_rule = *:storage=102400
#quota2_rule = *:storage=1048576
}

View file

@ -5,7 +5,7 @@
# Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks
# ping services over the public interface so we should whitelist that address of
# ours too. The string is substituted during installation.
ignoreip = 127.0.0.1/8 PUBLIC_IP
ignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6
[dovecot]
enabled = true
@ -38,6 +38,14 @@ logpath = STORAGE_ROOT/owncloud/nextcloud.log
maxretry = 20
findtime = 120
[miab-postfix465]
enabled = true
port = 465
filter = miab-postfix-submission
logpath = /var/log/mail.log
maxretry = 20
findtime = 30
[miab-postfix587]
enabled = true
port = 587

View file

@ -53,7 +53,7 @@
<key>OutgoingMailServerHostName</key>
<string>PRIMARY_HOSTNAME</string>
<key>OutgoingMailServerPortNumber</key>
<integer>587</integer>
<integer>465</integer>
<key>OutgoingMailServerUseSSL</key>
<true/>
<key>OutgoingPasswordSameAsIncomingPassword</key>

View file

@ -4,6 +4,7 @@ After=multi-user.target
[Service]
Type=idle
IgnoreSIGPIPE=False
ExecStart=/usr/local/lib/mailinabox/start
[Install]

View file

@ -16,8 +16,8 @@
<outgoingServer type="smtp">
<hostname>PRIMARY_HOSTNAME</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>

View file

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

View file

@ -1,6 +1,6 @@
# Expose this directory as static files.
root $ROOT;
index index.html index.htm;
# ADDITIONAL DIRECTIVES HERE
location = /robots.txt {
log_not_found off;
@ -25,37 +25,13 @@
alias /var/lib/mailinabox/mta-sts.txt;
}
# Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect;
rewrite ^/mail/$ /mail/index.php;
location /mail/ {
index index.php;
alias /usr/local/lib/roundcubemail/;
}
location ~ /mail/config/.* {
# A ~-style location is needed to give this precedence over the next block.
return 403;
}
location ~ /mail/.*\.php {
# note: ~ has precendence over a regular location block
include fastcgi_params;
fastcgi_split_path_info ^/mail(/.*)()$;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
fastcgi_pass php-fpm;
# Outgoing mail also goes through this endpoint, so increase the maximum
# file upload limit to match the corresponding Postfix limit.
client_max_body_size 128M;
}
# Z-Push (Microsoft Exchange ActiveSync)
location /Microsoft-Server-ActiveSync {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
fastcgi_read_timeout 630;
fastcgi_pass php-fpm;
fastcgi_pass php-default;
# Outgoing mail also goes through this endpoint, so increase the maximum
# file upload limit to match the corresponding Postfix limit.
@ -65,12 +41,9 @@
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
fastcgi_pass php-fpm;
fastcgi_pass php-default;
}
# ADDITIONAL DIRECTIVES HERE
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
# This block is placed at the end. Nginx's precedence rules means this block
# takes precedence over all non-regex matches and only regex matches that
@ -78,7 +51,7 @@
# we're blocking dotfiles in the static hosted sites but not the FastCGI-
# handled locations for Nextcloud (which serves user-uploaded files that might
# have this pattern, see #414) or some of the other services.
location ~ /\.(ht|svn|git|hg|bzr|.*\.conf) {
location ~ /\.(ht|svn|git|hg|bzr) {
log_not_found off;
access_log off;
deny all;

View file

@ -1,36 +0,0 @@
# Expose this directory as static files.
root $ROOT;
index index.html index.htm;
# If you want to use the PHP socket, use the "php-fpm" alias.
# DON'T DELETE THE LINE BELOW
# ADDITIONAL DIRECTIVES HERE
# Ensure we have the MTA-STS policy enabled
location = /.well-known/mta-sts.txt {
alias /var/lib/mailinabox/mta-sts.txt;
}
# Disable error logs for these
location = /robots.txt {
log_not_found off;
access_log off;
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
# This block is placed at the end. Nginx's precedence rules means this block
# takes precedence over all non-regex matches and only regex matches that
# come after it (i.e. none of those, since this is the last one.) That means
# we're blocking dotfiles in the static hosted sites but not the FastCGI-
# handled locations for Nextcloud (which serves user-uploaded files that might
# have this pattern, see #414) or some of the other services.
location ~ /\.(ht|svn|git|hg|bzr|.*\.conf) {
log_not_found off;
access_log off;
deny all;
}

View file

@ -0,0 +1,15 @@
# ADDITIONAL DIRECTIVES HERE
# WKD Locations
location ~ ^/.well-known/openpgpkey/(?<domain>.+)/policy$ {
add_header Access-Control-Allow-origin * always;
if (-d /var/lib/mailinabox/wkd/$domain/) {
return 204;
}
return 404;
}
location ~ ^/.well-known/openpgpkey/(?<domain>.+)/hu/(?<keyid>[ybndrfg8ejkmcpqxot1uwisza345h769]+)$ {
add_header Access-Control-Allow-Origin * always;
alias /var/lib/mailinabox/wkd/$domain/$keyid;
}

View file

@ -1,3 +1,5 @@
# ADDITIONAL DIRECTIVES HERE
# Control Panel
# Proxy /admin to our Python based control panel daemon. It is
# listening on IPv4 only so use an IP address and not 'localhost'.
@ -14,6 +16,30 @@
add_header Content-Security-Policy "frame-ancestors 'none';";
}
# Roundcube Webmail configuration.
rewrite ^/mail$ /mail/ redirect;
rewrite ^/mail/$ /mail/index.php;
location /mail/ {
index index.php;
alias /usr/local/lib/roundcubemail/;
}
location ~ /mail/config/.* {
# A ~-style location is needed to give this precedence over the next block.
return 403;
}
location ~ /mail/.*\.php {
# note: ~ has precendence over a regular location block
include fastcgi_params;
fastcgi_split_path_info ^/mail(/.*)()$;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
fastcgi_pass php-default;
# Outgoing mail also goes through this endpoint, so increase the maximum
# file upload limit to match the corresponding Postfix limit.
client_max_body_size 128M;
}
# Nextcloud configuration.
rewrite ^/cloud$ /cloud/ redirect;
rewrite ^/cloud/$ /cloud/index.php;
@ -34,7 +60,7 @@
index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
fastcgi_pass php-fpm;
fastcgi_pass php-default;
}
}
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
@ -51,7 +77,7 @@
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel;
fastcgi_read_timeout 630;
fastcgi_pass php-fpm;
fastcgi_pass php-default;
client_max_body_size 1G;
fastcgi_buffers 64 4K;
}
@ -72,5 +98,3 @@
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
# ADDITIONAL DIRECTIVES HERE

View file

@ -6,6 +6,11 @@
## If you modify any system configuration you are on
## your own --- please do not ask for help from us.
# This socket is reserved for Mail-in-a-Box-specific services
upstream php-default {
server unix:/var/run/php/php-default.sock;
}
upstream php-fpm {
server unix:/var/run/php/php{{phpver}}-fpm.sock;
}

View file

@ -1,14 +1,106 @@
<html>
<head>
<title>Redirecting...</title>
<title>Power Mail-in-a-Box</title>
<meta charset="utf-8" name="robots" content="noindex">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
position: relative;
min-height: 100%;
}
#content {
padding-bottom: 3.5rem;
}
p,
ul,
li {
font-size: 15pt
}
th,
td {
font-size: 20pt;
width: 30%;
}
#small td {
font-size: 12.5pt;
}
footer {
position: absolute;
bottom: 0;
width: 100%;
height: 3.5rem;
background-color: #ddd;
}
footer p {
font-size: 10pt;
}
</style>
</head>
<body>
<div id=content>
<h1 align=center>It's working! 🎉🎉🎉</h1>
<p align=center>It is running! Now you can begin your <b>very</b> awesome thing!
<p>
<h2 align=center>What is this Power Mail-in-a-Box able to do for you?</h2>
<ul>
<li>Send and receive mail (duh);</li>
<li>Be your <a href="https://wiki.gnupg.org/WKD">Web Key Directory</a> server, if you're into GnuPG and all
that jazz. No need to rely on public keyservers!</li>
<li>Also be your web server - if you want:</li>
<ul>
<li>Static sites? No problem!</li>
<li>Front-end + back-end? No problem!</li>
<li>PHP? <b>Did I stutter?</b> We got you!</li>
<li>How many sites can I host at the same time? How many you want (provided your server can handle all
of them, of course)</li>
</ul>
</ul>
<h2 align=center>Let's begin, then?</h2>
<p align=center>This Power Mail-in-a-Box was configured with the name <code>{{PRIMARY_HOSTNAME}}</code>.</p>
<table align=center>
<thead>
<th>To access the admin panel...</th>
<th>To access the webmail...</th>
<th>To access NextCloud...</th>
</thead>
<tbody>
<tr>
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/admin">Go here</a></b></td>
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/mail">Go here</a></b></td>
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/cloud">Go here</a></b></td>
</tr>
<tr id=small>
<td align=center><code>{{PRIMARY_HOSTNAME}}/admin</code></td>
<td align=center><code>{{PRIMARY_HOSTNAME}}/mail</code></td>
<td align=center><code>{{PRIMARY_HOSTNAME}}/cloud</code></td>
</tr>
</tbody>
</table>
<br><br>
<h2 align=center>Oh and by the way: thank you for sticking by!</h2>
<p align=center>This project is maintained <a href="https://power-mailinabox.net">on GitHub</a>.
Feel free to take a peek and maybe leave a star. It is very much appreciated! ❤</p>
<p align=center>Also feel free report any issues you may find, or leave your ideas.</p>
</div>
<footer>
<p align=center>
Unless you moved the folder elsewhere, or you manually added this file, it is located at
<code>{{STORAGE_ROOT}}/www/default/index.html</code> on the server - feel free to remove it whenever you
want.
</p>
</footer>
</body>
<script>
location.href = "/mail"
</script>
</html>

View file

@ -49,7 +49,7 @@ define('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn');
define('IMAP_SMTP_METHOD', 'sendmail');
global $imap_smtp_params;
$imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 587, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
$imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
define('MAIL_MIMEPART_CRLF', "\r\n");
define('IMAP_MEETING_USE_CALDAV', true);

View file

@ -1,138 +1,180 @@
import base64, os, os.path, hmac
import base64
import os
import os.path
import hmac
import json
import secrets
from datetime import timedelta
from flask import make_response
from expiringdict import ExpiringDict
import utils
from mailconfig import get_mail_password, get_mail_user_privileges
from mfa import get_hash_mfa_state, validate_auth_mfa
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
class KeyAuthService:
"""Generate an API key for authenticating clients
Clients must read the key from the key file and send the key with all HTTP
requests. The key is passed as the username field in the standard HTTP
Basic Auth header.
"""
class AuthService:
def __init__(self):
self.auth_realm = DEFAULT_AUTH_REALM
self.key = self._generate_key()
self.key_path = DEFAULT_KEY_PATH
self.max_session_duration = timedelta(days=2)
def write_key(self):
"""Write key to file so authorized clients can get the key
self.init_system_api_key()
self.sessions = ExpiringDict(
max_len=64,
max_age_seconds=self.max_session_duration.total_seconds())
The key file is created with mode 0640 so that additional users can be
authorized to access the API by granting group/ACL read permissions on
the key file.
"""
def create_file_with_mode(path, mode):
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
old_umask = os.umask(0)
try:
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
finally:
os.umask(old_umask)
def init_system_api_key(self):
"""Write an API key to a local file so local processes can use the API"""
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
with open(self.key_path, 'r') as file:
self.key = file.read()
with create_file_with_mode(self.key_path, 0o640) as key_file:
key_file.write(self.key + '\n')
def authenticate(self, request, env):
"""Test if the client key passed in HTTP Authorization header matches the service key
or if the or username/password passed in the header matches an administrator user.
def authenticate(self, request, env, login_only=False, logout=False):
"""Test if the HTTP Authorization header's username matches the system key, a session key,
or if the username/password passed in the header matches a local user.
Returns a tuple of the user's email address and list of user privileges (e.g.
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
If the user used an API key, the user's email is returned as None."""
If the user used the system API key, the user's email is returned as None since
this key is not associated with a user."""
def decode(s):
return base64.b64decode(s.encode('ascii')).decode('ascii')
def parse_http_authorization_basic(header):
def decode(s):
return base64.b64decode(s.encode('ascii')).decode('ascii')
def parse_basic_auth(header):
if " " not in header:
return None, None
scheme, credentials = header.split(maxsplit=1)
if scheme != 'Basic':
return None, None
credentials = decode(credentials)
if ":" not in credentials:
return None, None
username, password = credentials.split(':', maxsplit=1)
return username, password
header = request.headers.get('Authorization')
if not header:
raise ValueError("No authorization header provided.")
username, password = parse_basic_auth(header)
username, password = parse_http_authorization_basic(
request.headers.get('Authorization', ''))
if username in (None, ""):
raise ValueError("Authorization header invalid.")
elif username == self.key:
# The user passed the API key which grants administrative privs.
if username.strip() == "" and password.strip() == "":
raise ValueError(
"No email address, password, session key, or API key provided."
)
# If user passed the system API key, grant administrative privs. This key
# is not associated with a user.
if username == self.key and not login_only:
return (None, ["admin"])
# If the password corresponds with a session token for the user, grant access for that user.
if self.get_session(username, password, "login",
env) and not login_only:
sessionid = password
session = self.sessions[sessionid]
if logout:
# Clear the session.
del self.sessions[sessionid]
else:
# Re-up the session so that it does not expire.
self.sessions[sessionid] = session
# If no password was given, but a username was given, we're missing some information.
elif password.strip() == "":
raise ValueError("Enter a password.")
else:
# The user is trying to log in with a username and user-specific
# API key or password. Raises or returns privs.
return (username, self.get_user_credentials(username, password, env))
def get_user_credentials(self, email, pw, env):
# Validate a user's credentials. On success returns a list of
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
# with a login error message.
# Sanity check.
if email == "" or pw == "":
raise ValueError("Enter an email address and password.")
# The password might be a user-specific API key. create_user_key raises
# a ValueError if the user does not exist.
if hmac.compare_digest(self.create_user_key(email, env), pw):
# OK.
pass
else:
# Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user.
pw_hash = get_mail_password(email, env)
# Authenticate.
try:
# Use 'doveadm pw' to check credentials. doveadm will return
# a non-zero exit status if the credentials are no good,
# and check_call will raise an exception in that case.
utils.shell('check_call', [
"/usr/bin/doveadm", "pw",
"-p", pw,
"-t", pw_hash,
])
except:
# Login failed.
raise ValueError("Invalid password.")
# The user is trying to log in with a username and a password
# (and possibly a MFA token). On failure, an exception is raised.
self.check_user_auth(username, password, request, env)
# Get privileges for authorization. This call should never fail because by this
# point we know the email address is a valid user. But on error the call will
# return a tuple of an error message and an HTTP status code.
privs = get_mail_user_privileges(email, env)
if isinstance(privs, tuple): raise ValueError(privs[0])
# point we know the email address is a valid user --- unless the user has been
# deleted after the session was granted. On error the call will return a tuple
# of an error message and an HTTP status code.
privs = get_mail_user_privileges(username, env)
if isinstance(privs, tuple):
raise ValueError(privs[0])
# Return a list of privileges.
return privs
# Return the authorization information.
return (username, privs)
def create_user_key(self, email, env):
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
# email address & hashed password and the key will be the master API key. The user of
# course has their own email address and password. We assume they do not have the master
# API key (unless they are trusted anyway). The HMAC proves that they authenticated
# with us in some other way to get the HMAC. Including the password means that when
# a user's password is reset, the HMAC changes and they will correctly need to log
# in to the control panel again. This method raises a ValueError if the user does
# not exist, due to get_mail_password.
msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest()
def check_user_auth(self, email, pw, request, env):
# Validate a user's login email address and password. If MFA is enabled,
# check the MFA token in the X-Auth-Token header.
#
# On login failure, raises a ValueError with a login error message. On
# success, nothing is returned.
def _generate_key(self):
raw_key = os.urandom(32)
return base64.b64encode(raw_key).decode('ascii')
# Authenticate.
try:
# Get the hashed password of the user. Raise a ValueError if the
# email address does not correspond to a user. But wrap it in the
# same exception as if a password fails so we don't easily reveal
# if an email address is valid.
pw_hash = get_mail_password(email, env)
# Use 'doveadm pw' to check credentials. doveadm will return
# a non-zero exit status if the credentials are no good,
# and check_call will raise an exception in that case.
utils.shell('check_call', [
"/usr/bin/doveadm",
"pw",
"-p",
pw,
"-t",
pw_hash,
])
except:
# Login failed.
raise ValueError("Incorrect email address or password.")
# If MFA is enabled, check that MFA passes.
status, hints = validate_auth_mfa(email, request, env)
if not status:
# Login valid. Hints may have more info.
raise ValueError(",".join(hints))
def create_user_password_state_token(self, email, env):
# Create a token that changes if the user's password or MFA options change
# so that sessions become invalid if any of that information changes.
msg = get_mail_password(email, env).encode("utf8")
# Add to the message the current MFA state, which is a list of MFA information.
# Turn it into a string stably.
msg += b" " + json.dumps(get_hash_mfa_state(email, env),
sort_keys=True).encode("utf8")
# Make a HMAC using the system API key as a hash key.
hash_key = self.key.encode('ascii')
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
def create_session_key(self, username, env, type=None):
# Create a new session.
token = secrets.token_hex(32)
self.sessions[token] = {
"email": username,
"password_token":
self.create_user_password_state_token(username, env),
"type": type,
}
return token
def get_session(self, user_email, session_key, session_type, env):
if session_key not in self.sessions:
return None
session = self.sessions[session_key]
if session_type == "login" and session["email"] != user_email:
return None
if session["type"] != session_type:
return None
if session["password_token"] != self.create_user_password_state_token(
session["email"], env):
return None
return session

View file

@ -7,42 +7,53 @@
# 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 dateutil.parser, dateutil.relativedelta, dateutil.tz
import os
import os.path
import shutil
import glob
import re
import datetime
import sys
import dateutil.parser
import dateutil.relativedelta
import dateutil.tz
import rtyaml
from exclusiveprocess import Lock, CannotAcquireLock
from utils import load_environment, shell, wait_for_service, fix_boto, get_php_version
rsync_ssh_options = [
"--ssh-options= -i /root/.ssh/id_rsa_miab",
"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"",
]
from utils import load_environment, shell, wait_for_service, get_php_version
def backup_status(env):
# If backups are disabled, return no status.
config = get_backup_config(env)
if config["target"] == "off":
return { }
return {}
# Query duplicity to get a list of all full and incremental
# backups available.
backups = { }
backups = {}
now = datetime.datetime.now(dateutil.tz.tzlocal())
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_cache_dir = os.path.join(backup_root, 'cache')
def reldate(date, ref, clip):
if ref < date: return clip
if ref < date:
return clip
rd = dateutil.relativedelta.relativedelta(ref, date)
if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months)
if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months)
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
if rd.days >= 7: return "%d days" % rd.days
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
if rd.years > 1:
return "%d years, %d months" % (rd.years, rd.months)
if rd.years == 1:
return "%d year, %d months" % (rd.years, rd.months)
if rd.months > 1:
return "%d months, %d days" % (rd.months, rd.days)
if rd.months == 1:
return "%d month, %d days" % (rd.months, rd.days)
if rd.days >= 7:
return "%d days" % rd.days
if rd.days > 1:
return "%d days, %d hours" % (rd.days, rd.hours)
if rd.days == 1:
return "%d day, %d hours" % (rd.days, rd.hours)
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
# Get duplicity collection status and parse for a list of backups.
@ -54,24 +65,26 @@ def backup_status(env):
"date_str": date.strftime("%Y-%m-%d %X") + " " + now.tzname(),
"date_delta": reldate(date, now, "the future?"),
"full": keys[0] == "full",
"size": 0, # collection-status doesn't give us the size
"volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful)
"size": 0, # collection-status doesn't give us the size
# number of archive volumes for this backup (not really helpful)
"volumes": int(keys[2]),
}
code, collection_status = shell('check_output', [
"/usr/bin/duplicity",
"/usr/local/bin/duplicity",
"collection-status",
"--archive-dir", backup_cache_dir,
"--gpg-options", "--cipher-algo=AES256",
"--log-fd", "1",
config["target"],
] + rsync_ssh_options,
get_env(env),
get_duplicity_target_url(config),
] + get_duplicity_additional_args(env),
get_duplicity_env_vars(env),
trap=True)
if code != 0:
# Command failed. This is likely due to an improperly configured remote
# destination for the backups or the last backup job terminated unexpectedly.
raise Exception("Something is wrong with the backup: " + collection_status)
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"):
backup = parse_line(line)
@ -84,8 +97,11 @@ def backup_status(env):
# space is used for those.
unmatched_file_size = 0
for fn, size in list_target_files(config):
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
if not m: continue # not a part of a current backup chain
m = re.match(
r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.",
fn)
if not m:
continue # not a part of a current backup chain
key = m.group("date")
if key in backups:
backups[key]["size"] += size
@ -94,7 +110,7 @@ def backup_status(env):
# Ensure the rows are sorted reverse chronologically.
# This is relied on by should_force_full() and the next step.
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
backups = sorted(backups.values(), key=lambda b: b["date"], reverse=True)
# Get the average size of incremental backups, the size of the
# most recent full backup, and the date of the most recent
@ -123,16 +139,23 @@ def backup_status(env):
if incremental_count > 0 and incremental_size > 0 and first_full_size is not None:
# How many days until the next incremental backup? First, the part of
# the algorithm based on increment sizes:
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count)
est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full)
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (
incremental_size / incremental_count)
est_time_of_next_full = first_date + \
datetime.timedelta(days=est_days_to_next_full)
# ...And then the part of the algorithm based on full backup age:
est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1))
est_time_of_next_full = min(
est_time_of_next_full, first_full_date +
datetime.timedelta(days=config["min_age_in_days"] * 10 + 1))
# It still can't be deleted until it's old enough.
est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"]))
est_deleted_on = max(
est_time_of_next_full,
first_date + datetime.timedelta(days=config["min_age_in_days"]))
deleted_in = "approx. %d days" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5)
deleted_in = "approx. %d days" % round(
(est_deleted_on - now).total_seconds() / 60 / 60 / 24 + .5)
# When will a backup be deleted? Set the deleted_in field of each backup.
saw_full = False
@ -148,7 +171,11 @@ def backup_status(env):
elif saw_full and not deleted_in:
# We're now on backups prior to the most recent full backup. These are
# free to be deleted as soon as they are min_age_in_days old.
deleted_in = reldate(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup")
deleted_in = reldate(
now,
dateutil.parser.parse(bak["date"]) +
datetime.timedelta(days=config["min_age_in_days"]),
"on next daily backup")
bak["deleted_in"] = deleted_in
return {
@ -156,6 +183,7 @@ def backup_status(env):
"unmatched_file_size": unmatched_file_size,
}
def should_force_full(config, env):
# Force a full backup when the total size of the increments
# since the last full backup is greater than half the size
@ -171,9 +199,11 @@ def should_force_full(config, env):
# Return if we should to a full backup, which is based
# on the size of the increments relative to the full
# backup, as well as the age of the full backup.
if inc_size > .5*bak["size"]:
if inc_size > .5 * bak["size"]:
return True
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(
days=config["min_age_in_days"] * 10 +
1) < datetime.datetime.now(dateutil.tz.tzlocal()):
return True
return False
else:
@ -181,6 +211,7 @@ def should_force_full(config, env):
# (I love for/else blocks. Here it's just to show off.)
return True
def get_passphrase(env):
# Get the encryption passphrase. secret_key.txt is 2048 random
# bits base64-encoded and with line breaks every 65 characters.
@ -191,14 +222,59 @@ def get_passphrase(env):
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
passphrase = f.readline().strip()
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
if len(passphrase) < 43:
raise Exception("secret_key.txt's first line is too short!")
return passphrase
def get_env(env):
def get_duplicity_target_url(config):
target = config["target"]
if get_target_type(config) == "s3":
from urllib.parse import urlsplit, urlunsplit
target = list(urlsplit(target))
# Although we store the S3 hostname in the target URL,
# duplicity no longer accepts it in the target URL. The hostname in
# the target URL must be the bucket name. The hostname is passed
# via get_duplicity_additional_args. Move the first part of the
# path (the bucket name) into the hostname URL component, and leave
# the rest for the path.
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)
return target
def get_duplicity_additional_args(env):
config = get_backup_config(env)
port = 0
try:
port = int(config["target_rsync_port"])
except Exception:
port = 22
if get_target_type(config) == 'rsync':
return [
f"--ssh-options= -i /root/.ssh/id_rsa_miab -p {port}",
f"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"",
]
elif get_target_type(config) == 's3':
# See note about hostname in get_duplicity_target_url.
from urllib.parse import urlsplit, urlunsplit
target = urlsplit(config["target"])
endpoint_url = urlunsplit(("https", target.netloc, '', '', ''))
return ["--s3-endpoint-url", endpoint_url]
return []
def get_duplicity_env_vars(env):
config = get_backup_config(env)
env = { "PASSPHRASE" : get_passphrase(env) }
env = {"PASSPHRASE": get_passphrase(env)}
if get_target_type(config) == 's3':
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
@ -206,10 +282,12 @@ def get_env(env):
return env
def get_target_type(config):
protocol = config["target"].split(":")[0]
return protocol
def perform_backup(full_backup, user_initiated=False):
env = load_environment()
php_fpm = f"php{get_php_version()}-fpm"
@ -225,7 +303,7 @@ def perform_backup(full_backup, user_initiated=False):
return "Another backup is already being done!"
else:
lock.forever()
config = get_backup_config(env)
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_cache_dir = os.path.join(backup_root, 'cache')
@ -250,7 +328,10 @@ def perform_backup(full_backup, user_initiated=False):
# Stop services.
def service_command(service, command, quit=None):
# Execute silently, but if there is an error then display the output & exit.
code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
code, ret = shell('check_output',
["/usr/sbin/service", service, command],
capture_stderr=True,
trap=True)
if code != 0:
print(ret)
if quit:
@ -259,6 +340,7 @@ def perform_backup(full_backup, user_initiated=False):
service_command(php_fpm, "stop", quit=True)
service_command("postfix", "stop", quit=True)
service_command("dovecot", "stop", quit=True)
service_command("postgrey", "stop", quit=True)
# Execute a pre-backup script that copies files outside the homedir.
# Run as the STORAGE_USER user, not as root. Pass our settings in
@ -274,7 +356,7 @@ def perform_backup(full_backup, user_initiated=False):
# after the first backup. See #396.
try:
shell('check_call', [
"/usr/bin/duplicity",
"/usr/local/bin/duplicity",
"full" if full_backup else "incr",
"--verbosity", "warning", "--no-print-statistics",
"--archive-dir", backup_cache_dir,
@ -282,12 +364,13 @@ def perform_backup(full_backup, user_initiated=False):
"--volsize", "250",
"--gpg-options", "--cipher-algo=AES256",
env["STORAGE_ROOT"],
config["target"],
get_duplicity_target_url(config),
"--allow-source-mismatch"
] + rsync_ssh_options,
get_env(env))
] + get_duplicity_additional_args(env),
get_duplicity_env_vars(env))
finally:
# Start services again.
service_command("postgrey", "start", quit=False)
service_command("dovecot", "start", quit=False)
service_command("postfix", "start", quit=False)
service_command(php_fpm, "start", quit=False)
@ -295,15 +378,15 @@ def perform_backup(full_backup, user_initiated=False):
# Remove old backups. This deletes all backup data no longer needed
# from more than 3 days ago.
shell('check_call', [
"/usr/bin/duplicity",
"/usr/local/bin/duplicity",
"remove-older-than",
"%dD" % config["min_age_in_days"],
"--verbosity", "error",
"--archive-dir", backup_cache_dir,
"--force",
config["target"]
] + rsync_ssh_options,
get_env(env))
get_duplicity_target_url(config)
] + get_duplicity_additional_args(env),
get_duplicity_env_vars(env))
# From duplicity's manual:
# "This should only be necessary after a duplicity session fails or is
@ -311,19 +394,20 @@ def perform_backup(full_backup, user_initiated=False):
# That may be unlikely here but we may as well ensure we tidy up if
# that does happen - it might just have been a poorly timed reboot.
shell('check_call', [
"/usr/bin/duplicity",
"/usr/local/bin/duplicity",
"cleanup",
"--verbosity", "error",
"--archive-dir", backup_cache_dir,
"--force",
config["target"]
] + rsync_ssh_options,
get_env(env))
get_duplicity_target_url(config)
] + get_duplicity_additional_args(env),
get_duplicity_env_vars(env))
# Change ownership of backups to the user-data user, so that the after-bcakup
# script can access them.
if get_target_type(config) == 'file':
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
shell('check_call',
["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
# Execute a post-backup script that does the copying to a remote server.
# Run as the STORAGE_USER user, not as root. Pass our settings in
@ -346,6 +430,7 @@ def perform_backup(full_backup, user_initiated=False):
wait_for_service(25, True, env, 10)
wait_for_service(993, True, env, 10)
def run_duplicity_verification():
env = load_environment()
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
@ -353,27 +438,28 @@ def run_duplicity_verification():
backup_cache_dir = os.path.join(backup_root, 'cache')
shell('check_call', [
"/usr/bin/duplicity",
"--verbosity", "info",
"/usr/local/bin/duplicity",
"--verbosity",
"info",
"verify",
"--compare-data",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
config["target"],
get_duplicity_target_url(config),
env["STORAGE_ROOT"],
] + rsync_ssh_options, get_env(env))
] + get_duplicity_additional_args(env), get_duplicity_env_vars(env))
def run_duplicity_restore(args):
env = load_environment()
config = get_backup_config(env)
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
shell('check_call', [
"/usr/bin/duplicity",
"/usr/local/bin/duplicity",
"restore",
"--archive-dir", backup_cache_dir,
config["target"],
] + rsync_ssh_options + args,
get_env(env))
get_duplicity_target_url(config),
] + get_duplicity_additional_args(env) + args,
get_duplicity_env_vars(env))
def list_target_files(config):
import urllib.parse
@ -383,7 +469,8 @@ def list_target_files(config):
return "invalid target"
if target.scheme == "file":
return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]
return [(fn, os.path.getsize(os.path.join(target.path, fn)))
for fn in os.listdir(target.path)]
elif target.scheme == "rsync":
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
@ -395,23 +482,24 @@ def list_target_files(config):
if target_path.startswith('/'):
target_path = target_path[1:]
rsync_command = [ 'rsync',
'-e',
'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes',
'--list-only',
'-r',
rsync_target.format(
host=target.netloc,
path=target_path)
]
rsync_command = [
'rsync', '-e',
f"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {int(config.get('target_rsync_port', 22))} -i /root/.ssh/id_rsa_miab",
'--list-only', '-r',
rsync_target.format(host=target.netloc, path=target_path)
]
code, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True)
code, listing = shell('check_output',
rsync_command,
trap=True,
capture_stderr=True)
if code == 0:
ret = []
for l in listing.split('\n'):
match = rsync_fn_size_re.match(l)
if match:
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
ret.append((match.groups()[1],
int(match.groups()[0].replace(',', ''))))
return ret
else:
if 'Permission denied (publickey).' in listing:
@ -419,36 +507,26 @@ def list_target_files(config):
elif 'No such file or directory' in listing:
reason = "Provided path {} is invalid.".format(target_path)
elif 'Network is unreachable' in listing:
reason = "The IP address {} is unreachable.".format(target.hostname)
reason = "The IP address {} is unreachable.".format(
target.hostname)
elif 'Could not resolve hostname' in listing:
reason = "The hostname {} cannot be resolved.".format(target.hostname)
reason = "The hostname {} cannot be resolved.".format(
target.hostname)
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."
raise ValueError(
"Connection to rsync host failed: {}".format(reason))
elif target.scheme == "s3":
# match to a Region
fix_boto() # must call prior to importing boto
import boto.s3
from boto.exception import BotoServerError
custom_region = False
for region in boto.s3.regions():
if region.endpoint == target.hostname:
break
else:
# If region is not found this is a custom region
custom_region = True
import boto3.s3
from botocore.exceptions import ClientError
# separate bucket from path in target
bucket = target.path[1:].split('/')[0]
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
# Create a custom region with custom endpoint
if custom_region:
from boto.s3.connection import S3Connection
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)
# If no prefix is specified, set the path to '', otherwise boto won't list the files
if path == '/':
path = ''
@ -458,33 +536,63 @@ def list_target_files(config):
# connect to the region & bucket
try:
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
bucket = conn.get_bucket(bucket)
except BotoServerError as e:
if e.status == 403:
raise ValueError("Invalid S3 access key or secret access key.")
elif e.status == 404:
raise ValueError("Invalid S3 bucket name.")
elif e.status == 301:
raise ValueError("Incorrect region for this bucket.")
raise ValueError(e.reason)
s3 = boto3.client('s3', \
endpoint_url=f'https://{target.hostname}', \
aws_access_key_id=config['target_user'], \
aws_secret_access_key=config['target_pass'])
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path).get("Contents", [])
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
except ClientError as e:
raise ValueError(e)
return backup_list
elif target.scheme == 'b2':
InMemoryAccountInfo = None
B2Api = None
NonExistentBucket = None
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
from b2sdk.v1 import InMemoryAccountInfo, B2Api
from b2sdk.v1.exception import NonExistentBucket
info = InMemoryAccountInfo()
b2_api = B2Api(info)
# Extract information from target
b2_application_keyid = target.netloc[:target.netloc.index(':')]
b2_application_key = target.netloc[target.netloc.index(':') +
1:target.netloc.index('@')]
b2_bucket = target.netloc[target.netloc.index('@') + 1:]
try:
b2_api.authorize_account("production", b2_application_keyid,
b2_application_key)
bucket = b2_api.get_bucket_by_name(b2_bucket)
except NonExistentBucket as e:
raise ValueError(
"B2 Bucket does not exist. Please double check your information!"
)
return [(key.file_name, key.size) for key, _ in bucket.ls()]
else:
raise ValueError(config["target"])
def backup_set_custom(env, target, target_user, target_pass, min_age):
def backup_set_custom(env, target, target_user, target_pass, target_rsync_port, min_age):
config = get_backup_config(env, for_save=True)
# min_age must be an int
if isinstance(min_age, str):
min_age = int(min_age)
if isinstance(target_rsync_port, str):
try:
target_rsync_port = int(target_rsync_port)
except:
target_rsync_port = 22
config["target"] = target
config["target_user"] = target_user
config["target_pass"] = target_pass
config["target_rsync_port"] = target_rsync_port
config["min_age_in_days"] = min_age
# Validate.
@ -500,19 +608,19 @@ def backup_set_custom(env, target, target_user, target_pass, min_age):
return "OK"
def get_backup_config(env, for_save=False, for_ui=False):
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
# Defaults.
config = {
"min_age_in_days": 3,
"target": "local",
}
config = {"min_age_in_days": 3, "target": "local", "target_rsync_port": 22}
# Merge in anything written to custom.yaml.
try:
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
if not isinstance(custom_config, dict): raise ValueError() # caught below
custom_config = rtyaml.load(
open(os.path.join(backup_root, 'custom.yaml')))
if not isinstance(custom_config, dict):
raise ValueError() # caught below
config.update(custom_config)
except:
pass
@ -540,11 +648,13 @@ def get_backup_config(env, for_save=False, for_ui=False):
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:
f.write(rtyaml.dump(newconfig))
if __name__ == "__main__":
import sys
if sys.argv[-1] == "--verify":
@ -554,7 +664,8 @@ 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())):
for fn, size in list_target_files(get_backup_config(
load_environment())):
print("{}\t{}".format(fn, size))
elif sys.argv[-1] == "--status":

204
management/cli.py Executable file
View file

@ -0,0 +1,204 @@
#!/usr/bin/python3
#
# This is a command-line script for calling management APIs
# on the Mail-in-a-Box control panel backend. The script
# reads /var/lib/mailinabox/api.key for the backend's
# root API key. This file is readable only by root, so this
# tool can only be used as root.
import sys
import getpass
import urllib.request
import urllib.error
import json
import re
import csv
def mgmt(cmd, data=None, is_json=False):
# The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri)
req = urllib.request.Request(
mgmt_uri + cmd,
urllib.parse.urlencode(data).encode("utf8") if data else None)
try:
response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
if e.code == 401:
try:
print(e.read().decode("utf8"))
except:
pass
print(
"The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.",
file=sys.stderr)
elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr)
else:
print(e, file=sys.stderr)
sys.exit(1)
resp = response.read().decode('utf8')
if is_json:
resp = json.loads(resp)
return resp
def read_password():
while True:
first = getpass.getpass('password: ')
if len(first) < 8:
print("Passwords must be at least eight characters.")
continue
second = getpass.getpass(' (again): ')
if first != second:
print("Passwords not the same. Try again.")
continue
break
return first
def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler()
auth_handler.add_password(realm='Mail-in-a-Box Management Server',
uri=mgmt_uri,
user=key,
passwd='')
opener = urllib.request.build_opener(auth_handler)
urllib.request.install_opener(opener)
if len(sys.argv) < 2:
print("""Usage:
{cli} system default-quota [new default] (set default quota for system)
{cli} user (lists users)
{cli} user add user@domain.com [password]
{cli} user password user@domain.com [password]
{cli} user remove user@domain.com
{cli} user make-admin user@domain.com
{cli} user quota user@domain [new-quota]
{cli} user remove-admin user@domain.com
{cli} user admins (lists admins)
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
{cli} user mfa disable user@domain.com [id] (disables MFA for user)
{cli} alias (lists aliases)
{cli} alias add incoming.name@domain.com sent.to@other.domain.com
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
{cli} alias remove incoming.name@domain.com
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
""".format(cli="management/cli.py"))
elif sys.argv[1] == "user" and len(sys.argv) == 2:
# Dump a list of users, one per line. Mark admins with an asterisk.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if user['status'] == 'inactive':
continue
print(user['email'], end='')
if "admin" in user['privileges']:
print("*", end='')
if user['quota'] == '0':
print(" unlimited", end='')
else:
print(" " + user['quota'], end='')
print()
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
if len(sys.argv) < 5:
if len(sys.argv) < 4:
email = input("email: ")
else:
email = sys.argv[3]
pw = read_password()
else:
email, pw = sys.argv[3:5]
if sys.argv[2] == "add":
print(mgmt("/mail/users/add", {"email": email, "password": pw}))
elif sys.argv[2] == "password":
print(mgmt("/mail/users/password", {"email": email, "password": pw}))
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/users/remove", {"email": sys.argv[3]}))
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin",
"remove-admin") and len(
sys.argv) == 4:
if sys.argv[2] == "make-admin":
action = "add"
else:
action = "remove"
print(
mgmt("/mail/users/privileges/" + action, {
"email": sys.argv[3],
"privilege": "admin"
}))
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
# Dump a list of admin users.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if "admin" in user['privileges']:
print(user['email'])
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
# Set a user's quota
print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3]))
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
# Set a user's quota
users = mgmt("/mail/users/quota", {
"email": sys.argv[3],
"quota": sys.argv[4]
})
elif sys.argv[1] == "user" and len(
sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
# Show MFA status for a user.
status = mgmt("/mfa/status", {"user": sys.argv[4]}, is_json=True)
W = csv.writer(sys.stdout)
W.writerow(["id", "type", "label"])
for mfa in status["enabled_mfa"]:
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
elif sys.argv[1] == "user" and len(
sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
# Disable MFA (all or a particular device) for a user.
print(
mgmt(
"/mfa/disable", {
"user": sys.argv[4],
"mfa-id": sys.argv[5] if len(sys.argv) == 6 else None
}))
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(
mgmt("/mail/aliases/add", {
"address": sys.argv[3],
"forwards_to": sys.argv[4]
}))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", {"address": sys.argv[3]}))
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
sys.argv) == 3:
print(mgmt("/system/default-quota?text=1"))
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
sys.argv) == 4:
print(mgmt("/system/default-quota", {"default_quota": sys.argv[3]}))
else:
print("Invalid command-line arguments.")
sys.exit(1)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -24,9 +24,18 @@
# create the new config file in memory
import sys, re
import sys
import re
def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_lines = False, testing = False):
def edit_conf(filename,
settings,
delimiter_re,
delimiter,
comment_char,
folded_lines=False,
testing=False,
erase_setting=False):
found = set()
buf = ""
input_lines = list(open(filename, "r+"))
@ -42,34 +51,38 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
# See if this line is for any settings passed on the command line.
for i in range(len(settings)):
# Check that this line contain this setting from the command-line arguments.
# Check whether 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*$",
line, re.S)
if not m: continue
"(\s*)" + "(" + re.escape(comment_char) + "\s*)?" +
re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
if not m:
continue
indent, is_comment, existing_val = m.groups()
# If this is already the setting, do nothing.
if is_comment is None and existing_val == val:
# If this is already the setting, keep it in the file, except:
# * If we've already seen it before, then remove this duplicate line.
# * If val is empty and erase_setting is on, then comment it out.
if is_comment is None and existing_val == val and not (not val and erase_setting):
# It may be that we've already inserted this setting higher
# in the file so check for that first.
if i in found: break
if i in found:
break
buf += line
found.add(i)
break
# comment-out the existing line (also comment any folded lines)
if is_comment is None:
buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
buf += comment_char + line.rstrip().replace(
"\n", "\n" + comment_char) + "\n"
else:
# the line is already commented, pass it through
buf += line
# if this option oddly appears more than once, don't add the setting again
if i in found:
# Or if we're clearing it, don't add it
if (i in found) or (not val and erase_setting):
break
# add the new setting
@ -83,11 +96,12 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
# If did not match any setting names, pass this line through.
buf += line
# Put any settings we didn't see at the end of the file.
# Put any settings we didn't see at the end of the file, except those being erased.
for i in range(len(settings)):
if i not in found:
name, val = settings[i].split("=", 1)
buf += name + delimiter + val + "\n"
if not (not val and erase_setting):
buf += name + delimiter + val + "\n"
if not testing:
# Write out the new file.
@ -97,11 +111,14 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
# Just print the new file to stdout.
print(buf)
# Run standalone
if __name__ == "__main__":
# 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 [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]"
)
sys.exit(1)
# parse command line arguments
@ -113,12 +130,16 @@ if __name__ == "__main__":
comment_char = "#"
folded_lines = False
testing = False
erase_setting = False
while settings[0][0] == "-" and settings[0] != "--":
opt = settings.pop(0)
if opt == "-s":
# Space is the delimiter
delimiter = " "
delimiter_re = r"\s+"
elif opt == "-e":
# Erase settings that have empty values.
erase_setting = True
elif opt == "-w":
# Line folding is possible in this file.
folded_lines = True
@ -140,4 +161,5 @@ if __name__ == "__main__":
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
sys.exit(1)
edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_lines, testing)
edit_conf(filename, settings, delimiter_re, delimiter, comment_char,
folded_lines, testing, erase_setting)

View file

@ -7,7 +7,6 @@ import sys
import html
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@ -24,9 +23,6 @@ env = load_environment()
# Process command line args.
subject = sys.argv[1]
# Administrator's email address.
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
# Read in STDIN.
content = sys.stdin.read().strip()
@ -40,25 +36,29 @@ msg = MIMEMultipart('alternative')
# In Python 3.6:
#msg = Message()
msg['From'] = "\"%s\" <%s>" % (env['PRIMARY_HOSTNAME'], admin_addr)
msg['To'] = admin_addr
noreply = "noreply-daemon@" + env['PRIMARY_HOSTNAME']
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon",
"noreply-daemon@" + env['PRIMARY_HOSTNAME'])
msg['To'] = "administrator@" + env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
content_html = "<html><body><pre>{}</pre></body></html>".format(html.escape(content))
content_html = "<html><body><pre>{}</pre></body></html>".format(
html.escape(content))
msg.attach(MIMEText(content, 'plain'))
msg.attach(MIMEText(create_signature(content.encode()).decode(), 'plain'))
msg.attach(MIMEText(content_html, 'html'))
msg.attach(MIMEApplication(create_signature(content.encode()), Name="signed.asc"))
# In Python 3.6:
#msg.set_content(content)
# msg.set_content(content)
#msg.add_alternative(content_html, "html")
# send
smtpclient = smtplib.SMTP('127.0.0.1', 25)
smtpclient.ehlo()
smtpclient.sendmail(
admin_addr, # MAIL FROM
admin_addr, # RCPT TO
msg.as_string())
noreply, # MAIL FROM
admin_addr, # RCPT TO
msg.as_string())
smtpclient.quit()

File diff suppressed because it is too large Load diff

View file

@ -9,15 +9,20 @@
# Python 3 in setup/questions.sh to validate the email
# address entered by the user.
import subprocess, shutil, os, sqlite3, re
import subprocess
import shutil
import os
import sqlite3
import re
import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
import idna
def validate_email(email, mode=None):
# Checks that an email address is syntactically valid. Returns True/False.
# Until Postfix supports SMTPUTF8, an email address may contain ASCII
# characters only; IDNs must be IDNA-encoded.
# An email address may contain ASCII characters only because Dovecot's
# authentication mechanism gets confused with other character encodings.
#
# When mode=="user", we're checking that this can be a user account name.
# Dovecot has tighter restrictions - letters, numbers, underscore, and
@ -29,10 +34,9 @@ def validate_email(email, mode=None):
# Check the syntax of the address.
try:
validate_email_(email,
allow_smtputf8=False,
check_deliverability=False,
allow_empty_local=(mode=="alias")
)
allow_smtputf8=False,
check_deliverability=False,
allow_empty_local=(mode == "alias"))
except EmailNotValidError:
return False
@ -45,13 +49,15 @@ def validate_email(email, mode=None):
# Our database is case sensitive (oops), which affects mail delivery
# (Postfix always queries in lowercase?), so also only permit lowercase
# letters.
if len(email) > 255: return False
if len(email) > 255:
return False
if re.search(r'[^\@\.a-z0-9_\-]+', email):
return False
# Everything looks good.
return True
def sanitize_idn_email_address(email):
# The user may enter Unicode in an email address. Convert the domain part
# to IDNA before going into our database. Leave the local part alone ---
@ -72,6 +78,7 @@ def sanitize_idn_email_address(email):
# validate_email.
return email
def prettify_idn_email_address(email):
# This is the opposite of sanitize_idn_email_address. We store domain
# names in IDNA in the database, but we want to show Unicode to the user.
@ -84,13 +91,17 @@ def prettify_idn_email_address(email):
# single @-sign. Should never happen.
return 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+"+"):
for localpart in ("admin", "administrator", "postmaster", "hostmaster",
"webmaster", "abuse"):
if email.startswith(localpart + "@") or email.startswith(localpart +
"+"):
return True
return False
def open_database(env, with_connection=False):
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
if not with_connection:
@ -98,13 +109,28 @@ def open_database(env, with_connection=False):
else:
return conn, conn.cursor()
def get_mail_users(env):
# Returns a flat, sorted list of all user accounts.
c = open_database(env)
c.execute('SELECT email FROM users')
users = [ row[0] for row in c.fetchall() ]
users = [row[0] for row in c.fetchall()]
return utils.sort_email_addresses(users, env)
def sizeof_fmt(num):
for unit in ['', 'K', 'M', 'G', 'T']:
if abs(num) < 1024.0:
if abs(num) > 99:
return "%3.0f%s" % (num, unit)
else:
return "%2.1f%s" % (num, unit)
num /= 1024.0
return str(num)
def get_mail_users_ex(env, with_archived=False):
# Returns a complex data structure of all user accounts, optionally
# including archived (status="inactive") accounts.
@ -128,13 +154,49 @@ def get_mail_users_ex(env, with_archived=False):
users = []
active_accounts = set()
c = open_database(env)
c.execute('SELECT email, privileges FROM users')
for email, privileges in c.fetchall():
c.execute('SELECT email, privileges, quota FROM users')
for email, privileges, quota in c.fetchall():
active_accounts.add(email)
(user, domain) = email.split('@')
box_size = 0
box_count = 0
box_quota = 0
percent = ''
try:
dirsize_file = os.path.join(
env['STORAGE_ROOT'],
'mail/mailboxes/%s/%s/maildirsize' % (domain, user))
with open(dirsize_file, 'r') as f:
box_quota = int(f.readline().split('S')[0])
for line in f.readlines():
(size, count) = line.split(' ')
box_size += int(size)
box_count += int(count)
try:
percent = (box_size / box_quota) * 100
except:
percent = 'Error'
except:
box_size = '?'
box_count = '?'
box_quota = '?'
percent = '?'
if quota == '0':
percent = ''
user = {
"email": email,
"privileges": parse_privs(privileges),
"quota": quota,
"box_quota": box_quota,
"box_size": sizeof_fmt(box_size) if box_size != '?' else box_size,
"percent":
'%3.0f%%' % percent if type(percent) != str else percent,
"box_count": box_count,
"status": "active",
}
users.append(user)
@ -147,35 +209,41 @@ def get_mail_users_ex(env, with_archived=False):
for user in os.listdir(os.path.join(root, domain)):
email = user + "@" + domain
mbox = os.path.join(root, domain, user)
if email in active_accounts: continue
if email in active_accounts:
continue
user = {
"email": email,
"privileges": [],
"status": "inactive",
"mailbox": mbox,
"box_count": '?',
"box_size": '?',
"box_quota": '?',
"percent": '?',
}
users.append(user)
# Group by domain.
domains = { }
domains = {}
for user in users:
domain = get_domain(user["email"])
if domain not in domains:
domains[domain] = {
"domain": domain,
"users": []
}
domains[domain] = {"domain": domain, "users": []}
domains[domain]["users"].append(user)
# Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
domains = [
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
]
# Sort users within each domain first by status then lexicographically by email address.
for domain in domains:
domain["users"].sort(key = lambda user : (user["status"] != "active", user["email"]))
domain["users"].sort(
key=lambda user: (user["status"] != "active", user["email"]))
return domains
def get_admins(env):
# Returns a set of users with admin privileges.
users = set()
@ -185,16 +253,23 @@ def get_admins(env):
users.add(user["email"])
return users
def get_mail_aliases(env):
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders).
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders, auto).
c = open_database(env)
c.execute('SELECT source, destination, permitted_senders FROM aliases')
aliases = { row[0]: row for row in c.fetchall() } # make dict
c.execute(
'SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases'
)
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) ]
aliases = [
aliases[address]
for address in utils.sort_email_addresses(aliases.keys(), env)
]
return aliases
def get_mail_aliases_ex(env):
# Returns a complex data structure of all mail aliases, similar
# to get_mail_users_ex.
@ -208,7 +283,7 @@ def get_mail_aliases_ex(env):
# address_display: "name@domain.tld", # full Unicode
# forwards_to: ["user1@domain.com", "receiver-only1@domain.com", ...],
# permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null,
# required: True|False
# auto: True|False
# },
# ...
# ]
@ -216,12 +291,14 @@ def get_mail_aliases_ex(env):
# ...
# ]
required_aliases = get_required_aliases(env)
domains = {}
for address, forwards_to, permitted_senders in get_mail_aliases(env):
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
# skip auto domain maps since these are not informative in the control panel's aliases list
if auto and address.startswith("@"):
continue
# get alias info
domain = get_domain(address)
required = (address in required_aliases)
# add to list
if not domain in domains:
@ -230,21 +307,43 @@ def get_mail_aliases_ex(env):
"aliases": [],
}
domains[domain]["aliases"].append({
"address": address,
"address_display": prettify_idn_email_address(address),
"forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(",")],
"permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None,
"required": required,
"address":
address,
"address_display":
prettify_idn_email_address(address),
"forwards_to": [
prettify_idn_email_address(r.strip())
for r in forwards_to.split(",")
],
"permitted_senders": [
prettify_idn_email_address(s.strip())
for s in permitted_senders.split(",")
] if permitted_senders is not None else None,
"auto":
bool(auto),
})
# Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
domains = [
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
]
# Sort aliases within each domain first by required-ness then lexicographically by address.
for domain in domains:
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["address"]))
domain["aliases"].sort(
key=lambda alias: (alias["auto"], alias["address"]))
return domains
def get_noreply_addresses(env):
# Returns a set of noreply addresses:
# Noreply addresses are a special type of addresses that are send-only.
# Mail sent to these addresses is automatically bounced with a customized message..
c = open_database(env)
c.execute('SELECT email FROM noreply')
return set(row[0] for row in c.fetchall())
def get_domain(emailaddr, as_unicode=True):
# Gets the domain part of an email address. Turns IDNA
# back to Unicode for display.
@ -258,29 +357,58 @@ def get_domain(emailaddr, as_unicode=True):
pass
return ret
def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
def get_all_mail_addresses(env, no_catchalls=True):
# Gets the email addresses on literally all tables (users, aliases and noreplies)
emails = get_mail_users(env)
emails.extend([a[0] for a in get_mail_aliases(env)])
emails.extend(get_noreply_addresses(env))
# Filter out catch-alls
if no_catchalls:
return set(filter(lambda e: e.split("@")[0].strip != "", emails))
return set(emails)
def get_mail_domains(env, filter_aliases=lambda alias: True, users_only=False):
# Returns the domain names (IDNA-encoded) of all of the email addresses
# configured on the system. If users_only is True, only return domains
# with email addresses that correspond to user accounts.
# with email addresses that correspond to user accounts. Exclude Unicode
# forms of domain names listed in the automatic aliases table.
domains = []
domains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])
domains.extend(
[get_domain(login, as_unicode=False) for login in get_mail_users(env)])
if not users_only:
domains.extend([get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ])
domains.extend([
get_domain(address, as_unicode=False)
for address, _, _, auto in get_mail_aliases(env)
if filter_aliases(address) and not auto
])
domains.extend([
get_domain(address, as_unicode=False)
for address in get_noreply_addresses(env)
])
return set(domains)
def add_mail_user(email, pw, privs, env):
def add_mail_user(email, pw, privs, quota, env):
# validate email
if email.strip() == "":
return ("No email address provided.", 400)
elif not validate_email(email):
return ("Invalid email address.", 400)
elif not validate_email(email, mode='user'):
return ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
return (
"User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).",
400)
elif is_dcv_address(email) and len(get_mail_users(env)) > 0:
# Make domain control validation hijacking a little harder to mess up by preventing the usual
# addresses used for DCV from being user accounts. Except let it be the first account because
# during box setup the user won't know the rules.
return ("You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.", 400)
return (
"You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.",
400)
# validate password
validate_password(pw)
@ -292,7 +420,16 @@ def add_mail_user(email, pw, privs, env):
privs = privs.split("\n")
for p in privs:
validation = validate_privilege(p)
if validation: return validation
if validation:
return validation
if quota is None:
quota = get_default_quota()
try:
quota = validate_quota(quota)
except ValueError as e:
return (str(e), 400)
# get the database
conn, c = open_database(env, with_connection=True)
@ -302,17 +439,21 @@ def add_mail_user(email, pw, privs, env):
# add the user to the database
try:
c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)",
(email, pw, "\n".join(privs)))
c.execute(
"INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
(email, pw, "\n".join(privs), quota))
except sqlite3.IntegrityError:
return ("User already exists.", 400)
# write databasebefore next step
conn.commit()
dovecot_quota_recalc(email)
# Update things in case any new domains are added.
return kick(env, "mail user added")
def set_mail_password(email, pw, env):
# validate that password is acceptable
validate_password(pw)
@ -328,11 +469,72 @@ def set_mail_password(email, pw, env):
conn.commit()
return "OK"
def hash_password(pw):
# Turn the plain password into a Dovecot-format hashed password, meaning
# something like "{SCHEME}hashedpassworddata".
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
return utils.shell(
'check_output',
["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
def get_mail_quota(email, env):
conn, c = open_database(env, with_connection=True)
c.execute("SELECT quota FROM users WHERE email=?", (email, ))
rows = c.fetchall()
if len(rows) != 1:
return ("That's not a user (%s)." % email, 400)
return rows[0][0]
def set_mail_quota(email, quota, env):
# validate that password is acceptable
quota = validate_quota(quota)
# update the database
conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET quota=? WHERE email=?", (quota, email))
if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400)
conn.commit()
dovecot_quota_recalc(email)
return "OK"
def dovecot_quota_recalc(email):
# dovecot processes running for the user will not recognize the new quota setting
# a reload is necessary to reread the quota setting, but it will also shut down
# running dovecot processes. Email clients generally log back in when they lose
# a connection.
# subprocess.call(['doveadm', 'reload'])
# force dovecot to recalculate the quota info for the user.
subprocess.call(["doveadm", "quota", "recalc", "-u", email])
def get_default_quota(env):
config = utils.load_settings(env)
return config.get("default-quota", '0')
def validate_quota(quota):
# validate quota
quota = quota.strip().upper()
if quota == "":
raise ValueError("No quota provided.")
if re.search(r"[\s,.]", quota):
raise ValueError(
"Quotas cannot contain spaces, commas, or decimal points.")
if not re.match(r'^[\d]+[GM]?$', quota):
raise ValueError("Invalid quota.")
return quota
def get_mail_password(email, env):
# Gets the hashed password for a user. Passwords are stored in Dovecot's
@ -340,16 +542,17 @@ def get_mail_password(email, env):
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
# update the database
c = open_database(env)
c.execute('SELECT password FROM users WHERE email=?', (email,))
c.execute('SELECT password FROM users WHERE email=?', (email, ))
rows = c.fetchall()
if len(rows) != 1:
raise ValueError("That's not a user (%s)." % email)
return rows[0][0]
def remove_mail_user(email, env):
# remove
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM users WHERE email=?", (email,))
c.execute("DELETE FROM users WHERE email=?", (email, ))
if c.rowcount != 1:
return ("That's not a user (%s)." % email, 400)
conn.commit()
@ -357,32 +560,39 @@ def remove_mail_user(email, env):
# Update things in case any domains are removed.
return kick(env, "mail user removed")
def parse_privs(value):
return [p for p in value.split("\n") if p.strip() != ""]
def get_mail_user_privileges(email, env, empty_on_error=False):
# get privs
c = open_database(env)
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
c.execute('SELECT privileges FROM users WHERE email=?', (email, ))
rows = c.fetchall()
if len(rows) != 1:
if empty_on_error: return []
if empty_on_error:
return []
return ("That's not a user (%s)." % email, 400)
return parse_privs(rows[0][0])
def validate_privilege(priv):
if "\n" in priv or priv.strip() == "":
return ("That's not a valid privilege (%s)." % priv, 400)
return None
def add_remove_mail_user_privilege(email, priv, action, env):
# validate
validation = validate_privilege(priv)
if validation: return validation
if validation:
return validation
# get existing privs, but may fail
privs = get_mail_user_privileges(email, env)
if isinstance(privs, tuple): return privs # error
if isinstance(privs, tuple):
return privs # error
# update privs set
if action == "add":
@ -395,14 +605,21 @@ def add_remove_mail_user_privilege(email, priv, action, env):
# commit to database
conn, c = open_database(env, with_connection=True)
c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email))
c.execute("UPDATE users SET privileges=? WHERE email=?",
("\n".join(privs), email))
if c.rowcount != 1:
return ("Something went wrong.", 400)
conn.commit()
return "OK"
def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True):
def add_mail_alias(address,
forwards_to,
permitted_senders,
env,
update_if_exists=False,
do_kick=True):
# convert Unicode domain to IDNA
address = sanitize_idn_email_address(address)
@ -439,17 +656,23 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
for line in forwards_to.split("\n"):
for email in line.split(","):
email = email.strip()
if email == "": continue
email = sanitize_idn_email_address(email) # Unicode => IDNA
if email == "":
continue
email = sanitize_idn_email_address(email) # Unicode => IDNA
# Strip any +tag from email alias and check privileges
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',email)
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)", '', email)
if not validate_email(email):
return ("Invalid receiver email address (%s)." % email, 400)
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True):
return ("Invalid receiver email address (%s)." % email,
400)
if is_dcv_source and not is_dcv_address(
email) and "admin" not in get_mail_user_privileges(
privileged_email, env, empty_on_error=True):
# Make domain control validation hijacking a little harder to mess up by
# requiring aliases for email addresses typically used in DCV to forward
# only to accounts that are administrators on this system.
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
return (
"This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.",
400)
validated_forwards_to.append(email)
# validate permitted_senders
@ -462,14 +685,19 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
for line in permitted_senders.split("\n"):
for login in line.split(","):
login = login.strip()
if login == "": continue
if login == "":
continue
if login not in valid_logins:
return ("Invalid permitted sender: %s is not a user on this system." % login, 400)
return (
"Invalid permitted sender: %s is not a user on this system."
% login, 400)
validated_permitted_senders.append(login)
# Make sure the alias has either a forwards_to or a permitted_sender.
if len(validated_forwards_to) + len(validated_permitted_senders) == 0:
return ("The alias must either forward to an address or have a permitted sender.", 400)
return (
"The alias must either forward to an address or have a permitted sender.",
400)
# save to db
@ -482,13 +710,17 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
conn, c = open_database(env, with_connection=True)
try:
c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders))
c.execute(
"INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)",
(address, forwards_to, permitted_senders))
return_status = "alias added"
except sqlite3.IntegrityError:
if not update_if_exists:
return ("Alias already exists (%s)." % address, 400)
else:
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
c.execute(
"UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?",
(forwards_to, permitted_senders, address))
return_status = "alias updated"
conn.commit()
@ -497,13 +729,14 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
# Update things in case any new domains are added.
return kick(env, return_status)
def remove_mail_alias(address, env, do_kick=True):
# convert Unicode domain to IDNA
address = sanitize_idn_email_address(address)
# remove
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (address,))
c.execute("DELETE FROM aliases WHERE source=?", (address, ))
if c.rowcount != 1:
return ("That's not an alias (%s)." % address, 400)
conn.commit()
@ -512,9 +745,21 @@ def remove_mail_alias(address, env, do_kick=True):
# Update things in case any domains are removed.
return kick(env, "alias removed")
def add_auto_aliases(aliases, env):
conn, c = open_database(env, with_connection=True)
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()
def get_system_administrator(env):
return "administrator@" + env['PRIMARY_HOSTNAME']
def get_required_aliases(env):
# These are the aliases that must exist.
aliases = set()
@ -527,13 +772,11 @@ def get_required_aliases(env):
# Get a list of domains we serve mail for, except ones for which the only
# email on that domain are the required aliases or a catch-all/domain-forwarder.
real_mail_domains = get_mail_domains(env,
filter_aliases = lambda alias :
not alias.startswith("postmaster@")
and not alias.startswith("admin@")
and not alias.startswith("abuse@")
and not alias.startswith("@")
)
real_mail_domains = get_mail_domains(
env,
filter_aliases=lambda alias: not alias.startswith("postmaster@") and
not alias.startswith("admin@") and not alias.startswith(
"abuse@") and not alias.startswith("@"))
# Create postmaster@, admin@ and abuse@ for all domains we serve
# mail on. postmaster@ is assumed to exist by our Postfix configuration.
@ -547,6 +790,57 @@ def get_required_aliases(env):
return aliases
def add_noreply_address(env, address, do_kick=True):
email = sanitize_idn_email_address(address)
# validate email
if email.strip() == "":
return ("No email address provided.", 400)
elif not validate_email(email):
return ("Invalid email address.", 400)
# Make sure it's not an user/alias already
if email in get_mail_users(env):
return ("This address is already an user.", 400)
elif email in get_mail_aliases(env):
return ("This address is already an alias.", 400)
ret = ""
# Add the address
conn, c = open_database(env, with_connection=True)
try:
c.execute("INSERT INTO noreply (email) VALUES (?)", (email, ))
if do_kick:
ret = kick(env, "No-reply address (%s) added" % address)
else:
ret = "No-reply address (%s) added" % address
except sqlite3.IntegrityError:
return ("This noreply (%s) already exists." % address, 400)
conn.commit()
return ret
def remove_noreply_address(env, address, do_kick=True):
email = sanitize_idn_email_address(address)
# yeet yeet deleet
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM noreply WHERE email=?", (email, ))
if c.rowcount != 1:
return ("That's not a noreply (%s)." % address, 400)
conn.commit()
if do_kick:
# Update things in case any domains are removed.
return kick(env, "No-reply address removed")
def get_required_noreply_addresses(env):
return {"noreply-daemon@" + env['PRIMARY_HOSTNAME']}
def kick(env, mail_result=None):
results = []
@ -555,58 +849,61 @@ def kick(env, mail_result=None):
if mail_result is not None:
results.append(mail_result + "\n")
# Ensure every required alias exists.
auto_aliases = {}
existing_users = get_mail_users(env)
existing_alias_records = get_mail_aliases(env)
existing_aliases = set(a for a, *_ in existing_alias_records) # just first entry in tuple
# Mape required aliases to the administrator alias (which should be created manually).
administrator = get_system_administrator(env)
required_aliases = get_required_aliases(env)
required_noreply = get_required_noreply_addresses(env)
existing_noreply = get_noreply_addresses(env)
for alias in required_aliases:
if alias == administrator:
continue # don't make an alias from the administrator to itself --- this alias must be created manually
auto_aliases[alias] = administrator
def ensure_admin_alias_exists(address):
# If a user account exists with that address, we're good.
if address in existing_users:
return
# Add domain maps from Unicode forms of IDNA domains to the ASCII forms stored in the alias table.
for domain in get_mail_domains(env):
try:
domain_unicode = idna.decode(domain.encode("ascii"))
if domain == domain_unicode:
continue # not an IDNA/Unicode domain
auto_aliases["@" + domain_unicode] = "@" + domain
except (ValueError, UnicodeError, idna.IDNAError):
continue
# If the alias already exists, we're good.
if address in existing_aliases:
return
add_auto_aliases(auto_aliases, env)
for address in required_noreply - existing_noreply:
add_noreply_address(env, address, do_kick=False)
results.append("added noreply address \"%s\"\n" % address)
# Doesn't exist.
administrator = get_system_administrator(env)
if address == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually
add_mail_alias(address, administrator, "", env, do_kick=False)
if administrator not in existing_aliases: return # don't report the alias in output if the administrator alias isn't in yet -- this is a hack to supress confusing output on initial setup
results.append("added alias %s (=> %s)\n" % (address, administrator))
for address in required_aliases:
ensure_admin_alias_exists(address)
# Remove auto-generated postmaster/admin on domains we no
# longer have any other email addresses for.
for address, forwards_to, *_ in existing_alias_records:
# 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):
user, domain = address.split("@")
if user in ("postmaster", "admin", "abuse") \
and address not in required_aliases \
and forwards_to == get_system_administrator(env):
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(
"removed alias %s (was to %s; domain no longer used for email)\n"
% (address, forwards_to))
# Update DNS and nginx in case any domains are added/removed.
from dns_update import do_dns_update
results.append( do_dns_update(env) )
results.append(do_dns_update(env))
from web_update import do_web_update
results.append( do_web_update(env) )
results.append(do_web_update(env))
return "".join(s for s in results if s != "")
def validate_password(pw):
# validate password
if pw.strip() == "":
raise ValueError("No password provided.")
if re.search(r"[\s]", pw):
raise ValueError("Passwords cannot contain spaces.")
if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.")

158
management/mfa.py Normal file
View file

@ -0,0 +1,158 @@
import base64
import hmac
import io
import os
import pyotp
import qrcode
from mailconfig import open_database
def get_user_id(email, c):
c.execute('SELECT id FROM users WHERE email=?', (email, ))
r = c.fetchone()
if not r:
raise ValueError("User does not exist.")
return r[0]
def get_mfa_state(email, env):
c = open_database(env)
c.execute(
'SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?',
(get_user_id(email, c), ))
return [{
"id": r[0],
"type": r[1],
"secret": r[2],
"mru_token": r[3],
"label": r[4]
} for r in c.fetchall()]
def get_public_mfa_state(email, env):
mfa_state = get_mfa_state(email, env)
return [{
"id": s["id"],
"type": s["type"],
"label": s["label"]
} for s in mfa_state]
def get_hash_mfa_state(email, env):
mfa_state = get_mfa_state(email, env)
return [{
"id": s["id"],
"type": s["type"],
"secret": s["secret"]
} for s in mfa_state]
def enable_mfa(email, type, secret, token, label, env):
if type == "totp":
validate_totp_secret(secret)
# Sanity check with the provide current token.
totp = pyotp.TOTP(secret)
if not totp.verify(token, valid_window=1):
raise ValueError("Invalid token.")
else:
raise ValueError("Invalid MFA type.")
conn, c = open_database(env, with_connection=True)
c.execute(
'INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)',
(get_user_id(email, c), type, secret, label))
conn.commit()
def set_mru_token(email, mfa_id, token, env):
conn, c = open_database(env, with_connection=True)
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?',
(token, get_user_id(email, c), mfa_id))
conn.commit()
def disable_mfa(email, mfa_id, env):
conn, c = open_database(env, with_connection=True)
if mfa_id is None:
# Disable all MFA for a user.
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c), ))
else:
# Disable a particular MFA mode for a user.
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?',
(get_user_id(email, c), mfa_id))
conn.commit()
return c.rowcount > 0
def validate_totp_secret(secret):
if type(secret) != str or secret.strip() == "":
raise ValueError("No secret provided.")
if len(secret) != 32:
raise ValueError("Secret should be a 32 characters base32 string")
def provision_totp(email, env):
# Make a new secret.
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
validate_totp_secret(secret) # sanity check
# Make a URI that we encode within a QR code.
uri = pyotp.TOTP(secret).provisioning_uri(
name=email,
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel")
# Generate a QR code as a base64-encode PNG image.
qr = qrcode.make(uri)
byte_arr = io.BytesIO()
qr.save(byte_arr, format='PNG')
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
return {"type": "totp", "secret": secret, "qr_code_base64": png_b64}
def validate_auth_mfa(email, request, env):
# Validates that a login request satisfies any MFA modes
# that have been enabled for the user's account. Returns
# a tuple (status, [hints]). status is True for a successful
# MFA login, False for a missing token. If status is False,
# hints is an array of codes that indicate what the user
# can try. Possible codes are:
# "missing-totp-token"
# "invalid-totp-token"
mfa_state = get_mfa_state(email, env)
# If no MFA modes are added, return True.
if len(mfa_state) == 0:
return (True, [])
# Try the enabled MFA modes.
hints = set()
for mfa_mode in mfa_state:
if mfa_mode["type"] == "totp":
# Check that a token is present in the X-Auth-Token header.
# If not, give a hint that one can be supplied.
token = request.headers.get('x-auth-token')
if not token:
hints.add("missing-totp-token")
continue
# Check for a replay attack.
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
# If the token fails, skip this MFA mode.
hints.add("invalid-totp-token")
continue
# Check the token.
totp = pyotp.TOTP(mfa_mode["secret"])
if not totp.verify(token, valid_window=1):
hints.add("invalid-totp-token")
continue
# On success, record the token to prevent a replay attack.
set_mru_token(email, mfa_mode['id'], token, env)
return (True, [])
# On a failed login, indicate failure and any hints for what the user can do instead.
return (False, list(hints))

View file

@ -1,125 +1,178 @@
#!/usr/local/lib/mailinabox/env/bin/python
# Tools to manipulate PGP keys
import gpg, utils, datetime
import gpg
import utils
import datetime
import shutil
import tempfile
env = utils.load_environment()
# Import daemon's keyring - usually in /home/user-data/.gnupg/
gpghome = env['GNUPGHOME']
daemon_key_fpr = env['PGPKEY']
context = gpg.Context(armor=True, home_dir=gpghome)
# Global auxiliary lookup tables
crpyt_algos = {
0: "Unknown",
gpg.constants.PK_RSA: "RSA",
gpg.constants.PK_RSA_E: "RSA-E",
gpg.constants.PK_RSA_S: "RSA-S",
gpg.constants.PK_ELG_E: "ELG-E",
gpg.constants.PK_DSA: "DSA",
gpg.constants.PK_ECC: "ECC",
gpg.constants.PK_ELG: "ELG",
gpg.constants.PK_ECDSA: "ECDSA",
gpg.constants.PK_ECDH: "ECDH",
gpg.constants.PK_EDDSA: "EDDSA"
}
default_context = gpg.Context(armor=True, home_dir=gpghome)
# Auxiliary function to process the key in order to be read more conveniently
def key_representation(key):
if key is None:
return None
key_rep = {
"master_fpr": key.fpr,
"revoked": key.revoked != 0,
"ids": [],
"subkeys": []
}
if key is None:
return None
key_rep = {
"master_fpr": key.fpr,
"revoked": key.revoked != 0,
"ids": [],
"ids_emails": {},
"subkeys": []
}
now = datetime.datetime.utcnow()
key_rep["ids"] = [ id.uid for id in key.uids ]
key_rep["subkeys"] = [{
"master": skey.fpr == key.fpr,
"sign": skey.can_sign == 1,
"cert": skey.can_certify == 1,
"encr": skey.can_encrypt == 1,
"auth": skey.can_authenticate == 1,
"fpr": skey.fpr,
"expires": skey.expires if skey.expires != 0 else None,
"expires_date": datetime.datetime.utcfromtimestamp(skey.expires).strftime("%x") if skey.expires != 0 else None,
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) - now).days if skey.expires != 0 else None,
"expired": skey.expired == 1,
"algorithm": crpyt_algos[skey.pubkey_algo] if skey.pubkey_algo in crpyt_algos.keys() else crpyt_algos[0],
"bits": skey.length
} for skey in key.subkeys ]
now = datetime.datetime.utcnow()
key_rep["ids"] = [id.uid for id in key.uids]
# No duplicate email addresses in this list
key_rep["ids_emails"] = list({id.email for id in key.uids})
key_rep["subkeys"] = [{
"master":
skey.fpr == key.fpr,
"sign":
skey.can_sign == 1,
"cert":
skey.can_certify == 1,
"encr":
skey.can_encrypt == 1,
"auth":
skey.can_authenticate == 1,
"fpr":
skey.fpr,
"expires":
skey.expires if skey.expires != 0 else None,
"expires_date":
datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat()
if skey.expires != 0 else None,
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) -
now).days if skey.expires != 0 else None,
"expired":
skey.expired == 1,
"algorithm":
gpg.core.pubkey_algo_name(skey.pubkey_algo),
"bits":
skey.length
} for skey in key.subkeys]
return key_rep
return key_rep
# Tests an import as for whether we have any sort of private key material in our import
def contains_private_keys(imports):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
result = tmp.key_import(imports)
return result.secret_read != 0
with tempfile.TemporaryDirectory() as tmpdir:
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
result = tmp.key_import(imports)
try:
return result.secret_read != 0
except AttributeError:
raise ValueError("Import is not a valid PGP key block!")
def get_key(fingerprint):
try:
return context.get_key(fingerprint, secret=False)
except KeyError:
return None
def get_daemon_key():
if daemon_key_fpr is None or daemon_key_fpr == "":
return None
return context.get_key(daemon_key_fpr, secret=True)
# Decorator: Copies the homedir of a context onto a temporary directory and returns a context operating over that tmpdir
def get_imported_keys():
# All the keys in the keyring, except for the daemon's key
return list(
filter(
lambda k: k.fpr != daemon_key_fpr,
context.keylist(secret=False)
)
)
def import_key(key):
data = str.encode(key)
if contains_private_keys(data):
raise ValueError("Import cannot contain private keys!")
return context.key_import(data)
def fork_context(f, context=default_context):
from os.path import isdir, isfile
def export_key(fingerprint):
if get_key(fingerprint) is None:
return None
return context.key_export(pattern=fingerprint) # Key does exist, export it!
def dirs_files_only(current_dir, files):
ignore = []
for f in files:
path = f"{current_dir}{f}"
if not isdir(path) and not isfile(path):
ignore.append(f)
return ignore
def wrapped(*args, **kwargs):
with tempfile.TemporaryDirectory() as tmpdir:
shutil.copytree(context.home_dir,
f"{tmpdir}/gnupg",
ignore=dirs_files_only)
kwargs["context"] = gpg.Context(armor=context.armor,
home_dir=f"{tmpdir}/gnupg")
kwargs["buffer"] = gpg.Data()
return f(*args, **kwargs)
return wrapped
def get_key(fingerprint, context=default_context):
try:
return context.get_key(fingerprint, secret=False)
except KeyError:
return None
except gpg.errors.GPGMEError:
return None
def get_daemon_key(context=default_context):
if daemon_key_fpr is None or daemon_key_fpr == "":
return None
return context.get_key(daemon_key_fpr, secret=True)
def get_imported_keys(context=default_context):
# All the keys in the keyring, except for the daemon's key
return list(
filter(lambda k: k.fpr != daemon_key_fpr,
context.keylist(secret=False)))
def import_key(key, context=default_context):
data = str.encode(key)
if contains_private_keys(data):
raise ValueError("Import cannot contain private keys!")
return context.key_import(data)
def export_key(fingerprint, context=default_context):
if get_key(fingerprint) is None:
return None
# Key does exist, export it!
return context.key_export(pattern=fingerprint)
def delete_key(fingerprint, context=default_context):
key = get_key(fingerprint)
if fingerprint == daemon_key_fpr:
raise ValueError("You cannot delete the daemon's key!")
elif key is None:
return None
context.op_delete_ext(
key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
return True
def delete_key(fingerprint):
key = get_key(fingerprint)
if fingerprint == daemon_key_fpr:
raise ValueError("You cannot delete the daemon's key!")
elif key is None:
return None
context.op_delete_ext(key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
return True
# Key usage
# Uses the daemon key to sign the provided message. If 'detached' is True, only the signature will be returned
def create_signature(data, detached=False):
signed_data, _ = context.sign(data, mode=gpg.constants.SIG_MODE_DETACH if detached else gpg.constants.SIG_MODE_CLEAR)
return signed_data
def create_signature(data, detached=False, context=default_context):
signed_data, _ = context.sign(data,
mode=gpg.constants.sig.mode.DETACH if
detached else gpg.constants.sig.mode.CLEAR)
return signed_data
if __name__ == "__main__":
import sys, utils
# Check if we should renew the key
daemon_key = get_daemon_key()
exp = daemon_key.subkeys[0].expires
now = datetime.datetime.utcnow()
days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days
if days_left > 14:
sys.exit(0)
else:
utils.shell("check_output", ["management/pgp_renew.sh"])
import sys
import utils
# Check if we should renew the key
daemon_key = get_daemon_key()
exp = daemon_key.subkeys[0].expires
now = datetime.datetime.utcnow()
days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days
if days_left > 14:
sys.exit(0)
else:
utils.shell("check_output", ["management/pgp_renew.sh"])

View file

@ -1,13 +1,19 @@
#!/usr/local/lib/mailinabox/env/bin/python
# Utilities for installing and selecting SSL certificates.
import os, os.path, re, shutil, subprocess, tempfile
import os
import os.path
import re
import shutil
import subprocess
import tempfile
from utils import shell, safe_domain_name, sort_domains
import idna
# SELECTING SSL CERTIFICATES FOR USE IN WEB
def get_ssl_certificates(env):
# Scan all of the installed SSL certificates and map every domain
# that the certificates are good for to the best certificate for
@ -44,8 +50,8 @@ def get_ssl_certificates(env):
yield fn1
# Remember stuff.
private_keys = { }
certificates = [ ]
private_keys = {}
certificates = []
# Scan each of the files to find private keys and certificates.
# We must load all of the private keys first before processing
@ -58,36 +64,33 @@ def get_ssl_certificates(env):
# Not a valid PEM format for a PEM type we care about.
continue
# Remember where we got this object.
pem._filename = fn
# Is it a private key?
if isinstance(pem, RSAPrivateKey):
private_keys[pem.public_key().public_numbers()] = pem
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
# Is it a certificate?
if isinstance(pem, Certificate):
certificates.append(pem)
certificates.append({ "filename": fn, "cert": pem })
# Process the certificates.
domains = { }
domains = {}
for cert in certificates:
# What domains is this certificate good for?
cert_domains, primary_domain = get_certificate_domains(cert)
cert._primary_domain = primary_domain
cert_domains, primary_domain = get_certificate_domains(cert["cert"])
cert["primary_domain"] = primary_domain
# Is there a private key file for this certificate?
private_key = private_keys.get(cert.public_key().public_numbers())
private_key = private_keys.get(cert["cert"].public_key().public_numbers())
if not private_key:
continue
cert._private_key = private_key
cert["private_key"] = private_key
# Add this cert to the list of certs usable for the domains.
for domain in cert_domains:
# The primary hostname can only use a certificate mapped
# to the system private key.
if domain == env['PRIMARY_HOSTNAME']:
if cert._private_key._filename != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
if cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
continue
domains.setdefault(domain, []).append(cert)
@ -95,62 +98,70 @@ def get_ssl_certificates(env):
# Sort the certificates to prefer good ones.
import datetime
now = datetime.datetime.utcnow()
ret = { }
ret = {}
for domain, cert_list in domains.items():
#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename)
cert_list.sort(key = lambda cert : (
# must be valid NOW
cert.not_valid_before <= now <= cert.not_valid_after,
cert["cert"].not_valid_before <= now <= cert["cert"].not_valid_after,
# prefer one that is not self-signed
cert.issuer != cert.subject,
cert["cert"].issuer != cert["cert"].subject,
###########################################################
# The above lines ensure that valid certificates are chosen
# over invalid certificates. The lines below choose between
# multiple valid certificates available for this domain.
###########################################################
###########################################################
# The above lines ensure that valid certificates are chosen
# over invalid certificates. The lines below choose between
# multiple valid certificates available for this domain.
###########################################################
# prefer one with the expiration furthest into the future so
# that we can easily rotate to new certs as we get them
cert.not_valid_after,
cert["cert"].not_valid_after,
###########################################################
# We always choose the certificate that is good for the
# longest period of time. This is important for how we
# provision certificates for Let's Encrypt. To ensure that
# we don't re-provision every night, we have to ensure that
# if we choose to provison a certificate that it will
# *actually* be used so the provisioning logic knows it
# doesn't still need to provision a certificate for the
# domain.
###########################################################
###########################################################
# We always choose the certificate that is good for the
# longest period of time. This is important for how we
# provision certificates for Let's Encrypt. To ensure that
# we don't re-provision every night, we have to ensure that
# if we choose to provison a certificate that it will
# *actually* be used so the provisioning logic knows it
# doesn't still need to provision a certificate for the
# domain.
###########################################################
# in case a certificate is installed in multiple paths,
# prefer the... lexicographically last one?
cert._filename,
cert["filename"],
), reverse=True)
cert = cert_list.pop(0)
ret[domain] = {
"private-key": cert._private_key._filename,
"certificate": cert._filename,
"primary-domain": cert._primary_domain,
"certificate_object": cert,
"private-key": cert["private_key"]["filename"],
"certificate": cert["filename"],
"primary-domain": cert["primary_domain"],
"certificate_object": cert["cert"],
}
return ret
def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False, use_main_cert=True):
def get_domain_ssl_files(domain,
ssl_certificates,
env,
allow_missing_cert=False,
use_main_cert=True):
if use_main_cert or not allow_missing_cert:
# Get the system certificate info.
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
ssl_private_key = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
ssl_certificate = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
system_certificate = {
"private-key": ssl_private_key,
"certificate": ssl_certificate,
"primary-domain": env['PRIMARY_HOSTNAME'],
"certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]),
"certificate_object":
load_pem(load_cert_chain(ssl_certificate)[0]),
}
if use_main_cert:
@ -174,7 +185,10 @@ def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True):
def get_certificates_to_provision(env,
limit_domains=None,
show_valid_certs=True):
# Get a set of domain names that we can provision certificates for
# using certbot. We start with domains that the box is serving web
# for and subtract:
@ -192,7 +206,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
actual_web_domains = get_web_domains(env)
domains_to_provision = set()
domains_cant_provision = { }
domains_cant_provision = {}
for domain in plausible_web_domains:
# Skip domains that the user doesn't want to provision now.
@ -201,7 +215,8 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# Check that there isn't an explicit A/AAAA record.
if domain not in actual_web_domains:
domains_cant_provision[domain] = "The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here)."
domains_cant_provision[
domain] = "The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here)."
# Check that the DNS resolves to here.
else:
@ -211,41 +226,53 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# make sure both IPv4 and IPv6 are correct because we don't know
# how Let's Encrypt will connect.
bad_dns = []
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
if not value: continue # IPv6 is not configured
for rtype, value in [("A", env["PUBLIC_IP"]),
("AAAA", env.get("PUBLIC_IPV6"))]:
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))
if bad_dns:
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
+ (", ".join(bad_dns)) \
+ "."
+ (", ".join(bad_dns)) \
+ "."
else:
# DNS is all good.
# Check for a good existing cert.
existing_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
existing_cert = get_domain_ssl_files(domain,
existing_certs,
env,
use_main_cert=False,
allow_missing_cert=True)
if existing_cert:
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],
existing_cert_check = check_certificate(
domain,
existing_cert['certificate'],
existing_cert['private-key'],
warn_if_expiring_soon=14)
if existing_cert_check[0] == "OK":
if show_valid_certs:
domains_cant_provision[domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
existing_cert_check[1],
existing_cert['certificate'],
existing_cert['private-key'])
domains_cant_provision[
domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
existing_cert_check[1],
existing_cert['certificate'],
existing_cert['private-key'])
continue
domains_to_provision.add(domain)
return (domains_to_provision, domains_cant_provision)
def provision_certificates(env, limit_domains):
# What domains should we provision certificates for? And what
# errors prevent provisioning for other domains.
domains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)
domains, domains_cant_provision = get_certificates_to_provision(
env, limit_domains=limit_domains)
# Build a list of what happened on each domain or domain-set.
ret = []
@ -267,7 +294,7 @@ def provision_certificates(env, limit_domains):
# entry in each list (unless we overflow beyond 100) which ends up as the
# primary domain listed in each certificate.
from dns_update import get_dns_zones
certs = { }
certs = {}
for zone, zonefile in get_dns_zones(env):
certs[zone] = [[]]
for domain in sort_domains(domains, env):
@ -308,7 +335,8 @@ def provision_certificates(env, limit_domains):
try:
# Create a CSR file for our master private key so that certbot
# uses our private key.
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl',
'ssl_private_key.pem')
with tempfile.NamedTemporaryFile() as csr_file:
# We could use openssl, but certbot requires
# that the CN domain and SAN domains match
@ -326,12 +354,18 @@ def provision_certificates(env, limit_domains):
from cryptography.hazmat.primitives import hashes
from cryptography.x509.oid import NameOID
builder = x509.CertificateSigningRequestBuilder()
builder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ]))
builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
builder = builder.subject_name(
x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0])
]))
builder = builder.add_extension(x509.BasicConstraints(
ca=False, path_length=None),
critical=True)
builder = builder.add_extension(x509.SubjectAlternativeName(
[x509.DNSName(d) for d in domain_list]
), critical=False)
request = builder.sign(load_pem(load_cert_chain(key_file)[0]), hashes.SHA256(), default_backend())
[x509.DNSName(d) for d in domain_list]),
critical=False)
request = builder.sign(load_pem(load_cert_chain(key_file)[0]),
hashes.SHA256(), default_backend())
with open(csr_file.name, "wb") as f:
f.write(request.public_bytes(Encoding.PEM))
@ -340,27 +374,40 @@ def provision_certificates(env, limit_domains):
os.makedirs(webroot, exist_ok=True)
with tempfile.TemporaryDirectory() as d:
cert_file = os.path.join(d, 'cert_and_chain.pem')
print("Provisioning TLS certificates for " + ", ".join(domain_list) + ".")
certbotret = subprocess.check_output([
"certbot",
"certonly",
#"-v", # just enough to see ACME errors
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
"--agree-tos", # Automatically agrees to Let's Encrypt TOS
"--register-unsafely-without-email", # The daemon takes care of renewals
print("Provisioning TLS certificates for " +
", ".join(domain_list) + ".")
certbotret = subprocess.check_output(
[
"certbot",
"certonly",
# "-v", # just enough to see ACME errors
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
"--agree-tos", # Automatically agrees to Let's Encrypt TOS
"--register-unsafely-without-email", # The daemon takes care of renewals
"-d", ",".join(domain_list), # first will be main domain
# first will be main domain
"-d",
",".join(domain_list),
"--csr", csr_file.name, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
"--cert-path", os.path.join(d, 'cert'), # we only use the full chain
"--chain-path", os.path.join(d, 'chain'), # we only use the full chain
"--fullchain-path", cert_file,
"--webroot", "--webroot-path", webroot,
"--config-dir", account_path,
#"--staging",
], stderr=subprocess.STDOUT).decode("utf8")
# use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
"--csr",
csr_file.name,
# we only use the full chain
"--cert-path",
os.path.join(d, 'cert'),
# we only use the full chain
"--chain-path",
os.path.join(d, 'chain'),
"--fullchain-path",
cert_file,
"--webroot",
"--webroot-path",
webroot,
"--config-dir",
account_path,
# "--staging",
],
stderr=subprocess.STDOUT).decode("utf8")
install_cert_copy_file(cert_file, env)
ret[-1]["log"].append(certbotret)
@ -378,6 +425,7 @@ def provision_certificates(env, limit_domains):
# Return what happened with each certificate request.
return ret
def provision_certificates_cmdline():
import sys
from exclusiveprocess import Lock
@ -414,12 +462,13 @@ def provision_certificates_cmdline():
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
def create_csr(domain, ssl_key, country_code, env):
return shell("check_output", [
"openssl", "req", "-new",
"-key", ssl_key,
"-sha256",
"-subj", "/C=%s/CN=%s" % (country_code, domain)])
"openssl", "req", "-new", "-key", ssl_key, "-sha256", "-subj",
"/C=%s/CN=%s" % (country_code, 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.
@ -430,8 +479,10 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
os.close(fd)
# Do validation on the certificate before installing it.
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)
ssl_private_key = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
cert_status, cert_status_details = check_certificate(
domain, fn, ssl_private_key)
if cert_status != "OK":
if cert_status == "SELF-SIGNED":
cert_status = "This is a self-signed certificate. I can't install that."
@ -445,7 +496,8 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
# Run post-install steps.
ret = post_install_func(env)
if raw: return ret
if raw:
return ret
return "\n".join(ret)
@ -457,11 +509,15 @@ def install_cert_copy_file(fn, env):
cert = load_pem(load_cert_chain(fn)[0])
all_domains, cn = get_certificate_domains(cert)
path = "%s-%s-%s.pem" % (
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
)
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path))
# 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
safe_domain_name(cn),
cert.not_valid_after.date().isoformat().replace("-",
""), # expiration date
hexlify(cert.fingerprint(
hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
)
ssl_certificate = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', path))
# Install the certificate.
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
@ -473,16 +529,21 @@ def post_install_func(env):
# Get the certificate to use for PRIMARY_HOSTNAME.
ssl_certificates = get_ssl_certificates(env)
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'], ssl_certificates, env, use_main_cert=False)
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'],
ssl_certificates,
env,
use_main_cert=False)
if not cert:
# Ruh-row, we don't have any certificate usable
# for the primary hostname.
ret.append("there is no valid certificate for " + env['PRIMARY_HOSTNAME'])
ret.append("there is no valid certificate for " +
env['PRIMARY_HOSTNAME'])
# Symlink the best cert for PRIMARY_HOSTNAME to the system
# certificate path, which is hard-coded for various purposes, and then
# restart postfix and dovecot.
system_ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
system_ssl_certificate = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
# Update symlink.
ret.append("updating primary certificate")
@ -501,13 +562,20 @@ def post_install_func(env):
# Update the web configuration so nginx picks up the new certificate file.
from web_update import do_web_update
ret.append( do_web_update(env) )
ret.append(do_web_update(env))
return ret
# VALIDATION OF CERTIFICATES
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=10, rounded_time=False, just_check_domain=False):
def check_certificate(domain,
ssl_certificate,
ssl_private_key,
warn_if_expiring_soon=10,
rounded_time=False,
just_check_domain=False):
# Check that the ssl_certificate & ssl_private_key files are good
# for the provided domain.
@ -520,9 +588,11 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
try:
ssl_cert_chain = load_cert_chain(ssl_certificate)
cert = load_pem(ssl_cert_chain[0])
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
if not isinstance(cert, Certificate):
raise ValueError("This is not a certificate file.")
except ValueError as e:
return ("There is a problem with the certificate file: %s" % str(e), None)
return ("There is a problem with the certificate file: %s" % str(e),
None)
# First check that the domain name is one of the names allowed by
# the certificate.
@ -534,21 +604,27 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# should work in normal cases).
wildcard_domain = re.sub("^[^\.]+", "*", 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)
return (
"The certificate is for the wrong domain name. It is for %s." %
", ".join(sorted(certificate_names)), None)
# Second, check that the certificate matches the private key.
if ssl_private_key is not None:
try:
priv_key = load_pem(open(ssl_private_key, 'rb').read())
except ValueError as e:
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
return ("The private key file %s is not a private key file: %s" %
(ssl_private_key, str(e)), None)
if not isinstance(priv_key, RSAPrivateKey):
return ("The private key file %s is not a private key file." % ssl_private_key, None)
return ("The private key file %s is not a private key file." %
ssl_private_key, None)
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers():
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None)
if priv_key.public_key().public_numbers() != cert.public_key(
).public_numbers():
return (
"The certificate does not correspond to the private key at %s."
% ssl_private_key, None)
# We could also use the openssl command line tool to get the modulus
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
@ -569,8 +645,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
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)
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)
# 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
@ -579,11 +657,17 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# The certificate chain has to be passed separately and is given via STDIN.
# This command returns a non-zero exit status in most cases, so trap errors.
retcode, verifyoutput = shell('check_output', [
"openssl",
"verify", "-verbose",
"-purpose", "sslserver", "-policy_check",]
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
retcode, verifyoutput = shell(
'check_output',
[
"openssl",
"verify",
"-verbose",
"-purpose",
"sslserver",
"-policy_check",
] +
([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
+ [ssl_certificate],
input=b"\n\n".join(ssl_cert_chain[1:]),
trap=True)
@ -594,10 +678,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
elif retcode != 0:
if "unable to get local issuer certificate" in verifyoutput:
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
return (
"The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)"
% verifyoutput, None)
# There is some unknown problem. Return the `openssl verify` raw output.
return ("There is a problem with the certificate.", verifyoutput.strip())
return ("There is a problem with the certificate.",
verifyoutput.strip())
else:
# `openssl verify` returned a zero exit status so the cert is currently
@ -605,13 +692,15 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# But is it expiring soon?
cert_expiration_date = cert.not_valid_after
ndays = (cert_expiration_date-now).days
ndays = (cert_expiration_date - now).days
if not rounded_time or ndays <= 10:
# Yikes better renew soon!
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
expiry_info = "The certificate expires in %d days on %s." % (
ndays, cert_expiration_date.date().isoformat())
else:
# We'll renew it with Lets Encrypt.
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
expiry_info = "The certificate expires on %s." % cert_expiration_date.date(
).isoformat()
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
# Warn on day 10 to give 4 days for us to automatically renew the
@ -621,17 +710,19 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# Return the special OK code.
return ("OK", expiry_info)
def load_cert_chain(pemfile):
# A certificate .pem file may contain a chain of certificates.
# Load the file and split them apart.
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n]+(?:[A-Za-z0-9+/=]{1,64}[\r\n]+)+-+END (?:.+)-+[\r\n]+)"
with open(pemfile, "rb") as f:
pem = f.read() + b"\n" # ensure trailing newline
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.")
return pemblocks
def load_pem(pem):
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
# using classes from the cryptography package.
@ -643,10 +734,14 @@ def load_pem(pem):
raise ValueError("File is not a valid PEM-formatted file.")
pem_type = pem_type.group(1)
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"):
return serialization.load_pem_private_key(pem, password=None, backend=default_backend())
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())
raise ValueError("Unsupported PEM object type: " + pem_type.decode("ascii", "replace"))
raise ValueError("Unsupported PEM object type: " +
pem_type.decode("ascii", "replace"))
def get_certificate_domains(cert):
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
@ -675,7 +770,8 @@ def get_certificate_domains(cert):
return idna.encode(dns_name).decode('ascii')
try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
sans = cert.extensions.get_extension_for_oid(
OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans:
names.add(idna_decode_dns_name(san))
except ExtensionNotFound:
@ -683,6 +779,7 @@ def get_certificate_domains(cert):
return names, cn
if __name__ == "__main__":
if __name__ == "__main__":
# Provision certificates.
provision_certificates_cmdline()

File diff suppressed because it is too large Load diff

View file

@ -1,115 +1,149 @@
<style>
#alias_table .actions > * { padding-right: 3px; }
#alias_table .alias-required .remove { display: none }
#alias_table .alias-auto .actions>* {
display: none
}
#addalias-form .hidden {
display: none;
}
.btn.btn-xs {
padding: .1rem .3rem;
font-size: .75rem;
border-radius: .2rem;
}
</style>
<h2>Aliases</h2>
<h3>Add a mail alias</h3>
<p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail user</a> or to any email address.</p>
<p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail
user</a> or to any email address.</p>
<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.</p>
<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as
a permitted sender for the alias.</p>
<form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<div id="alias_type_buttons" class="btn-group btn-group-xs">
<button type="button" class="btn btn-default" data-mode="regular">Regular</button>
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
</div>
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to another domain, preserving the part before the @-sign.</span>
</div>
</div>
</div>
<div class="form-group">
<label for="addaliasAddress" class="col-sm-1 control-label">Alias</label>
<div class="col-sm-10">
<input type="email" class="form-control" id="addaliasAddress">
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
You may use international (non-ASCII) characters for the domain part of the email address only.
</div>
</div>
</div>
<div class="form-group">
<label for="addaliasForwardsTo" class="col-sm-1 control-label">Forwards To</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasForwardsTo"></textarea>
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
<span class="domainalias text-muted">Enter just the part of an email address starting with the @-sign.</span>
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other domains, create a mail user and then log into webmail for the user and create a filter rule to forward mail.</span>
</div>
</div>
</div>
<div class="form-group">
<label for="addaliasSenders" class="col-sm-1 control-label">Permitted Senders</label>
<div class="col-sm-10">
<div class="radio">
<label>
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio" checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
Any mail user listed in the Forwards To box can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
</label>
</div>
<div class="radio">
<label>
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
I&rsquo;ll enter the mail users that can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
</label>
</div>
<div id="addaliasForwardsToDiv" style="margin-top: .5em; margin-left: 1.4em; display: none;">
<textarea class="form-control" rows="3" id="addaliasSenders" placeholder="one user per line or separated by commas"></textarea>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
</div>
</div>
<div class="mb-3">
<div id="alias_type_buttons" class="btn-group btn-group-xs">
<button type="button" class="btn btn-secondary" data-mode="regular">Regular</button>
<button type="button" class="btn btn-secondary" data-mode="catchall">Catch-All</button>
<button type="button" class="btn btn-secondary" data-mode="domainalias">Domain Alias</button>
</div>
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to
another domain, preserving the part before the @-sign.</span>
</div>
</div>
<div class="col-lg-9 mb-3">
<div class="input-group">
<label for="addaliasAddress" class="input-group-text">Alias</label>
<input type="email" class="form-control" id="addaliasAddress">
</div>
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted ">
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
You may use international (non-ASCII) characters for the domain part of the email address only.
</div>
</div>
<div class="col-lg-9 mb-3">
<div class="form-floating">
<textarea class="form-control" style="min-height: 8em;" id="addaliasForwardsTo"></textarea>
<label for="addaliasForwardsTo">Forwards To</label>
</div>
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
<span class="domainalias text-muted">Enter just the part of an email address starting with the
@-sign.</span>
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded
by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other
domains, create a mail user and then log into webmail for the user and create a filter rule to forward
mail.</span>
</div>
</div>
<h4>Permitted Senders</h4>
<div class="col-lg-9 mb-3">
<div class="mb-3">
<div class="mb-3">
<div class="radio">
<label>
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio"
checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
Any mail user listed in the Forwards To box can send mail claiming to be from <span
class="regularalias">the alias address</span><span class="catchall domainalias">any address
on the alias domain</span>.
</label>
</div>
<div class="radio">
<label>
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio"
id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
I&rsquo;ll enter the mail users that can send mail claiming to be from <span
class="regularalias">the alias address</span><span class="catchall domainalias">any address
on the alias domain</span>.
</label>
</div>
</div>
<div id="addaliasForwardsToDiv" style="display: none;">
<div class="form-floating">
<textarea class="form-control" style="min-height: 8em;" id="addaliasSenders"
placeholder="one user per line or separated by commas"></textarea>
<label for="addaliasSenders">Permitted Senders</label>
</div>
<small>One user per line or separated by commas</small>
</div>
</div>
</div>
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
</form>
<h3>Existing mail aliases</h3>
<table id="alias_table" class="table" style="width: auto">
<thead>
<tr>
<th></th>
<th>Alias<br></th>
<th>Forwards To</th>
<th>Permitted Senders</th>
</tr>
</thead>
<tbody>
</tbody>
<table id="alias_table" class="table">
<caption></caption>
<thead>
<tr>
<th></th>
<th>Alias<br></th>
<th>Forwards To</th>
<th>Permitted Senders</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some domains.</small></p>
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some
domains.</small></p>
<div style="display: none">
<table>
<tr id="alias-template">
<td class='actions'>
<a href="#" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias">
<span class="glyphicon glyphicon-pencil"></span>
</a>
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
<span class="glyphicon glyphicon-trash"></span>
</a>
</td>
<td class='address'> </td>
<td class='forwardsTo'> </td>
<td class='senders'> </td>
</tr>
</table>
<table>
<tr id="alias-template">
<td class='actions row justify-content-evenly' style="min-width: 4em; border: none;">
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;"
class='edit' title="Edit Alias">
<span class="text-center text-primary fas fa-pen"></span>
</button>
<button class="col-5 btn btn-xs btn-default" onclick="aliases_remove(this); return false;"
class='remove' title="Remove Alias">
<span class="text-center text-danger fas fa-trash"></span>
</button>
</td>
<td class='address'> </td>
<td class='forwardsTo'> </td>
<td class='senders'> </td>
</tr>
</table>
</div>
<h3>Mail aliases API (advanced)</h3>
<p>Use your box&rsquo;s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p>
<p>Use your box&rsquo;s mail aliases API to add and remove mail aliases from the command-line or custom services you
build.</p>
<p>Usage:</p>
@ -122,15 +156,35 @@
<h4 style="margin-bottom: 0">Verbs</h4>
<table class="table" style="margin-top: .5em">
<thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.</td></tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td></tr>
<caption></caption>
<thead>
<th>Verb</th>
<th>Action</th>
<th></th>
</thead>
<tr>
<td><b>GET</b></td>
<td><i>(none)</i></td>
<td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded
results.</td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/add</td>
<td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.
</td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/remove</td>
<td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td>
</tr>
</table>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your email address and password.</p>
<pre># Gives a JSON-encoded list of all mail aliases
curl -X GET https://{{hostname}}/admin/mail/aliases?format=json
@ -144,159 +198,157 @@ curl -X POST -d "address=new_alias@mydomail.com" https://{{hostname}}/admin/mail
<script>
function show_aliases() {
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/mail/aliases",
"GET",
{ format: 'json' },
function(r) {
$('#alias_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
hdr.find('h4').text(r[i].domain);
$('#alias_table tbody').append(hdr);
function show_aliases() {
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/mail/aliases",
"GET",
{ format: 'json' },
function (r) {
$('#alias_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><th colspan='4' class='bg-light'></th></tr>");
hdr.find('th').text(r[i].domain);
$('#alias_table tbody').append(hdr);
for (var k = 0; k < r[i].aliases.length; k++) {
var alias = r[i].aliases[k];
for (var k = 0; k < r[i].aliases.length; k++) {
var alias = r[i].aliases[k];
var n = $("#alias-template").clone();
n.attr('id', '');
var n = $("#alias-template").clone();
n.attr('id', '');
if (alias.required) n.addClass('alias-required');
n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
n.find('td.address').text(alias.address_display)
for (var j = 0; j < alias.forwards_to.length; j++)
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[j]))
for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++)
n.find('td.senders').append($("<div></div>").text(alias.permitted_senders[j]))
$('#alias_table tbody').append(n);
}
}
})
if (alias.auto) n.addClass('alias-auto');
n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
n.find('td.address').text(alias.address_display)
for (var j = 0; j < alias.forwards_to.length; j++)
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[j]))
for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++)
n.find('td.senders').append($("<div></div>").text(alias.permitted_senders[j]))
$('#alias_table tbody').append(n);
}
}
})
$(function() {
$('#alias_type_buttons button').off('click').click(function() {
$('#alias_type_buttons button').removeClass('active');
$(this).addClass('active');
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
if ($(this).attr('data-mode') == "regular") {
$('#addaliasAddress').attr('type', 'email');
$('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
$('#alias_mode_info').slideUp();
$('#addalias-form .regularalias').removeClass('hidden');
} else if ($(this).attr('data-mode') == "catchall") {
$('#addaliasAddress').attr('type', 'text');
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
$('#alias_mode_info').slideDown();
$('#addalias-form .catchall').removeClass('hidden');
} else if ($(this).attr('data-mode') == "domainalias") {
$('#addaliasAddress').attr('type', 'text');
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
$('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');
$('#alias_mode_info').slideDown();
$('#addalias-form .domainalias').removeClass('hidden');
}
})
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
})
}
$('#alias_type_buttons button').off('click').click(function () {
$('#alias_type_buttons button').removeClass('active');
$(this).addClass('active');
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
if ($(this).attr('data-mode') == "regular") {
$('#addaliasAddress').attr('type', 'email');
$('#addaliasAddress').attr('placeholder', 'you@example.com (incoming email address)');
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
$('#alias_mode_info').slideUp();
$('#addalias-form .regularalias').removeClass('hidden');
} else if ($(this).attr('data-mode') == "catchall") {
$('#addaliasAddress').attr('type', 'text');
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
$('#alias_mode_info').slideDown();
$('#addalias-form .catchall').removeClass('hidden');
} else if ($(this).attr('data-mode') == "domainalias") {
$('#addaliasAddress').attr('type', 'text');
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
$('#addaliasForwardsTo').attr('placeholder', '@example.net (forward to other domain)');
$('#alias_mode_info').slideDown();
$('#addalias-form .domainalias').removeClass('hidden');
}
})
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
}
var is_alias_add_update = false;
function do_add_alias() {
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
var form_address = $("#addaliasAddress").val();
var form_forwardsto = $("#addaliasForwardsTo").val();
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
show_modal_error(title, "You did not enter any permitted senders.");
return false;
}
api(
"/mail/aliases/add",
"POST",
{
update_if_exists: is_alias_add_update ? '1' : '0',
address: form_address,
forwards_to: form_forwardsto,
permitted_senders: form_senders
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error(title, $("<pre/>").text(r));
show_aliases()
aliases_reset_form();
},
function(r) {
show_modal_error(title, r);
});
return false;
}
var is_alias_add_update = false;
function do_add_alias() {
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
var form_address = $("#addaliasAddress").val();
var form_forwardsto = $("#addaliasForwardsTo").val();
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
show_modal_error(title, "You did not enter any permitted senders.");
return false;
}
api(
"/mail/aliases/add",
"POST",
{
update_if_exists: is_alias_add_update ? '1' : '0',
address: form_address,
forwards_to: form_forwardsto,
permitted_senders: form_senders
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error(title, $("<pre/>").text(r));
show_aliases()
aliases_reset_form();
},
function (r) {
show_modal_error(title, r);
});
return false;
}
function aliases_reset_form() {
$("#addaliasAddress").prop('disabled', false);
$("#addaliasAddress").val('')
$("#addaliasForwardsTo").val('')
$("#addaliasSenders").val('')
$('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add Alias');
is_alias_add_update = false;
}
function aliases_reset_form() {
$("#addaliasAddress").prop('disabled', false);
$("#addaliasAddress").val('')
$("#addaliasForwardsTo").val('')
$("#addaliasSenders").val('')
$('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add Alias');
is_alias_add_update = false;
}
function aliases_edit(elem) {
var address = $(elem).parents('tr').attr('data-address');
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
var senderdivs = $(elem).parents('tr').find('.senders div');
var forwardsTo = "";
for (var i = 0; i < receiverdivs.length; i++)
forwardsTo += $(receiverdivs[i]).text() + "\n";
var senders = "";
for (var i = 0; i < senderdivs.length; i++)
senders += $(senderdivs[i]).text() + "\n";
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="domainalias"]').click();
else if (address.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="catchall"]').click();
else
$('#alias_type_buttons button[data-mode="regular"]').click();
$('#alias-cancel').removeClass('hidden');
$("#addaliasAddress").prop('disabled', true);
$("#addaliasAddress").val(address);
$("#addaliasForwardsTo").val(forwardsTo);
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
$("#addaliasSenders").val(senders);
$('#add-alias-button').text('Update');
$('body').animate({ scrollTop: 0 })
is_alias_add_update = true;
}
function aliases_edit(elem) {
var address = $(elem).parents('tr').attr('data-address');
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
var senderdivs = $(elem).parents('tr').find('.senders div');
var forwardsTo = "";
for (var i = 0; i < receiverdivs.length; i++)
forwardsTo += $(receiverdivs[i]).text() + "\n";
var senders = "";
for (var i = 0; i < senderdivs.length; i++)
senders += $(senderdivs[i]).text() + "\n";
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="domainalias"]').click();
else if (address.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="catchall"]').click();
else
$('#alias_type_buttons button[data-mode="regular"]').click();
$('#alias-cancel').removeClass('hidden');
$("#addaliasAddress").prop('disabled', true);
$("#addaliasAddress").val(address);
$("#addaliasForwardsTo").val(forwardsTo);
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
$("#addaliasSenders").val(senders);
$('#add-alias-button').text('Update');
$('body').animate({ scrollTop: 0 })
is_alias_add_update = true;
}
function aliases_remove(elem) {
var row_address = $(elem).parents('tr').attr('data-address');
show_modal_confirm(
"Remove Alias",
"Remove " + row_address + "?",
"Remove",
function() {
api(
"/mail/aliases/remove",
"POST",
{
address: row_address
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove User", $("<pre/>").text(r));
show_aliases();
});
});
}
function aliases_remove(elem) {
var row_address = $(elem).parents('tr').attr('data-address');
show_modal_confirm(
"Remove Alias",
"Remove " + row_address + "?",
"Remove",
function () {
api(
"/mail/aliases/remove",
"POST",
{
address: row_address
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove Alias", $("<pre/>").text(r));
show_aliases();
});
});
}
function scroll_top() {
$('html, body').animate({
scrollTop: $("#panel_aliases").offset().top
}, 1000);
}
function scroll_top() {
$('html, body').animate({
scrollTop: $("#panel_aliases").offset().top
}, 1000);
}
</script>

View file

@ -1,7 +1,7 @@
<style>
#custom-dns-current td.long {
word-break: break-all;
}
#custom-dns-current td.long {
word-break: break-all;
}
</style>
<h2>Custom DNS</h2>
@ -12,97 +12,123 @@
<h3>Set custom DNS records</h3>
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for
external mail providers, or for various confirmation-of-ownership tests.</p>
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
<div class="form-group">
<label for="customdnsQname" class="col-sm-1 control-label">Name</label>
<div class="col-sm-10">
<table style="max-width: 400px">
<tr><td>
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
</td><td style="padding: 0 1em; font-weight: bold;">.</td><td>
<select id="customdnsZone" class="form-control"> </select>
</td></tr></table>
<div class="text-info" style="margin-top: .5em">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>
</div>
</div>
<div class="form-group">
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
<div class="col-sm-10">
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">A (IPv4 address)</option>
<option value="AAAA" data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">AAAA (IPv6 address)</option>
<option value="CAA" data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild &quot;letsencrypt.org&quot;)">CAA (Certificate Authority Authorization)</option>
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
<option value="MX" data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
<option value="SRV" data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">SRV (service record)</option>
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH fingerprint record)</option>
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS subdomain delegation)</option>
</select>
</div>
</div>
<div class="form-group">
<label for="customdnsValue" class="col-sm-1 control-label">Value</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="customdnsValue" placeholder="">
<div id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<button type="submit" class="btn btn-primary">Set Record</button>
</div>
</div>
<div class="col-lg-10 col-xl-7 mb-3">
<div class="input-group">
<label class="input-group-text">Name</label>
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
<label class="input-group-text">.</label>
<select id="customdnsZone" class="form-select"></select>
</div>
<small>Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</small>
</div>
<div class="col-lg-10 col-xl-7 mb-3">
<div class="input-group">
<label for="customdnsType" class="input-group-text">Type</label>
<select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()">
<option value="A"
data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">
A (IPv4 address)</option>
<option value="AAAA"
data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">
AAAA (IPv6 address)</option>
<option value="CAA"
data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild &quot;letsencrypt.org&quot;)">
CAA (Certificate Authority Authorization)</option>
<option value="CNAME"
data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">
CNAME (DNS forwarding)</option>
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
<option value="MX"
data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">
MX (mail exchanger)</option>
<option value="SRV"
data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">
SRV (service record)</option>
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH
fingerprint record)</option>
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS
subdomain delegation)</option>
</select>
</div>
</div>
<div class="col-lg-10 col-xl-7 mb-3">
<div class="input-group">
<label for="customdnsValue" class="input-group-text">Value</label>
<input type="text" class="form-control" id="customdnsValue" placeholder="">
<label for="customdnsTtl" class="input-group-text">TTL</label>
<input type="number" class="form-control" id="customdnsTtl" placeholder="default">
</div>
<small id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></small>
</div>
<button type="submit" class="btn btn-primary mb-3">Set Record</button>
</form>
<table id="custom-dns-current" class="table" style="width: auto; display: none">
<thead>
<th>Domain Name</th>
<th>Record Type</th>
<th>Value</th>
<th></th>
</thead>
<tbody>
<tr><td colspan="4">Loading...</td></tr>
</tbody>
<div style="font-size: 90%;">
<b>Sort by:</b>
<a href="#"
onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain
name</a>
<b>|</b>
<a href="#"
onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">Created</a>
</div>
<table id="custom-dns-current" class="table col-12" style="display: none; margin-top: 0;">
<caption></caption>
<thead>
<th>Domain Name</th>
<th>Record Type</th>
<th>Value</th>
<th>TTL</th>
<th></th>
</thead>
<tbody>
<tr>
<td colspan="5">Loading...</td>
</tr>
</tbody>
</table>
<h3>Using a secondary nameserver</h3>
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p>
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#"
onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or
use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p>
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name
registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver
service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
<div class="form-group">
<label for="secondarydnsHostname" class="col-sm-1 control-label">Hostname</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="secondarydnsHostname" placeholder="ns1.cloudprovider.com">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<button type="submit" class="btn btn-primary">Update</button>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-1 col-sm-11">
<p class="small">
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.20.30.40/24</code>.
</p>
<p id="secondarydns-clear-instructions" style="display: none" class="small">
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.
</p>
</div>
</div>
<div class="col-12 form-floating mb-3">
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname"
placeholder="ns1.example.com"></textarea>
<label for="secondarydnsHostname">Secondary Nameservers</label>
<div>
<p class="small">
Multiple secondary servers can be separated with commas or spaces (i.e., <span
class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP
address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span
class="font-monospace">xfr:10.0.0.0/8</span>.
</p>
<p id="secondarydns-clear-instructions" style="display: none" class="small">
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the
default/normal setup.
</p>
</div>
</div>
<button type="submit" class="btn btn-primary">Update</button>
</form>
<h3>Custom DNS API</h3>
<p>Use your box&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
<p>Use your box&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own
dynamic DNS service.</p>
<p>Usage:</p>
@ -113,29 +139,83 @@
<h4>Verbs</h4>
<table class="table">
<thead><th>Verb</th> <th>Usage</th></thead>
<tr><td>GET</td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr>
<tr><td>PUT</td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>
<tr><td>POST</td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>
<tr><td>DELETE</td> <td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>
<caption></caption>
<thead>
<th>Verb</th>
<th>Usage</th>
</thead>
<tr>
<td class="font-monospace"><b>GET</b></td>
<td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>,
<code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code>
parameters in the request URL filter the records returned in the response. The request body
(<code>-d "..."</code>) must be omitted.</td>
</tr>
<tr>
<td class="font-monospace"><b>PUT</b></td>
<td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and
<code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and
<code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td>
</tr>
<tr>
<td class="font-monospace"><b>POST</b></td>
<td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin
<code>A</code> records. (PUT would delete previously added records.)</td>
</tr>
<tr>
<td class="font-monospace"><b>DELETE</b></td>
<td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all
records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only
the record matching the <code>qname</code>, <code>rtype</code> and value.</td>
</tr>
</table>
<h4>Parameters</h4>
<table class="table">
<thead><th>Parameter</th> <th>Value</th></thead>
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
<tr><td>password</td> <td>That user&rsquo;s password.</td></tr>
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name &mdash; don&rsquo;t forget the final period), <code>MX</code>, <code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td></tr>
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record&rsquo;s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
<caption></caption>
<thead>
<th>Parameter</th>
<th>Value</th>
</thead>
<tr>
<td class="font-monospace"><b>email</b></td>
<td>The email address of any administrative user here.</td>
</tr>
<tr>
<td class="font-monospace"><b>password</b></td>
<td>That user&rsquo;s password.</td>
</tr>
<tr>
<td class="font-monospace"><b>qname</b></td>
<td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or
a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)
</td>
</tr>
<tr>
<td class="font-monospace"><b>rtype</b></td>
<td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address),
<code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which
is a fully qualified domain name &mdash; don&rsquo;t forget the final period), <code>MX</code>,
<code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td>
</tr>
<tr>
<td class="font-monospace"><b>value</b></td>
<td>For PUT, POST, and DELETE, the record&rsquo;s value. If the <code>rtype</code> is <code>A</code> or
<code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is
used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!
</td>
</tr>
</table>
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be added to all custom domains unless you override them.</p>
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a
href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be
added to all custom domains unless you override them.</p>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your email address and password.</p>
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
@ -157,124 +237,138 @@ curl -X DELETE -d "some text here" https://{{hostname}}/admin/dns/custom/foo.myd
</pre>
<script>
function show_custom_dns() {
api(
"/dns/secondary-nameserver",
"GET",
{ },
function(data) {
$('#secondarydnsHostname').val(data.hostnames.join(' '));
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
});
function show_custom_dns() {
api(
"/dns/secondary-nameserver",
"GET",
{},
function (data) {
$('#secondarydnsHostname').val(data.hostnames.join(' '));
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
});
api(
"/dns/zones",
"GET",
{ },
function(data) {
$('#customdnsZone').text('');
for (var i = 0; i < data.length; i++) {
$('#customdnsZone').append($('<option/>').text(data[i]));
}
});
api(
"/dns/zones",
"GET",
{},
function (data) {
$('#customdnsZone').text('');
for (var i = 0; i < data.length; i++) {
$('#customdnsZone').append($('<option/>').text(data[i]));
}
});
show_current_custom_dns();
show_customdns_rtype_hint();
}
show_current_custom_dns();
show_customdns_rtype_hint();
}
function show_current_custom_dns() {
api(
"/dns/custom",
"GET",
{ },
function(data) {
if (data.length > 0)
$('#custom-dns-current').fadeIn();
else
$('#custom-dns-current').fadeOut();
var reverse_fqdn = function(el) {
el.qname = el.qname.split('.').reverse().join('.');
return el;
}
var sort = function(a, b) {
if(a.qname === b.qname) {
if(a.rtype === b.rtype) {
return a.value > b.value ? 1 : -1;
}
return a.rtype > b.rtype ? 1 : -1;
}
return a.qname > b.qname ? 1 : -1;
}
function show_current_custom_dns() {
api(
"/dns/custom",
"GET",
{},
function (data) {
if (data.length > 0)
$('#custom-dns-current').fadeIn();
else
$('#custom-dns-current').fadeOut();
window.miab_custom_dns_data = data;
show_current_custom_dns_update_after_sort();
});
}
data = data.map(reverse_fqdn).sort(sort).map(reverse_fqdn);
function show_current_custom_dns_update_after_sort() {
var data = window.miab_custom_dns_data;
var sort_key = window.miab_custom_dns_data_sort_order || "qname";
$('#custom-dns-current').find("tbody").text('');
for (var i = 0; i < data.length; i++) {
var tr = $("<tr/>");
$('#custom-dns-current').find("tbody").append(tr);
tr.attr('data-qname', data[i].qname);
tr.attr('data-rtype', data[i].rtype);
tr.attr('data-value', data[i].value);
tr.append($('<td class="long"/>').text(data[i].qname));
tr.append($('<td/>').text(data[i].rtype));
tr.append($('<td class="long"/>').text(data[i].value));
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
}
});
}
data.sort(function (a, b) { return a["sort-order"][sort_key] - b["sort-order"][sort_key] });
function delete_custom_dns_record(elem) {
var qname = $(elem).parents('tr').attr('data-qname');
var rtype = $(elem).parents('tr').attr('data-rtype');
var value = $(elem).parents('tr').attr('data-value');
do_set_custom_dns(qname, rtype, value, "DELETE");
return false;
}
var tbody = $('#custom-dns-current').find("tbody");
tbody.text('');
var last_zone = null;
for (var i = 0; i < data.length; i++) {
if (sort_key == "qname" && data[i].zone != last_zone) {
var r = $("<tr><th colspan=5 class='bg-light'></th></tr>");
r.find("th").text(data[i].zone);
tbody.append(r);
last_zone = data[i].zone;
}
function do_set_secondary_dns() {
api(
"/dns/secondary-nameserver",
"POST",
{
hostnames: $('#secondarydnsHostname').val()
},
function(data) {
if (data == "") return; // nothing updated
show_modal_error("Secondary DNS", $("<pre/>").text(data));
$('#secondarydns-clear-instructions').slideDown();
},
function(err) {
show_modal_error("Secondary DNS", $("<pre/>").text(err));
});
}
var tr = $("<tr/>");
tbody.append(tr);
tr.attr('data-qname', data[i].qname);
tr.attr('data-rtype', data[i].rtype);
tr.attr('data-value', data[i].value);
tr.append($('<td class="long"/>').text(data[i].qname));
tr.append($('<td/>').text(data[i].rtype));
tr.append($('<td class="long font-monospace" style="max-width: 40em"/>').text(data[i].value));
if (data[i].ttl) {
tr.append($('<td/>').text(data[i].ttl));
} else {
tr.append($('<td/>').html('<i class="">default</i>'));
}
tr.append($('<td class="col-1">[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
}
}
function do_set_custom_dns(qname, rtype, value, method) {
if (!qname) {
if ($('#customdnsQname').val() != '')
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
else
qname = $('#customdnsZone').val();
rtype = $('#customdnsType').val();
value = $('#customdnsValue').val();
method = 'POST';
}
function delete_custom_dns_record(elem) {
var qname = $(elem).parents('tr').attr('data-qname');
var rtype = $(elem).parents('tr').attr('data-rtype');
var value = $(elem).parents('tr').attr('data-value');
do_set_custom_dns(qname, rtype, value, "DELETE");
return false;
}
api(
"/dns/custom/" + qname + "/" + rtype,
method,
value,
function(data) {
if (data == "") return; // nothing updated
show_modal_error("Custom DNS", $("<pre/>").text(data));
show_current_custom_dns();
},
function(err) {
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
});
}
function do_set_secondary_dns() {
api(
"/dns/secondary-nameserver",
"POST",
{
hostnames: $('#secondarydnsHostname').val()
},
function (data) {
if (data == "") return; // nothing updated
show_modal_error("Secondary DNS", $("<pre/>").text(data));
$('#secondarydns-clear-instructions').slideDown();
},
function (err) {
show_modal_error("Secondary DNS", $("<pre/>").text(err));
});
}
function show_customdns_rtype_hint() {
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
}
function do_set_custom_dns(qname, rtype, value, method) {
if (!qname) {
if ($('#customdnsQname').val() != '')
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
else
qname = $('#customdnsZone').val();
rtype = $('#customdnsType').val();
value = {
value: $('#customdnsValue').val(),
ttl: $('#customdnsTtl').val()
};
method = 'POST';
} else {
value = {
value: value
}
}
api(
"/dns/custom/" + qname + "/" + rtype,
method,
value,
function (data) {
if (data == "") return; // nothing updated
show_modal_error("Custom DNS", $("<pre/>").text(data));
show_current_custom_dns();
},
function (err) {
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
});
}
function show_customdns_rtype_hint() {
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
}
</script>

View file

@ -1,49 +1,66 @@
<style>
#external_dns_settings .heading td {
font-weight: bold;
font-size: 120%;
padding-top: 1.5em;
}
#external_dns_settings .heading.first td {
border-top: none;
padding-top: 0;
}
#external_dns_settings .values td {
border: 0;
padding-top: .75em;
padding-bottom: 0;
}
#external_dns_settings .value {
word-break: break-all;
}
#external_dns_settings .explanation td {
border: 0;
padding-top: .5em;
padding-bottom: .75em;
font-style: italic;
font-size: 95%;
color: #777;
}
#external_dns_settings .domain-header {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
#external_dns_settings .values td {
border: 0;
padding-top: .75em;
padding-bottom: 0;
}
#external_dns_settings .value {
word-break: break-all;
}
#external_dns_settings .explanation td {
padding-top: .5em;
padding-bottom: .75em;
font-style: italic;
color: #777;
}
</style>
<h2>External DNS</h2>
<p class="text-warning">This is an advanced configuration page.</p>
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere &mdash; such as in the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS zone information shown in the table below into your external DNS server&rsquo;s control panel.</p>
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere &mdash; such as in
the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS
zone information shown in the table below into your external DNS server&rsquo;s control panel.</p>
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your
domain name by setting a DS record at your registrar, you will likely have to turn it off before changing
nameservers.</p>
<p class="alert" role="alert">
<span class="glyphicon glyphicon-info-sign"></span>
<span class="fas fa-info-circle"></span>
You may encounter zone file errors when attempting to create a TXT record with a long string.
<a href="http://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
<a href="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain
multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to
validate your zone file.
</p>
<h3>Download zonefile</h3>
<p>You can download your zonefiles here or use the table of records below.</p>
<form class="form-inline" role="form" onsubmit="do_download_zonefile(); return false;">
<div class="col-md-10 col-xl-6 mb-3">
<div class="input-group">
<label for="downloadZonefile" class="input-group-text">Zone</label>
<select id="downloadZonefile" class="form-select"></select>
</div>
</div>
<button type="submit" class="btn btn-primary">Download</button>
</form>
<h3>Records</h3>
<table id="external_dns_settings" class="table">
<caption></caption>
<thead>
<tr>
<th>QName</th>
@ -56,32 +73,59 @@
</table>
<script>
function show_external_dns() {
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/dns/dump",
"GET",
{ },
function(zones) {
$('#external_dns_settings tbody').html("");
for (var j = 0; j < zones.length; j++) {
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
h.find("td").text(zones[j][0]);
$('#external_dns_settings tbody').append(h);
function show_external_dns() {
api(
"/dns/zones",
"GET",
{},
function (data) {
var zones = $('#downloadZonefile');
zones.text('');
for (var j = 0; j < data.length; j++) {
zones.append($('<option/>').text(data[j]));
}
});
var r = zones[j][1];
for (var i = 0; i < r.length; i++) {
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
n.find('.qname').text(r[i].qname);
n.find('.rtype').text(r[i].rtype);
n.find('.value').text(r[i].value);
$('#external_dns_settings tbody').append(n);
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/dns/dump",
"GET",
{},
function (zones) {
$('#external_dns_settings tbody').html("");
for (var j = 0; j < zones.length; j++) {
var h = $("<tr><td colspan='3' class='bg-light'><h4 class='domain-header'/></td></tr>");
h.find("td h4").text(zones[j][0]);
$('#external_dns_settings tbody').append(h);
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
n.find('td').text(r[i].explanation);
$('#external_dns_settings tbody').append(n);
}
}
})
}
var r = zones[j][1];
for (var i = 0; i < r.length; i++) {
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value font-monospace'/></tr>");
n.find('.qname').text(r[i].qname);
n.find('.rtype').text(r[i].rtype);
n.find('.value').text(r[i].value);
$('#external_dns_settings tbody').append(n);
var n = $("<tr class='explanation'><td colspan='3'/><small/></tr>");
n.find('td small').text(r[i].explanation);
$('#external_dns_settings tbody').append(n);
}
}
})
}
function do_download_zonefile() {
var zone = $('#downloadZonefile').val();
api(
"/dns/zonefile/" + zone,
"GET",
{},
function (data) {
show_modal_error("Download Zonefile", $("<pre/>").text(data));
},
function (err) {
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
});
}
</script>

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,83 @@
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
<style>
.title {
margin: 0.5em;
text-align: center;
}
.subtitle {
margin: 1em;
text-align: center;
}
.login {
margin: 0 auto;
max-width: 32em;
}
.login #loginOtp {
display: none;
}
#loginForm.is-twofactor #loginOtp {
display: block
}
</style>
<h1 class="title">{{hostname}}</h1>
{% if no_users_exist or no_admins_exist %}
<div class="row">
<div class="col-md-offset-2 col-md-8">
<div class="col-12">
<hr>
{% if no_users_exist %}
<p class="text-danger">There are no users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox
sudo tools/mail.py user add me@{{hostname}}
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
sudo management/cli.py user add me@{{hostname}}
sudo management/cli.py user make-admin me@{{hostname}}</pre>
{% else %}
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
sudo management/cli.py user make-admin me@{{hostname}}</pre>
{% endif %}
<hr>
</div>
</div>
{% endif %}
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
<div style="margin: 0 auto; max-width: 32em;">
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
<div class="form-group">
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
<div class="col-sm-12">
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
<div class="login">
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
<div class="form-floating mb-3">
<input type="email" class="form-control col-sm-12" id="loginEmail" placeholder="user@example.com">
<label for="loginEmail">Email</label>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control col-sm-12" id="loginPassword" placeholder="password">
<label for="loginPassword">Password</label>
</div>
<div class="form-floating mb-3" id="loginOtp">
<input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off">
<label for="loginOtpInput">TOTP Code</label>
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two
factor authentication app.</div>
</div>
<div class="form-group mb-3">
<div class="ms-auto">
<input type="checkbox" class="form-check-input" id="loginRemember">
<label class="form-check-label" for="loginRemember">Remember Me</label>
</div>
</div>
<div class="form-group">
<label for="inputPassword3" class="col-sm-3 control-label">Password</label>
<div class="col-sm-12">
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<div class="checkbox">
<label>
<input name='remember' type="checkbox" id="loginRemember"> Remember me
</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-default">Sign in</button>
<div class="container-fluid">
<button type="submit" class="btn btn-primary col-12">Sign in</button>
</div>
</div>
</form>
</div>
<script>
function do_login() {
if ($('#loginEmail').val() == "") {
@ -62,6 +86,7 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
});
return false;
}
if ($('#loginPassword').val() == "") {
show_modal_error("Login Failed", "Enter your email password.", function () {
$('#loginPassword').focus();
@ -70,22 +95,34 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
}
// Exchange the email address & password for an API key.
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
api(
"/me",
"GET",
"/login",
"POST",
{},
function (response) {
// This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not.
if (response.status != "ok") {
// Show why the login failed.
show_modal_error("Login Failed", response.reason)
if (response.status != 'ok') {
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
$('#loginForm').addClass('is-twofactor');
if (response.reason === "invalid-totp-token") {
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
} else {
setTimeout(() => {
$('#loginOtpInput').focus();
});
}
} else {
$('#loginForm').removeClass('is-twofactor');
// Reset any saved credentials.
do_logout();
// Show why the login failed.
show_modal_error("Login Failed", response.reason)
// Reset any saved credentials.
do_logout();
}
} else if (!("api_key" in response)) {
// Login succeeded but user might not be authorized!
show_modal_error("Login Failed", "You are not an administrator on this system.")
@ -97,41 +134,47 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
// Login succeeded.
// Save the new credentials.
api_credentials = [response.email, response.api_key];
api_credentials = {
username: response.email,
session_key: response.api_key,
privileges: response.privileges
};
// Try to wipe the username/password information.
$('#loginEmail').val('');
$('#loginPassword').val('');
$('#loginOtpInput').val('');
$('#loginForm').removeClass('is-twofactor');
// Remember the credentials.
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
if ($('#loginRemember').val()) {
localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
sessionStorage.removeItem("miab-cp-credentials");
} else {
localStorage.removeItem("miab-cp-credentials");
sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
}
}
// Toggle menus.
show_hide_menus();
// Open the next panel the user wants to go to. Do this after the XHR response
// is over so that we don't start a new XHR request while this one is finishing,
// which confuses the loading indicator.
setTimeout(function () { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
setTimeout(function () { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300);
}
})
}
function do_logout() {
api_credentials = ["", ""];
if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials");
show_panel('login');
},
undefined,
{
'x-auth-token': $('#loginOtpInput').val()
});
}
function show_login() {
$('#loginForm').removeClass('is-twofactor');
$('#loginOtpInput').val('');
$('#loginEmail,#loginPassword').each(function () {
var input = $(this);
if (!$.trim(input.val())) {
@ -140,4 +183,19 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
}
});
}
function show_hide_menus() {
var is_logged_in = (api_credentials != null);
var privs = api_credentials ? api_credentials.privileges : [];
$('.if-logged-in').toggle(is_logged_in);
$('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);
if (is_logged_in) {
$('.if-logged-in-not-admin').toggle(true);
privs.forEach(function (priv) {
$('.if-logged-in-' + priv).toggle(true);
$('.if-logged-in-not-' + priv).toggle(false);
});
}
$('.if-not-logged-in').toggle(!is_logged_in);
}
</script>

View file

@ -1,4 +1,9 @@
<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>
<style>
#panel_mail-guide table.table {
width: auto;
margin-left: .5em;
}
</style>
<div>
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
@ -8,66 +13,121 @@
<h3>Webmail</h3>
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
<p style="margin-left: 2em"><strong><a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
<p style="margin-left: 2em"><strong><a
href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
<p>Your username is your whole email address.</p>
<h3>Mobile/desktop apps</h3>
<h4>Automatic configuration</h4>
<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>
<h4>Manual 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>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
<h4>Manual configuration</h4>
<table class="table">
<thead>
<tr><th>Option</th> <th>Value</th></tr>
</thead>
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
<tr><th>Mail server</th> <td>{{hostname}}</td>
<tr><th>IMAP Port</th> <td>993</td></tr>
<tr><th>IMAP Security</th> <td>SSL or TLS</td></tr>
<tr><th>SMTP Port</th> <td>587</td></tr>
<tr><th>SMTP Security</td> <td>STARTTLS <small>(&ldquo;always&rdquo; or &ldquo;required&rdquo;, if prompted)</small></td></tr>
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
<tr><th>Password:</th> <td>Your mail password.</td></tr>
</table>
<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
<table class="table">
<caption></caption>
<thead>
<tr>
<th>Option</th>
<th>Value</th>
</tr>
</thead>
<tr>
<th>Protocol/Method</th>
<td>IMAP</td>
</tr>
<tr>
<th>Mail server</th>
<td>{{hostname}}</td>
<tr>
<th>IMAP Port</th>
<td>993</td>
</tr>
<tr>
<th>IMAP Security</th>
<td>SSL or TLS</td>
</tr>
<tr>
<th>SMTP Port</th>
<td>465</td>
</tr>
<tr>
<th>SMTP Security</td>
<td>SSL or TLS</td>
</tr>
<tr>
<th>Username:</th>
<td>Your whole email address.</td>
</tr>
<tr>
<th>Password:</th>
<td>Your mail password.</td>
</tr>
</table>
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide"
onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
<h4>Exchange/ActiveSync settings</h4>
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and
SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same.
However, we recommend you use IMAP instead.</p>
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
<h4>Exchange/ActiveSync settings</h4>
<table class="table">
<tr><th>Server</th> <td>{{hostname}}</td></tr>
<tr><th>Options</th> <td>Secure Connection</td></tr>
</table>
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility
list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an
Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as
described above. If you encounter any problems, please use the manual settings above.</p>
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
<table class="table">
<tr>
<th>Server</th>
<td>{{hostname}}</td>
</tr>
<tr>
<th>Options</th>
<td>Secure Connection</td>
</tr>
</table>
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this
method.</p>
</div>
<div class="col-sm-5">
<div class="panel panel-default">
<div class="panel-heading">
<h3>Other information about mail on your box</h3>
</div>
<div class="panel-body">
<h4>Greylisting</h4>
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven&rsquo;t received mail from before. Legitimate mail servers will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please be aware there will be a minimum of 3 minutes delay, depending how soon the remote server attempts redelivery.</p>
<div class="panel panel-default">
<div class="panel-heading">
<h3>Other information about mail on your box</h3>
</div>
<div class="panel-body">
<h4>Greylisting</h4>
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially
rejecting mail from people you haven&rsquo;t received mail from before. Legitimate mail servers
will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this.
If you are waiting for an email from someone new, such as if you are registering on a new
website and are waiting for an email confirmation, please be aware there will be a minimum of 3
minutes delay, depending how soon the remote server attempts redelivery.</p>
<h4>+tag addresses</h4>
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you&rsquo;ll also automatically get mail sent to <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.</p>
<h4>+tag addresses</h4>
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email
address is <code>you@yourdomain.com</code>, you&rsquo;ll also automatically get mail sent to
<code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail
for your own filtering rules without having to create aliases in this control panel.</p>
<h4>Use only this box to send as you</h4>
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
</div>
</div>
<h4>Use only this box to send as you</h4>
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and
other fraudulent mail to claim to be you. Only this machine is authorized to send email on
behalf of your domain names. If you use any other service to send email as you, it will likely
get spam filtered by recipients.</p>
</div>
</div>
</div>
</div>
</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

@ -0,0 +1,240 @@
<style>
.twofactor #totp-setup,
.twofactor #disable-2fa,
.twofactor #output-2fa {
display: none;
}
.twofactor.loaded .loading-indicator {
display: none;
}
.twofactor.disabled #disable-2fa,
.twofactor.enabled #totp-setup {
display: none;
}
.twofactor.disabled #totp-setup,
.twofactor.enabled #disable-2fa {
display: block;
}
.twofactor #totp-setup-qr img {
display: block;
width: 256px;
max-width: 100%;
height: auto;
}
.twofactor #output-2fa.visible {
display: block;
}
</style>
<h2>Two-Factor Authentication</h2>
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
authenticator app (usually on your phone) when you log into this control panel.</p>
<div class="card mb-4">
<div class="card-header text-white bg-danger">
Enabling two-factor authentication does not protect access to your email
</div>
<div class="card-body bg-light">
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most
websites allow you to
reset your password by checking your email, so anyone with access to your email can typically take over
your other accounts. Additionally, if your email address or any alias that forwards to your email
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
and ensure every administrator account for this control panel does the same.</strong>
</div>
</div>
<div class="twofactor">
<div class="loading-indicator">Loading...</div>
<form id="totp-setup">
<h3>Setup Instructions</h3>
<div class="row gx-5">
<div class="col-12 col-lg-6">
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a
href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
other two-factor authentication app</a> that supports TOTP.</p>
<div class="mb-3">
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into
the app:</p>
<div id="totp-setup-qr">
<img class="mt-3 mb-3 ms-auto me-auto" id="twofactor-qrimg">
<div class="input-group">
<label class="input-group-text" for="totp-setup-secret"><b>Secret</b></label>
<input type="text" class="form-control font-monospace" id="totp-setup-secret" disabled />
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="mb-3">
<label for="otp-label" style="font-weight: normal"><b>3.</b> Optionally, give your device a label so
that you can remember what device you set it up on:</label>
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
</div>
<div class="mb-3">
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit
code and enter it here:</label>
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
</div>
</div>
</div>
<div class="form-group">
<div>
<button id="totp-setup-submit" disabled type="submit" class="btn btn-primary">Enable Two-Factor
Authentication</button>
</div>
<small>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will
have to log in
again, now using your two-factor authentication app.</small>
</div>
</form>
<form id="disable-2fa">
<div>
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span
class="font-monospace"></span></span>.</p>
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</div>
<small>You will have to log into the admin panel again after disabling two-factor authentication.</small>
</form>
</div>
<script>
var el = {
disableForm: document.getElementById('disable-2fa'),
totpSetupForm: document.getElementById('totp-setup'),
totpSetupToken: document.getElementById('totp-setup-token'),
totpSetupSecret: document.getElementById('totp-setup-secret'),
totpSetupLabel: document.getElementById('totp-setup-label'),
totpQr: document.getElementById('totp-setup-qr'),
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
wrapper: document.querySelector('.twofactor')
}
function update_setup_disabled(evt) {
var val = evt.target.value.trim();
if (
typeof val !== 'string' ||
typeof el.totpSetupSecret.value !== 'string' ||
val.length !== 6 ||
el.totpSetupSecret.value.length !== 32 ||
!(/^\+?\d+$/.test(val))
) {
el.totpSetupSubmit.setAttribute('disabled', '');
} else {
el.totpSetupSubmit.removeAttribute('disabled');
}
}
function render_totp_setup(provisioned_totp) {
$('#twofactor-qrimg').attr("src", "data:image/png;base64," + provisioned_totp.qr_code_base64);
el.totpSetupSecret.val = provisioned_totp.secret;
el.totpSetupToken.addEventListener('input', update_setup_disabled);
el.totpSetupForm.addEventListener('submit', do_enable_totp);
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
el.wrapper.classList.add('disabled');
}
function render_disable(mfa) {
el.disableForm.addEventListener('submit', do_disable);
el.wrapper.classList.add('enabled');
if (mfa.label) {
$("#mfa-device-label").show()
$("#mfa-device-label .font-monospace").text(mfa.label);
} else {
$("#mfa-device-label").hide()
}
}
function reset_view() {
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
el.disableForm.removeEventListener('submit', do_disable);
el.totpSetupForm.reset();
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
el.totpSetupSecret.setAttribute('value', '');
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
el.totpSetupSubmit.setAttribute('disabled', '');
$('#twofactor-qrimg').attr("src", "");
$('#totp-setup-secret').attr("value", "");
}
function show_mfa() {
reset_view();
api(
'/mfa/status',
'POST',
{},
function (res) {
el.wrapper.classList.add('loaded');
var has_mfa = false;
res.enabled_mfa.forEach(function (mfa) {
if (mfa.type == "totp") {
render_disable(mfa);
has_mfa = true;
}
});
if (!has_mfa)
render_totp_setup(res.new_mfa.totp);
}
);
}
function do_disable(evt) {
evt.preventDefault();
api(
'/mfa/disable',
'POST',
{ type: 'totp' },
function () {
do_logout();
}
);
return false;
}
function do_enable_totp(evt) {
evt.preventDefault();
api(
'/mfa/totp/enable',
'POST',
{
token: $(el.totpSetupToken).val(),
secret: $(el.totpSetupSecret).val(),
label: $(el.totpSetupLabel).val()
},
function (res) { do_logout(); },
function (res) { show_modal_error("Two-Factor Authentication Setup", res); }
);
return false;
}
</script>

View file

@ -0,0 +1,20 @@
<h2>Munin Monitoring</h2>
<style>
</style>
<p>Opening munin in a new tab... You may need to allow pop-ups for this site.</p>
<script>
function show_munin() {
// Set the cookie.
api(
"/munin",
"GET",
{},
function (r) {
// Redirect.
window.open("/admin/munin/index.html", "_blank");
});
}
</script>

View file

@ -1,268 +1,287 @@
<style>
#pgp_keyring_config .status-error {
color: rgb(140, 0, 0);
#pgp_keyring_config .icon {
min-width: 2em;
}
#pgp_keyring_config .status-warning {
color: rgb(170, 120, 0);
.inline {
display: inline;
}
#pgp_keyring_config .status-ok {
color: rgb(0, 140, 0);
}
#pgp_keyring_config .status-none {
color: rgb(190, 190, 190);
}
#pgp_keyring_config #uids {
white-space: pre-line;
}
</style>
<h2>PGP Keyring Management</h2>
<template id="pgpkey-template">
<tr>
<td>
<div id="trustlevel" style="font-size: 14pt;"><b>Trust Level:</b> Ultimate</div>
<code id="uids">
🤖 Power Mail-in-a-Box Management Daemon &lt;administrator@mailinabox.lan&gt;
</code>
<h3 style="font-size: 12pt;">Subkeys</h3>
<table id="subkeys">
<tr id="subkey-template">
<td id="ismaster">🔑</td>
<td>
<b>
<a id="sign">S</a>
<a id="cert">C</a>
<a id="encr">E</a>
<a id="auth">A</a>
</b>
</td>
<td style="width: 120pt;">
<b id="algo">RSA, 3072 bit</b>
</td>
<td>
<pre id="fpr">1756 6B81 D8A4 24C7 0098 659E 6872 2633 F692 52C6</pre>
</td>
<td id="expiration">
12/12/20 (119 days)
</td>
</tr>
</table>
</td>
<td id="options" style="width: 140pt;">
</td>
</tr>
<tr>
<td class="row p-3">
<div class="col-lg-10 col-12">
<b id="isrevoked" class="fs-4"></b>
<div id="uids" class="mb-3 font-monospace">
<div id="uid-template">
<span class="icon fas"></span><span id="id" class="status-text"></span>
</div>
</div>
<b class="fs-5">Subkeys</b>
<div id="subkeys" class="align-middle col-12">
<div class="row" id="subkey-template">
<div style="width: 2em;" id="ismaster" data-bs-toggle="tooltip" title="Master Key"></div>
<div style="width: 5em;">
<b>
<span class="sym" id="sign">S</span>
<span class="sym" id="cert">C</span>
<span class="sym" id="encr">E</span>
<span class="sym" id="auth">A</span>
</b>
</div>
<div class="col-md-2 col-4" id="algo">
<b class="status-text"></b>
</div>
<div class="col" id="expiration">
<span class="status-text"></span>
</div>
<div class="col-md-6 col-12" id="fpr">
<span class="status-text font-monospace"></span>
</div>
</div>
</div>
</div>
<div id="options" class="col-lg-2 col-12">
</div>
</td>
</tr>
</template>
<div id="pgp_keyring_config">
<h3>Daemon's Private Key</h3>
<table id="privatekey" class="table container">
</table>
<h3>Daemon's Private Key</h3>
<table id="privatekey" class="container">
</table>
<h3>Imported Public Keys</h3>
<table id="pubkeys" class="table container">
</table>
<h3>Imported Public Keys</h3>
<table id="pubkeys" class="container">
</table>
<h3>Import Key</h3>
<p>
You can upload your <b>public</b> key/keychain here. Keys <b>must</b> be submitted in ASCII-armored format.
<br>
If you're using <code>gpg</code>, you can export your public key by following this example:
<pre>
<h3>Import Key</h3>
<p>
You can upload your <b>public</b> key/keychain here. Keys <b>must</b> be submitted in ASCII-armored format.
<br>
If you're using <code>gpg</code>, you can export your public key by following this example:
<pre>
# Get all the keys in the ring
<b>$ gpg --list-keys</b>
$ <b>gpg --list-keys</b>
/home/you/.gnupg/pubring.kbx
----------------------------
pub rsa4096 1970-01-01 [SC]
247C3553B4B36107BA0490C3CAFCCF3B4965761A
uid [ full ] Someone That I Used to Know &lt;someone@example.com&gt;
sub rsa2048 2020-01-01 [E] [expires: 2021-01-01]
sub rsa2048 2020-01-01 [E] [expires: 2069-12-31]
pub rsa4096 2020-05-24 [SC] [expires: 2021-02-12]
pub rsa4096 1970-01-01 [SC] [expires: 2069-12-31]
52661092E5CD9EEFD7796B19E85F540C9318B69F
uid [ultimate] Me, Myself and I &lt;me@mydomain.com&gt;
sub rsa2048 2020-05-24 [E] [expires: 2021-02-12]
uid [ultimate] Me, Myself and I &lt;me@example.net&gt;
sub rsa2048 2020-05-24 [E] [expires: 2069-12-31]
# Say that we want to export our own key, this is - "Me, Myself and I". That's the fingerprint "52661..."
<b>$ gpg --export --armor 52661092E5CD9EEFD7796B19E85F540C9318B69F</b> # Replace with your key's fingerprint
# Let's export the key "Me, Myself and I"
$ <b>gpg --export --armor 52661092E5CD9EEFD7796B19E85F540C9318B69F</b>
<b>-----BEGIN PGP PUBLIC KEY BLOCK-----
copy and paste this block in the area below
-----END PGP PUBLIC KEY BLOCK-----</b>
</pre>
</p>
<p><textarea id="pgp_paste_key" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#xA;stuff here&#xA;-----END PGP PUBLIC KEY BLOCK-----"></textarea></p>
<button class="btn btn-primary" onclick="importkey()">Import Key</button>
</p>
<div class="form-floating col-12 col-xl-6 mb-3">
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#xA;stuff here&#xA;-----END PGP PUBLIC KEY BLOCK-----"></textarea>
<label for="pgp_paste_key">Paste your PGP public key here</label>
</div>
<button class="btn btn-primary" onclick="importkey()">Import Key</button>
</div>
<script>
function pretty_fpr(fpr) {
let pfpr = ""
for (let n = 0; n < 2; ++n) {
for (let i = 0; i < 5; ++i) {
pfpr += `${fpr.substring(n * 20 + i * 4, n * 20 + (i + 1) * 4)} `
}
pfpr += " "
}
function pretty_fpr(fpr) {
let pfpr = ""
for (let n = 0; n < 2; ++n) {
for (let i = 0; i < 5; ++i) {
pfpr += `${fpr.substring(n * 20 + i * 4, n * 20 + (i + 1) * 4)} `
}
pfpr += " "
}
return pfpr.substring(0, pfpr.length - 2)
}
return pfpr.substring(0, pfpr.length - 2)
}
function key_html(key, darken_bg, daemon) {
let keyrep = $("#pgpkey-template").html()
keyrep = $(keyrep)
keyrep.attr("id", key.master_fpr)
function key_html(key, darken_bg, daemon) {
let keyrep = $("#pgpkey-template").html()
console.log(keyrep)
keyrep = $(keyrep)
keyrep.attr("id", key.master_fpr)
// Main key config
if (darken_bg) {
keyrep.addClass("bg-light")
}
// Main key config
if (darken_bg) {
keyrep.addClass("bg-light")
}
const tlevel = keyrep.find("#trustlevel")
if (key.revoked) {
tlevel.html("<b class='status-error'>This key was revoked by it's owner.</b>")
} else {
tlevel.html("<b>Key is not revoked.</b>")
}
let rev = keyrep.find("#isrevoked")
let uidtxt = ""
if (daemon) {
key.ids.forEach(id => {
uidtxt += "🤖 " + id + "\n"
});
} else {
key.ids.forEach(id => {
uidtxt += "🕵 " + id + "\n"
});
}
keyrep.find("#uids").text(uidtxt.substring(0, uidtxt.length - 1))
if (key.revoked) {
rev.text("This key was revoked by it's owner.")
rev.addClass(".status-error")
} else {
rev.text("Key is not revoked.")
}
// Subkeys
const keyflags = ["sign", "cert", "encr", "auth"]
let subkeys = keyrep.find("#subkeys")
let subkeytemplate = subkeys.html()
keyrep.find("#subkey-template").remove()
key.subkeys.forEach(skey => {
let skeyrep = $(subkeytemplate)
skeyrep.attr("id", `sub${skey.fpr}`)
// Master key?
if (skey.master) {
skeyrep.find("#ismaster").html("🔑")
} else {
skeyrep.find("#ismaster").html("")
}
let uids = keyrep.find("#uids")
let uidtemplate = $(uids.html())
uids.find("#uid-template").remove()
if (daemon) {
uidtemplate.find(".icon").addClass("fa-robot")
} else {
uidtemplate.find(".icon").addClass("fa-user")
}
// Usage flags
keyflags.forEach(flag => {
if (!skey[flag]) {
skeyrep.find(`#${flag}`).addClass("status-none")
}
});
key.ids.forEach(id => {
let thisuid = uidtemplate.clone()
thisuid.attr("id", "")
thisuid.find("#id").text(id)
thisuid.appendTo(uids)
});
// Algorithm and fingerprint
skeyrep.find("#algo").html(`${skey.algorithm}, ${skey.bits} bits`)
skeyrep.find("#fpr").html(pretty_fpr(skey.fpr))
let expiration = skeyrep.find("#expiration")
// Subkeys
const keyflags = ["sign", "cert", "encr", "auth"]
let subkeys = keyrep.find("#subkeys")
let subkeytemplate = subkeys.html()
keyrep.find("#subkey-template").remove()
key.subkeys.forEach(skey => {
let skeyrep = $(subkeytemplate)
skeyrep.attr("id", `sub${skey.fpr}`)
// Expiration
if (key.revoked) {
skeyrep.addClass("status-error")
expiration.html(`Revoked`)
} else if (skey.expired) {
skeyrep.addClass("status-error")
expiration.html(`${skey.expires_date} (expired)`)
} else if (skey.expires && skey.expires_days <= 14) {
skeyrep.addClass("status-warning")
expiration.html(`${skey.expires_date} (${skey.expires_days} days)`)
} else if (skey.expires) {
skeyrep.addClass("status-ok")
expiration.html(`${skey.expires_date} (${skey.expires_days} days)`)
} else {
skeyrep.addClass("status-ok")
expiration.html("Does not expire")
}
skeyrep.appendTo(subkeys)
});
let statusclass
let expiration = skeyrep.find("#expiration")
let exptxt = expiration.find(".status-text")
// Options
if (daemon) {
keyrep.find("#options").html(`<button class="btn btn-primary btn-block" onclick="exportkey('${key.master_fpr}')">Export Public Key</button>`)
} else {
keyrep.find("#options").html(`<button class="btn btn-secondary btn-block" onclick="exportkey('${key.master_fpr}')">Export Public Key</button><button class="btn btn-danger btn-block" onclick="rmkey('${key.master_fpr}')">Remove Key</button>`)
}
if (key.revoked) {
statusclass = "status-error"
exptxt.text("Revoked")
} else if (skey.expired) {
statusclass = "status-error"
exptxt.text(`${skey.expires_date} (expired)`)
} else if (skey.expires && skey.expires_days <= 14) {
statusclass = "status-warning"
exptxt.text(`${skey.expires_date} (${skey.expires_days} days)`)
} else if (skey.expires) {
statusclass = "status-ok"
exptxt.text(`${skey.expires_date} (${skey.expires_days} days)`)
} else {
statusclass = "status-ok"
exptxt.text("Does not expire")
}
return keyrep
}
expiration.addClass(statusclass)
function show_pgp_keyring() {
$('#privatekey').html("<tr><td class='text-muted'>Loading...</td></tr>")
$('#pubkeys').html("<tr><td class='text-muted'>Loading...</td></tr>")
api(
"/system/pgp/",
"GET",
{},
function(r) {
$('#privatekey').html("")
$('#pubkeys').html("")
key_html(r.daemon, true, true).appendTo("#privatekey")
let pendulum = 1
r.imported.forEach(k => {
key_html(k, pendulum > 0, false).appendTo("#pubkeys")
pendulum *= -1
});
}
)
}
// Master key?
if (skey.master) {
skeyrep.find("#ismaster").addClass("fas fa-key")
}
function exportkey(fpr) {
api(
`/system/pgp/${fpr}/export`,
"GET",
{},
function(r) {
show_modal_error("PGP Key", `Key export for <b>${fpr}</b>:<br><br><pre>${r}</pre>`)
},
function(_ ,xhr) {
if (xhr.status == 404) {
show_modal_error("Error", `The key you asked for (<b>${fpr}</b>) does not exist!`)
} else {
// Fallback to the default error modal
show_modal_error("Error", "Something went wrong, sorry.")
}
}
)
}
// Usage flags
keyflags.forEach(flag => {
skeyrep.find(`#${flag}`).addClass(skey[flag] ? statusclass : "status-na")
});
function rmkey(fpr) {
show_modal_confirm("Delete key", `Are you sure you wish to remove the key with the fingerprint ${pretty_fpr(fpr)}?`, "Yes, remove it", () => {
api(
`/system/pgp/${fpr}`,
"DELETE",
{},
function(r) {
show_modal_error("Delete key", r, show_pgp_keyring)
},
function(r) {
show_modal_error("Key deletion error", r)
}
)
}, ()=>{})
}
// Algorithm and fingerprint
let algo = skeyrep.find("#algo")
algo.addClass(statusclass)
algo.find(".status-text").html(`${skey.algorithm}, ${skey.bits} bits`)
function importkey() {
api(
"/system/pgp/import",
"POST",
{
key: $("#pgp_paste_key").val()
},
function(r) {
show_modal_error("Import Results", `<ul>
let fpr = skeyrep.find("#fpr")
fpr.addClass(statusclass)
fpr.find(".status-text").html(pretty_fpr(skey.fpr))
skeyrep.appendTo(subkeys)
});
// Options
if (daemon) {
keyrep.find("#options").html(`<button class="btn btn-primary col-12" onclick="exportkey('${key.master_fpr}')">Export Public Key</button>`)
} else {
keyrep.find("#options").html(`<button class="btn btn-secondary col-12 mb-3" onclick="exportkey('${key.master_fpr}')">Export Public Key</button><button class="btn btn-danger col-12" onclick="rmkey('${key.master_fpr}')">Remove Key</button>`)
}
return keyrep
}
function show_pgp_keyring() {
$('#privatekey').html("<tr><td class='text-muted'>Loading...</td></tr>")
$('#pubkeys').html("<tr><td class='text-muted'>Loading...</td></tr>")
api(
"/system/pgp/",
"GET",
{},
function (r) {
$('#privatekey').html("")
$('#pubkeys').html("")
key_html(r.daemon, true, true).appendTo("#privatekey")
let pendulum = 1
r.imported.forEach(k => {
key_html(k, pendulum > 0, false).appendTo("#pubkeys")
pendulum *= -1
});
}
)
}
function exportkey(fpr) {
api(
`/system/pgp/${fpr}/export`,
"GET",
{},
function (r) {
show_modal_error("PGP Key", `Key export for <b>${fpr}</b>:<br><br><pre>${r}</pre>`)
},
function (_, xhr) {
if (xhr.status == 404) {
show_modal_error("Error", `The key you asked for (<b>${fpr}</b>) does not exist!`)
} else {
// Fallback to the default error modal
show_modal_error("Error", "Something went wrong, sorry.")
}
}
)
}
function rmkey(fpr) {
show_modal_confirm("Delete key", `Are you sure you wish to remove the key with the fingerprint ${pretty_fpr(fpr)}?`, "Yes, remove it", () => {
api(
`/system/pgp/${fpr}`,
"DELETE",
{},
function (r) {
if (r.length == 0) {
show_modal_error("Delete key", "OK", show_pgp_keyring)
} else {
wkd_info = "OK. <b>The following entries were removed from WKD:</b><ul>"
r.forEach(email => {
wkd_info += `<li>${email}</li>`
});
wkd_info += "</ul>"
show_modal_error("Delete key", wkd_info, show_pgp_keyring)
}
},
function (r) {
show_modal_error("Key deletion error", r)
}
)
}, () => { })
}
function importkey() {
api(
"/system/pgp/import",
"POST",
{
key: $("#pgp_paste_key").val()
},
function (r) {
show_modal_error("Import Results", `<ul>
<li><b>Keys read:</b> ${r.keys_read}</li>
<li><b>Keys added:</b> ${r.keys_added}</li>
<li><b>Keys not changed:</b> ${r.keys_unchanged}</li>
@ -270,10 +289,10 @@ copy and paste this block in the area below
<li><b>Signatures added:</b> ${r.sigs_added}</li>
<li><b>Revocations added:</b> ${r.revs_added}</li>
</ul>`, show_pgp_keyring)
},
function(r) {
show_modal_error("Import Error", r)
}
)
}
},
function (r) {
show_modal_error("Import Error", r)
}
)
}
</script>

View file

@ -3,98 +3,133 @@
<h2>SMTP Relays</h2>
<p>SMTP Relays are third-party services you can hand off the responsability of getting the mail delivered. They
can be useful when, for example, port 25 is blocked.</p>
<p>SMTP Relays are third-party services that can deliver email on your behalf. They
can be useful when, for example, port 25 is blocked, the cloud provider/ISP doesn't provide Reverse DNS, or the IP
address
has a low reputation, among other situations where deliverablity isn't great.</p>
<p>Here, you can configure an authenticated SMTP relay (for example, <a href="https://sendgrid.com/"
target="_blank">SendGrid</a>) over port 587.</p>
<p>These services are governed by their own terms and as such limits can be imposed in the usage of those services.</p>
<p>Here, you can configure an authenticated SMTP relay and authorize it's associated servers to send mail for you.</p>
<div id="smtp_relay_config">
<h3>SMTP Relay Configuration</h3>
<form class="form-horizontal" role="form" onsubmit="set_smtp_relay_config(); return false;">
<div class="form-group">
<table id="smtp-relays" class="table" style="width: 600px">
<tr>
<td>
<label for="use_relay" class="col-sm-1 control-label">Use Relay?</label>
</td>
<td>
<div class="col-sm-10">
<input type="checkbox" id="use_relay" name="use_relay" value="true"
onclick="checkfields();">
</div>
</td>
</tr>
<h3>SMTP Relay Configuration</h3>
<div id="smtp-relays" class="col-12">
<tr>
<td>
<label for="relay_host" class="col-sm-1 control-label">Hostname</label>
</td>
<td>
<div class="col-sm-10">
<input type="text" class="form-control" id="relay_host" placeholder="host.domain.tld">
</div>
</td>
<td style="padding: 0; font-weight: bold;">:587</td>
</tr>
<div class="input-group mb-3">
<label for="use_relay" class="input-group-text"><b>Use Relay?</b></label>
<div class="input-group-text">
<div class="form-switch">
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false
onclick="checkfields();">
</div>
</div>
</div>
<tr>
<td>
<label for="relay_use_auth" class="col-sm-1 control-label">Authenticate</label>
</td>
<td>
<div class="col-sm-10">
<input checked type="checkbox" id="relay_use_auth" name="relay_use_auth" value="true"
onclick="checkfields();">
</div>
</td>
</tr>
<div class="input-group mb-3">
<label class="input-group-text">Where to?</label>
<input type="text" class="form-control" id="relay_host" placeholder="relay.example.com">
<label class="input-group-text">:</label>
<div style="width: 20%">
<input type="number" class="form-control" id="relay_port" placeholder="Port (465)">
</div>
</div>
<tr>
<td>
<label for="relay_auth_user" class="col-sm-1 control-label">Username</label>
</td>
<td>
<div class="col-sm-10">
<input type="text" class="form-control" id="relay_auth_user" placeholder="user">
</div>
</td>
</tr>
<div class="input-group mb-3">
<label class="input-group-text">Username</label>
<input type="text" class="form-control" id="relay_auth_user" placeholder="">
</div>
<tr>
<td>
<label for="relay_auth_pass" class="col-sm-1 control-label">Password/Key</label>
</td>
<td>
<div class="col-sm-10">
<input type="password" class="form-control" id="relay_auth_pass" placeholder="password">
</div>
</td>
</tr>
</table>
<div>
<div class="input-group">
<label class="input-group-text">Password/Key</label>
<input type="password" class="form-control" id="relay_auth_pass" placeholder="">
</div>
<p class="small">If you've already set up a relay before on this box, you can leave this field blank if
you don't want to change it's password.</p>
</div>
</div>
<h3>Authorized Servers</h3>
<p>The relay service should specify the servers where the email will be sent from, please add them below. These
will probably be published in the form of a SPF record. <b>Failure to do so will potentially have your email
sent to spam or even rejected altogether by recipients.</b>
</p>
<p>You can use the button below to attempt to localize the SPF record associated with the service you're using.
</p>
<button id="smtp_relay_autospf_btn" type="button" class="btn btn-secondary" onclick="autodetect_spf()">Detect
SPF
Records</button>
<div id="smtp_relay_autospf"></div>
<br>
<div class="form-group">
<h4>Add your SPF configuration/authorized servers here</h4>
<input type="text" class="form-control" id="relay_authorized_servers"
placeholder="mail1.example.net mail2.example.net">
<p class="small">You can separate multiple servers with commas or spaces. You can also add IP addresses or
subnets using <code>10.20.30.40</code> or <code>10.0.0.0/8</code>. You can "import" SPF records using
<code>spf:example.com</code>. If your provider gave you an SPF record to add to your DNS, you can also paste it here.
</p>
</div>
<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>
<div class="col-lg-6 col-md-8 col-12">
<div class="input-group">
<input type="text" class="form-control" id="relay_dkim_selector" placeholder="selector"></td>
<label class="input-group-text" for="relay_dkim_selector">._domainkey.{{hostname}}</label>
</div>
</div>
<h4>Paste the DKIM key here:</h4>
<p><textarea id="relay_dkim_key" class="form-control" style="width: 100%; height: 8em"
placeholder="k=algo;p=K3y/C0N7ent5/dGhpcyBpcyBub3QgYSByZWFsIGtleSwgc28gYmV3YXJlIDrigb4"></textarea>
</p>
<div>
<button type="submit" class="btn btn-primary">Update</button>
</div>
<h3>After configuration</h3>
<p>By that time you should be good to go. If your relay provider provides their own custom DNS verification
methods (<b>including custom DMARC configurations</b>), feel free to publish them on DNS.</p>
</form>
</div>
<script>
const use_relay = document.getElementById("use_relay")
const relay_host = document.getElementById("relay_host")
const relay_use_auth = document.getElementById("relay_use_auth")
const relay_port = document.getElementById("relay_port")
const relay_auth_user = document.getElementById("relay_auth_user")
const relay_auth_pass = document.getElementById("relay_auth_pass")
const relay_authorized_servers = document.getElementById("relay_authorized_servers")
const relay_spf_discover = document.getElementById("smtp_relay_autospf_btn")
const relay_spf_discover_results = document.getElementById("smtp_relay_autospf")
const relay_dkim_sel = document.getElementById("relay_dkim_selector")
const relay_dkim_key = document.getElementById("relay_dkim_key")
function checkfields() {
let relay_enabled = use_relay.checked
let auth_enabled = relay_use_auth.checked
relay_host.disabled = !relay_enabled
relay_use_auth.disabled = !relay_enabled
relay_auth_user.disabled = !(relay_enabled && auth_enabled)
relay_auth_pass.disabled = !(relay_enabled && auth_enabled)
relay_port.disabled = !relay_enabled
relay_auth_user.disabled = !relay_enabled
relay_auth_pass.disabled = !relay_enabled
relay_authorized_servers.disabled = !relay_enabled
relay_spf_discover.disabled = !relay_enabled
relay_spf_discover_results.innerHTML = ""
relay_dkim_sel.disabled = !relay_enabled
relay_dkim_key.disabled = !relay_enabled
}
function show_smtp_relays() {
@ -105,9 +140,23 @@
data => {
use_relay.checked = data.enabled
relay_host.value = data.host
relay_use_auth.checked = data.auth_enabled
relay_port.value = data.port
relay_auth_user.value = data.user
relay_auth_pass.value = ""
relay_authorized_servers.value = ""
if (data.spf_record) {
relay_authorized_servers.value = data.spf_record
}else if (data.authorized_servers) {
data.authorized_servers.forEach(element => {
relay_authorized_servers.value += `${element} `
});
}
if (data.dkim_selector) {
relay_dkim_sel.value = data.dkim_selector
relay_dkim_key.value = data.dkim_rr
}
checkfields()
}
@ -115,21 +164,159 @@
}
function set_smtp_relay_config() {
let relay_configuration = {
enabled: use_relay.checked,
host: relay_host.value,
port: relay_port.value,
user: relay_auth_user.value,
key: relay_auth_pass.value,
dkim_selector: relay_dkim_sel.value,
dkim_rr: relay_dkim_key.value
}
if (relay_authorized_servers.value.substr(0, 7) === "v=spf1 ") {
relay_configuration.spf_record = relay_authorized_servers.value
} else {
relay_configuration.authorized_servers = relay_authorized_servers.value
}
api(
"/system/smtp/relay",
"POST",
{
enabled: use_relay.checked,
host: relay_host.value,
auth_enabled: relay_use_auth.checked,
user: relay_auth_user.value,
key: relay_auth_pass.value
},
relay_configuration,
() => {
show_modal_error("Done!", "The configuration has been updated and Postfix was restarted successfully. Please make sure everything is functioning as intended.", () => {
return false
})
},
(e) => {
show_modal_error("Error!", e, () => { return false })
}
)
}
function add_spf(domain) {
document.getElementById(`smtp_relay_spfinclude_${domain}`).disabled = true
let impl = false
relay_authorized_servers.value.split(/[\s,]+/).forEach(s => {
if (s === `spf:${domain}`) {
impl = true
}
});
if (!impl) {
relay_authorized_servers.value += ` spf:${domain}`
}
}
async function autodetect_spf() {
let btn = $("#smtp_relay_autospf_btn")
let results = $("#smtp_relay_autospf")
let host = relay_host.value
if (host.trim() == "") {
results.html("<b>Error:</b> No hostname specified.")
return
}
let hmatches = host.match(/([^\s.\\\/]+\.)+([^\s.\\\/]+)/)
if (!hmatches || hmatches[0] != host) {
results.html(`<b>Error: <code>${host}</code></b> is not a valid hostname.`)
return
}
btn.html("Working...")
btn.prop("disabled", true)
results.html("")
let base_host = hmatches[hmatches.length - 2] + hmatches[hmatches.length - 1]
function record_html(name, rec) {
if (rec.error) {
return `<b>${name}</b> - ${rec.error} Error (${rec.msg})`
} else {
return `<button id="smtp_relay_spfinclude_${name}" type="button" class="btn btn-secondary" onclick="add_spf('${name}')">Include</button> <b>${name}</b> <code>${rec.msg}</code>`
}
}
let records = []
await Promise.all([
query_spf(base_host).then(rr => { records[0] = record_html(base_host, rr) }),
query_spf(`spf.${base_host}`).then(rr => { records[1] = record_html(`spf.${base_host}`, rr) }),
query_spf(`_spf.${base_host}`).then(rr => { records[2] = record_html(`_spf.${base_host}`, rr) })
])
let txt = "<h4>Here's what I've found:</h4><ul>"
records.forEach((r) => {
txt += `<li>${r}</li>`
})
txt += "</ul><small>Only some common subdomains are tested here, so it's possible I've missed some. You <b>SHOULD NOT</b> include records you do not recognize, else there will be servers that can send mail as you, but that you will not use.</small>"
results.html(txt)
btn.html("Detect SPF Records")
btn.prop("disabled", false)
}
function query_spf(hostname) {
// We use Cloudflare's DNS servers for this (DNS over HTTPS)
return new Promise((resolve, _) => {
$.ajax({
url: "https://cloudflare-dns.com/dns-query",
headers: {
accept: "application/dns-json"
},
method: "GET",
data: {
name: hostname,
type: "TXT",
do: false,
cd: false
},
success: (data) => {
const RRTXT = 16
const status_description = [
// From https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
// (last checked: 3rd June 2021)
"Success",
"Format Error",
"Server Failure",
"Non-Existent Domain",
"Not Implemented",
"Query Refused",
"Name exists when it should not",
"RR set exists when it should not",
"RR set that should exist does not",
"Server is not authoritative for zone",
"Not Authorized",
"Name not contained in zone",
"DSO-TYPE Not Implemented"
]
if (data.Status != 0) {
if (data.Status > 11) {
return resolve({ error: "DNS", msg: "Unknown Error" })
} else {
return resolve({ error: "DNS", msg: status_description[data.Status] })
}
}
if (data.Answer) {
data.Answer.forEach(ans => {
if (ans.type == 16 && ans.name == hostname && ans.data.substring(1, 7) == "v=spf1") {
return resolve({ msg: ans.data.substring(1, ans.data.length - 1) })
}
})
}
return resolve({ error: "DNS", msg: "No SPF record found" })
},
error: (_, __, err) => {
resolve({ error: "HTTP", msg: err })
}
})
})
}
</script>

View file

@ -1,237 +1,307 @@
<style>
.code-white>code {
color: #ffffff
}
#ssl_provision_result>div>div {
white-space: pre;
}
#ssl_domains .icon {
min-width: 1.6em;
}
</style>
<h2>TLS (SSL) Certificates</h2>
<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address that the connection is secure between you and the owner of that address.</p>
<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address
that the connection is secure between you and the owner of that address.</p>
<p>You need a TLS certificate for this box&rsquo;s hostname ({{hostname}}) and every other domain name and subdomain that this box is hosting a website for (see the list below).</p>
<p>You need a TLS certificate for this box&rsquo;s hostname ({{hostname}}) and every other domain name and subdomain
that this box is hosting a website for (see the list below).</p>
<div id="ssl_provision">
<h3>Provision certificates</h3>
<h3>Provision certificates</h3>
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
<button onclick='return provision_tls_cert();' class='btn btn-primary' style="float: left; margin: 0 1.5em 1em 0;">Provision</button>
<p><b>By provisioning the certificates, you&rsquo;re agreeing to the <a href="https://acme-v01.api.letsencrypt.org/terms">Let&rsquo;s Encrypt Subscriber Agreement</a>.</b></p>
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/" target="_blank">Let&rsquo;s Encrypt</a>, a free TLS certificate provider, for:<br>
<span class="text-primary"></span></p>
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/"
target="_blank">Let&rsquo;s Encrypt</a>, a free TLS certificate provider, for:</p>
<ul class="text-primary"></ul>
<div class="container input-group mt-3" style="overflow-x: auto;">
<button id="ssl_provision_button" class="btn btn-primary"
onclick="return provision_tls_cert();">Provision</button>
<label class="input-group-text" for=""><b>By provisioning the certificates, you&rsquo;re agreeing to the <a
href="https://letsencrypt.org/repository">Let&rsquo;s Encrypt Subscriber Agreement</a>.</b></label>
</div>
</div>
</div>
<div class="clearfix"> </div>
<div id="ssl_provision_result"></div>
</div>
<div id="ssl_provision_result" class="mt-4"></div>
<h3>Certificate status</h3>
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed through <a href="https://letsencrypt.org/" target="_blank">Let&rsquo;s Encrypt</a> 14 days prior to expiration.</p>
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed
through <a href="https://letsencrypt.org/" target="_blank">Let&rsquo;s Encrypt</a> 14 days prior to expiration.</p>
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto; display: none">
<thead>
<tr>
<th>Domain</th>
<th>Certificate Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
</tbody>
<table id="ssl_domains" class="table align-middle col-12" style="margin-bottom: 2em; display: none">
<caption></caption>
<thead>
<tr>
<th>Domain</th>
<th>Certificate Status</th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<h3 id="ssl_install_header">Install certificate</h3>
<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try. You can generate the needed CSR below.</p>
<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try.
You can generate the needed CSR below.</p>
<p>Which domain are you getting a certificate for?</p>
<div class="col-lg-10 col-xl-8">
<div class="input-group">
<label class="input-group-text" for="ssldomain">Which domain are you getting a certificate for?</label>
<select id="ssldomain" onchange="show_csr()" class="form-select"></select>
</div>
</div>
<p><small>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides
the one you choose above.</small></p>
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
<div class="col-lg-10 col-xl-8">
<div class="input-group">
<label class="input-group-text" for="sslcc">What country are you in?</label>
<select id="sslcc" onchange="show_csr()" class="form-select">
<option value="">(Select)</option>
{% for code, name in csr_country_codes %}
<option value="{{code}}">{{name}}</option>
{% endfor %}
</select>
</div>
</div>
<p><small>This is required by some TLS certificate providers. You may just pick any if you know your TLS certificate
provider doesn't require it.</small></p>
<p>(A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides the one you choose above.)</p>
<div id="csr_info" style="display: none;">
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
<p>What country are you in? This is required by some TLS certificate providers. You may leave this blank if you know your TLS certificate provider doesn't require it.</p>
<div class="col-lg-10 col-xl-8 mb-3">
<textarea class="form-control font-monospace" id="ssl_csr" style="min-height: 26.5em;" readonly></textarea>
</div>
<p><select id="sslcc" onchange="show_csr()" class="form-control" style="width: auto">
<option value="">(Select)</option>
{% for code, name in csr_country_codes %}
<option value="{{code}}">{{name}}</option>
{% endfor %}
</select></p>
<small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small>
<div id="csr_info" style="display: none">
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
<p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an
intermediate chain. Paste each separately into the boxes below:</p>
<pre id="ssl_csr"></pre>
<div class="row g-4">
<div class="form-floating col-12 col-xl-6">
<textarea id="ssl_paste_cert" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----"></textarea>
<label for="ssl_paste_cert" class="ms-3">TLS/SSL Certificate</label>
</div>
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
<div class="form-floating col-12 col-xl-6">
<textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----END CERTIFICATE-----"></textarea>
<label for="ssl_paste_chain" class="ms-3">TLS/SSL intermediate Chain (if provided)</label>
</div>
</div>
<p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>
<br>
<p style="margin-bottom: .5em">TLS/SSL certificate:</p>
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----"></textarea></p>
<p style="margin-bottom: .5em">TLS/SSL intermediate chain (if provided):</p>
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----END CERTIFICATE-----"></textarea></p>
<p>After you paste in the information, click the install button.</p>
<button class="btn-primary" onclick="install_cert()">Install</button>
<button class="btn btn-primary" onclick="install_cert()">Install</button>
</div>
<script>
function show_tls(keep_provisioning_shown) {
api(
"/ssl/status",
"GET",
{
},
function(res) {
// provisioning status
function show_tls(keep_provisioning_shown) {
api(
"/ssl/status",
"GET",
{
},
function (res) {
// provisioning status
if (!keep_provisioning_shown)
$('#ssl_provision').toggle(res.can_provision.length > 0)
$("#ssl_provision_p ul").html("")
if (!keep_provisioning_shown)
$('#ssl_provision').toggle(res.can_provision.length > 0)
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
if (res.can_provision.length > 0)
$('#ssl_provision_p span').text(res.can_provision.join(", "));
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
if (res.can_provision.length > 0) {
let hostlist = $("#ssl_provision_p ul")
res.can_provision.forEach(domain => {
$("#ssl_provision_p ul").append($("<li></li>").text(domain))
});
}
// certificate status
var domains = res.status;
var tb = $('#ssl_domains tbody');
tb.text('');
$('#ssldomain').html('<option value="">(select)</option>');
$('#ssl_domains').show();
for (var i = 0; i < domains.length; i++) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.find('.domain a').text(domains[i].domain);
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
if (domains[i].status == "not-applicable") {
domains[i].status = "muted"; // text-muted css class
row.find('.actions a').remove(); // no actions applicable
}
row.addClass("text-" + domains[i].status);
row.find('.status').text(domains[i].text);
if (domains[i].status == "success") {
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
} else {
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
}
// certificate status
var domains = res.status;
var tb = $('#ssl_domains tbody');
tb.text('');
$('#ssldomain').html('<option value="">(select)</option>');
$('#ssl_domains').show();
for (var i = 0; i < domains.length; i++) {
var row = $("<tr><th class='text-wrap'></th><td class='status'><div class='icon fas'></div><span class='status-text'></span></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.find('th').text(domains[i].domain);
if (domains[i].status == "not-applicable") {
row.find('.actions a').remove(); // no actions applicable
}
let statusclass
let faclass
switch (domains[i].status) {
case "success":
statusclass = "status-ok"
faclass = "fa-check"
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
break;
case "warning":
statusclass = "status-warning"
faclass = "fa-exclamation-triangle"
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
break;
case "danger":
statusclass = "status-error"
faclass = "fa-times"
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
break;
default:
statusclass = "status-na"
row.find('.actions a').remove()
break;
}
let txt = row.find(".status")
txt.addClass(statusclass);
txt.find(".fas").addClass(faclass)
txt.find("span").text(domains[i].text);
$('#ssldomain').append($('<option>').text(domains[i].domain));
}
});
}
$('#ssldomain').append($('<option>').text(domains[i].domain));
}
});
}
function ssl_install(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
$('#ssldomain').val(domain);
show_csr();
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
return false;
}
function ssl_install(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
$('#ssldomain').val(domain);
show_csr();
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
return false;
}
function show_csr() {
// Can't show a CSR until both inputs are entered.
if ($('#ssldomain').val() == "") return;
if ($('#sslcc').val() == "") return;
function show_csr() {
// Can't show a CSR until both inputs are entered.
if ($('#ssldomain').val() == "") return;
if ($('#sslcc').val() == "") return;
// Scroll to it and fetch.
$('#csr_info').slideDown();
$('#ssl_csr').text('Loading...');
api(
"/ssl/csr/" + $('#ssldomain').val(),
"POST",
{
countrycode: $('#sslcc').val()
},
function(data) {
$('#ssl_csr').text(data);
});
}
// Scroll to it and fetch.
$('#csr_info').slideDown();
$('#ssl_csr').text('Loading...');
api(
"/ssl/csr/" + $('#ssldomain').val(),
"POST",
{
countrycode: $('#sslcc').val()
},
function (data) {
$('#ssl_csr').text(data);
});
}
function install_cert() {
api(
"/ssl/install",
"POST",
{
domain: $('#ssldomain').val(),
cert: $('#ssl_paste_cert').val(),
chain: $('#ssl_paste_chain').val()
},
function(status) {
if (/^OK($|\n)/.test(status)) {
console.log(status)
show_modal_error("TLS Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
} else {
show_modal_error("TLS Certificate Installation", status);
}
});
}
function install_cert() {
api(
"/ssl/install",
"POST",
{
domain: $('#ssldomain').val(),
cert: $('#ssl_paste_cert').val(),
chain: $('#ssl_paste_chain').val()
},
function (status) {
if (/^OK($|\n)/.test(status)) {
console.log(status)
show_modal_error("TLS Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function () { show_ssl(); $('#csr_info').slideUp(); });
} else {
show_modal_error("TLS Certificate Installation", status);
}
});
}
function provision_tls_cert() {
// Automatically provision any certs.
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
api(
"/ssl/provision",
"POST",
{ },
function(status) {
// Clear last attempt.
$('#ssl_provision_result').text("");
may_reenable_provision_button = true;
function provision_tls_cert() {
// Automatically provision any certs.
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
api(
"/ssl/provision",
"POST",
{},
function (status) {
// Clear last attempt.
$('#ssl_provision_result').html("");
may_reenable_provision_button = true;
// Nothing was done. There might also be problem domains, but we've already displayed those.
if (status.requests.length == 0) {
show_modal_error("TLS Certificate Provisioning", "There were no domain names to provision certificates for.");
// don't return - haven't re-enabled the provision button
}
// Nothing was done. There might also be problem domains, but we've already displayed those.
if (status.requests.length == 0) {
show_modal_error("TLS Certificate Provisioning", "There were no domain names to provision certificates for.");
// don't return - haven't re-enabled the provision button
}
// Each provisioning API call returns zero or more "requests" which represent
// a request to Let's Encrypt for a single certificate. Normally there is just
// one request (for a single multi-domain certificate).
for (var i = 0; i < status.requests.length; i++) {
var r = status.requests[i];
// Each provisioning API call returns zero or more "requests" which represent
// a request to Let's Encrypt for a single certificate. Normally there is just
// one request (for a single multi-domain certificate).
let global_logs = []
for (var i = 0; i < status.requests.length; i++) {
var r = status.requests[i];
if (r.result == "skipped") {
// not interested --- this domain wasn't in the table
// to begin with
continue;
}
if (r.result == "skipped") {
// not interested --- this domain wasn't in the table
// to begin with
continue;
}
// create an HTML block to display the results of this request
var n = $("<div><h4/><p/></div>");
$('#ssl_provision_result').append(n);
// plain log line
if (typeof r === "string") {
if (r !== "") {
global_logs.append(r)
}
continue;
}
// plain log line
if (typeof r === "string") {
n.find("p").text(r);
continue;
}
let n = $("<div class=\"card mb-3\"><h5 class=\"card-header text-white code-white\"></h5><div class=\"card-body bg-light small\"></div></div>");
$('#ssl_provision_result').append(n);
// show a header only to disambiguate request blocks
if (status.requests.length > 0)
n.find("h4").text(r.domains.join(", "));
if (status.requests.length > 0) {
n.find(".card-header").html(`Logs for ${r.domains.map((h) => { return `<code>${h}</code>` }).join(", ")}`);
}
if (r.result == "error") {
n.find("p").addClass("text-danger").text(r.message);
if (r.result == "error") {
n.find(".card-header").addClass("bg-danger")
} else if (r.result == "installed") {
n.find(".card-header").addClass("bg-success")
setTimeout("show_tls(true)", 1);
}
} else if (r.result == "installed") {
n.find("p").addClass("text-success").text("The TLS certificate was provisioned and installed.");
setTimeout("show_tls(true)", 1); // update main table of certificate statuses, call with arg keep_provisioning_shown true so that we don't clear what we just outputted
// display the detailed log info in case of problems
let logs = r.log.join("\n")
if (logs === "" || logs === null || logs === undefined) {
logs = "Something wrong happened, sorry!"
}
n.find("div").text(logs);
}
}
if (global_logs.length !== 0) {
let card = $("<div class=\"card mb-3\"><div class=\"card-body small\"></div></div>")
$('#ssl_provision_result').append(card);
n.find(".card-body").text(global_logs.join("\n"))
}
// display the detailed log info in case of problems
var trace = $("<div class='small text-muted' style='margin-top: 1.5em'>Log:</div>");
n.append(trace);
for (var j = 0; j < r.log.length; j++)
trace.append($("<div/>").text(r.log[j]));
}
if (may_reenable_provision_button)
$('#ssl_provision_p .btn').removeAttr("disabled");
});
}
if (may_reenable_provision_button) {
$('#ssl_provision_p .btn').removeAttr("disabled");
}
});
}
</script>

View file

@ -5,44 +5,91 @@
<hr>
<div class="row">
<div class="col-sm-6">
<div class="row d-flex">
<div class="col-lg-6">
<h4>In your browser</h4>
<p>You can edit your contacts and calendar from your web browser.</p>
<table class="table">
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
<tr><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
<caption></caption>
<thead>
<tr>
<th>For...</th>
<th>Visit this URL</th>
</tr>
</thead>
<tr>
<th>Contacts</td>
<td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td>
</tr>
<tr>
<th>Calendar</td>
<td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td>
</tr>
</table>
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
complete email address and your mail password.</p>
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>:
your
complete email address and your mail password.</p>
</div>
<div class="col-sm-6">
<div class="col-lg-6">
<h4>On your mobile device</h4>
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
your contacts and calendar may already appear on your device.</p>
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using
Exchange/ActiveSync,
your contacts and calendar may already appear on your device.</p>
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
<table class="table">
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
<tr><td>Contacts and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVdroid</a> ($3.69; free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td></tr>
<tr><td>Only Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free beta</a> (free)</td></tr>
<tr><td>Only Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
<caption></caption>
<thead>
<tr>
<th>For...</th>
<th>Use...</th>
</tr>
</thead>
<tr>
<td>Contacts and Calendar</td>
<td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVx⁵</a> ($5.99;
free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td>
</tr>
<tr>
<td>Only Contacts</td>
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync
free</a> (free)</td>
</tr>
<tr>
<td>Only Calendar</td>
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a>
($2.99)</td>
</tr>
</table>
<p>Use the following settings:</p>
<table class="table">
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
<tr><td>Use SSL</td> <td>Yes</td></tr>
<tr><td>Username</td> <td>Your complete email address.</td></tr>
<tr><td>Password</td> <td>Your mail password.</td></tr>
<tr>
<td>Account Type</td>
<td>CardDAV or CalDAV</td>
</tr>
<tr>
<td>Server Name</td>
<td>{{hostname}}</td>
</tr>
<tr>
<td>Use SSL</td>
<td>Yes</td>
</tr>
<tr>
<td>Username</td>
<td>Your complete email address.</td>
</tr>
<tr>
<td>Password</td>
<td>Your mail password.</td>
</tr>
</table>
</div>
</div>

View file

@ -1,341 +1,416 @@
<style>
#backup-status th { text-align: center; }
#backup-status tr.full-backup td { font-weight: bold; }
#backup-status tr.full-backup td {
font-weight: bold;
}
</style>
<h2>Backup Status</h2>
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can also have it stored on Amazon S3.</p>
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can
also store it on S3-compatible services like Amazon Web Services (AWS).</p>
<h3>Configuration</h3>
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
<div class="form-group">
<label for="backup-target-type" class="col-sm-2 control-label">Backup to:</label>
<div class="col-sm-3">
<select class="form-control" rows="1" id="backup-target-type" onchange="toggle_form()">
<option value="off">Nowhere (Disable Backups)</option>
<option value="local">{{hostname}}</option>
<option value="rsync">rsync</option>
<option value="s3">Amazon S3</option>
</select>
</div>
</div>
<!-- LOCAL BACKUP -->
<div class="form-group backup-target-local">
<div class="col-sm-10 col-sm-offset-2">
<p>Backups are stored on this machine&rsquo;s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
</div>
</div>
<!-- RSYNC BACKUP -->
<div class="form-group backup-target-rsync">
<div class="col-sm-10 col-sm-offset-2">
<div class="col-lg-10 col-xl-8">
<div class="input-group">
<label class="input-group-text" for="backup-target-type">Backup to</label>
<select class="form-select" rows="1" id="backup-target-type" onchange="toggle_form()">
<option value="off">Nowhere (Disable Backups)</option>
<option value="local">{{hostname}}</option>
<option value="rsync">rsync</option>
<option value="s3">S3 (Amazon or compatible) </option>
<option value="b2">Backblaze B2</option>
</select>
</div>
</div>
<p>Backups synced to a remote machine using rsync over SSH, with local
copies in <tt class="backup-location"></tt>. These files are encrypted, so
they are safe to store anywhere.</p> <p>Separately copy the encryption
password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files.</p>
<!-- LOCAL BACKUP -->
<div class="form-group backup-target-local">
<div class="col-lg-10 col-xl-8 mb-3">
<p>Backups are stored on this machine&rsquo;s own hard disk. You are responsible for periodically using SFTP (FTP
over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are
encrypted, so they are safe to store anywhere.</p>
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure
location. You will need this file to decrypt backup files.</p>
</div>
</div>
<!-- RSYNC BACKUP -->
<div class="form-group backup-target-rsync">
<div class="col-lg-10 col-xl-8 mb-3">
</div>
</div>
<div class="form-group backup-target-rsync">
<label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label>
<div class="col-sm-8">
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
</div>
</div>
<div class="form-group backup-target-rsync">
<label for="backup-target-rsync-path" class="col-sm-2 control-label">Path</label>
<div class="col-sm-8">
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" rows="1" id="backup-target-rsync-path">
</div>
</div>
<div class="form-group backup-target-rsync">
<label for="backup-target-rsync-user" class="col-sm-2 control-label">Username</label>
<div class="col-sm-8">
<input type="text" class="form-control" rows="1" id="backup-target-rsync-user">
</div>
</div>
<div class="form-group backup-target-rsync">
<label for="ssh-pub-key" class="col-sm-2 control-label">Public SSH Key</label>
<div class="col-sm-8">
<input type="text" class="form-control" rows="1" id="ssh-pub-key" readonly>
<div class="small" style="margin-top: 2px">
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
of target user on the backup server specified above. That way you'll enable secure and
passwordless authentication from your mail-in-a-box server and your backup server.
</div>
</div>
</div>
<!-- S3 BACKUP -->
<div class="form-group backup-target-s3">
<div class="col-sm-10 col-sm-offset-2">
<p>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</p>
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Amazon S3 bucket.</p>
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-s3-host-select" class="col-sm-2 control-label">S3 Region</label>
<div class="col-sm-8">
<select class="form-control" rows="1" id="backup-target-s3-host-select">
{% for name, host in backup_s3_hosts %}
<option value="{{host}}">{{name}}</option>
{% endfor %}
<option value="other">Other</option>
</select>
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label>
<div class="col-sm-8">
<input type="text" placeholder="Endpoint" class="form-control" rows="1" id="backup-target-s3-host">
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
<div class="col-sm-8">
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-user" class="col-sm-2 control-label">S3 Access Key</label>
<div class="col-sm-8">
<input type="text" class="form-control" rows="1" id="backup-target-user">
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-pass" class="col-sm-2 control-label">S3 Secret Access Key</label>
<div class="col-sm-8">
<input type="text" class="form-control" rows="1" id="backup-target-pass">
</div>
</div>
<!-- Common -->
<div class="form-group backup-target-local backup-target-rsync backup-target-s3">
<label for="min-age" class="col-sm-2 control-label">Days:</label>
<div class="col-sm-8">
<input type="number" class="form-control" rows="1" id="min-age">
<div class="small" style="margin-top: 2px">This is the <i>minimum</i> number of days backup data is kept for. The box makes an incremental backup, so backup data is often kept much longer. An incremental backup file that is less than this number of days old requires that all previous increments back to the most recent full backup, plus that full backup, remain available.</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
</div>
</div>
<p>Backups synced to a remote machine using rsync over SSH, with local
copies in <tt class="backup-location"></tt>. These files are encrypted, so
they are safe to store anywhere.</p>
<p>Separately copy the encryption
password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files.</p>
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
<div class="input-group">
<label class="input-group-text">Where to?</label>
<input type="text" class="form-control" id="backup-target-rsync-host" placeholder="hostname.local">
<label class="input-group-text">:</label>
<div style="width: 20%">
<input type="number" class="form-control" id="backup-target-rsync-port" placeholder="Port (22)">
</div>
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
<div class="input-group">
<label for="backup-target-rsync-path" class="input-group-text">Path</label>
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" id="backup-target-rsync-path">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
<div class="input-group">
<label for="backup-target-rsync-path" class="input-group-text">Username</label>
<input type="text" class="form-control" id="backup-target-rsync-user">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
<label for="ssh-pub-key" class="col-sm-2 control-label">
<h4>Public SSH Key</h4>
</label>
<textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea>
<div class="small" style="margin-top: 2px">
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
of target user on the backup server specified above. That way you'll enable secure and
passwordless authentication from your mail-in-a-box server and your backup server.
</div>
</div>
<!-- S3 BACKUP -->
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group">
<label for="backup-target-s3-host-select" class="input-group-text">S3 Region</label>
<select class="form-select" id="backup-target-s3-host-select">
{% for name, host in backup_s3_hosts %}
<option value="{{host}}">{{name}}</option>
{% endfor %}
<option value="other">Other (non AWS)</option>
</select>
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group">
<label for="backup-target-s3-host" class="input-group-text">S3 Host / Endpoint</label>
<input type="text" placeholder="Endpoint" class="form-control" id="backup-target-s3-host">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group">
<label for="backup-target-s3-path" class="input-group-text">S3 Path</label>
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control"
id="backup-target-s3-path">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group">
<label for="backup-target-user" class="input-group-text">S3 Access Key</label>
<input type="text" class="form-control" id="backup-target-user">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group">
<label for="backup-target-pass" class="input-group-text">S3 Secret Access Key</label>
<input type="text" class="form-control" id="backup-target-pass">
</div>
</div>
<!-- Backblaze -->
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2
bucket. You must have a Backblaze account already.</p>
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.
</p>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<div class="input-group">
<label for="backup-target-b2-user" class="input-group-text">B2 Application KeyID</label>
<input type="text" class="form-control" id="backup-target-b2-user">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<div class="input-group">
<label for="backup-target-b2-pass" class="input-group-text">B2 Application Key</label>
<input type="text" class="form-control" id="backup-target-b2-pass">
</div>
</div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<div class="input-group">
<label for="backup-target-b2-bucket" class="input-group-text">B2 Bucket</label>
<input type="text" class="form-control" id="backup-target-b2-bucket">
</div>
</div>
<!-- Common -->
<div class="backup-target-local backup-target-rsync backup-target-s3 backup-target-b2">
<hr>
<div class="col-lg-10 col-xl-8">
<div class="input-group">
<label class="input-group-text" for="backup-min-age">Retention Days</label>
<input type="number" class="form-control" id="backup-min-age">
</div>
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an
incremental backup most nights, which requires that previous backups back to the most recent full backup be
preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when
the incremental backup data size exceeds a limit.</div>
</div>
</div>
<div class="mt-3">
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
</div>
</form>
<h3>Available backups</h3>
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id="backup-total-size"></span>.</p>
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span
id="backup-total-size"></span>.</p>
<table id="backup-status" class="table" style="width: auto">
<thead>
<th colspan="2">When</th>
<th>Type</th>
<th>Size</th>
<th>Deleted in...</th>
</thead>
<tbody>
</tbody>
<table id="backup-status" class="table align-middle col-12">
<caption></caption>
<thead>
<th colspan="2">When</th>
<th>Type</th>
<th>Size</th>
<th>Deleted in...</th>
</thead>
<tbody>
</tbody>
</table>
<!-- Hide these buttons until we're sure we can use them :) -->
<button id="create-full-backup-button" class="btn btn-primary" onclick="do_backup(true)" style="display: none;">Create Full Backup Now</button>
<button id="create-incremental-backup-button" class="btn btn-primary" onclick="do_backup(false)" style="display: none;">Create Incremental Backup Now</button>
<div class="row justify-content-evenly">
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)"
style="display: none;">Create Full Backup Now</button>
<button id="create-incremental-backup-button" class="btn btn-primary col-3" onclick="do_backup(false)"
style="display: none;">Create Incremental Backup Now</button>
</div>
<script>
function toggle_form() {
var target_type = $("#backup-target-type").val();
$(".backup-target-local, .backup-target-rsync, .backup-target-s3").hide();
$(".backup-target-" + target_type).show();
function toggle_form() {
var target_type = $("#backup-target-type").val();
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
$(".backup-target-" + target_type).show();
init_inputs(target_type);
}
init_inputs(target_type);
}
function nice_size(bytes) {
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
while (true) {
if (powers.length == 1) break;
if (bytes < 1000) break;
bytes /= 1024;
powers.shift();
}
// round to have three significant figures but at most one decimal place
if (bytes >= 100)
bytes = Math.round(bytes)
else
bytes = Math.round(bytes*10)/10;
return bytes + " " + powers[0];
}
function nice_size(bytes) {
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
while (true) {
if (powers.length == 1) break;
if (bytes < 1000) break;
bytes /= 1024;
powers.shift();
}
// round to have three significant figures but at most one decimal place
if (bytes >= 100)
bytes = Math.round(bytes)
else
bytes = Math.round(bytes * 10) / 10;
return bytes + " " + powers[0];
}
function show_system_backup() {
show_custom_backup()
function show_system_backup() {
show_custom_backup()
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/system/backup/status",
"GET",
{ },
function(r) {
if (r.error) {
show_modal_error("Backup Error", $("<pre/>").text(r.error));
return;
}
$('#backup-status tbody').html("<tr><td colspan='5' class='text-muted'>Loading...</td></tr>")
api(
"/system/backup/status",
"GET",
{},
function (r) {
if (r.error) {
show_modal_error("Backup Error", $("<pre/>").text(r.error));
return;
}
$('#backup-status tbody').html("");
var total_disk_size = 0;
$('#backup-status tbody').html("");
var total_disk_size = 0;
if (typeof r.backups == "undefined") {
var tr = $('<tr><td colspan="3">Backups are turned off.</td></tr>');
$('#backup-status tbody').append(tr);
$('#create-full-backup-button').css("display","none")
$('#create-incremental-backup-button').css("display","none")
return;
} else if (r.backups.length == 0) {
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
$('#backup-status tbody').append(tr);
}
if (typeof r.backups == "undefined") {
var tr = $('<tr><td colspan="5">Backups are turned off.</td></tr>');
$('#backup-status tbody').append(tr);
$('#create-full-backup-button').css("display", "none")
$('#create-incremental-backup-button').css("display", "none")
return;
} else if (r.backups.length == 0) {
var tr = $('<tr><td colspan="5">No backups have been made yet.</td></tr>');
$('#backup-status tbody').append(tr);
}
// Backups ARE enabled.
$('#create-full-backup-button').css("display","unset")
$('#create-incremental-backup-button').css("display","unset")
for (var i = 0; i < r.backups.length; i++) {
var b = r.backups[i];
var tr = $('<tr/>');
if (b.full) tr.addClass("full-backup");
tr.append( $('<td/>').text(b.date_str) );
tr.append( $('<td/>').text(b.date_delta + " ago") );
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
if (b.deleted_in)
tr.append( $('<td/>').text(b.deleted_in) );
else
tr.append( $('<td class="text-muted">unknown</td>') );
$('#backup-status tbody').append(tr);
// Backups ARE enabled.
$('#create-full-backup-button').css("display", "unset")
$('#create-incremental-backup-button').css("display", "unset")
for (var i = 0; i < r.backups.length; i++) {
var b = r.backups[i];
var tr = $('<tr/>');
if (b.full) tr.addClass("full-backup");
tr.append($('<td/>').text(b.date_str));
tr.append($('<td/>').text(b.date_delta + " ago"));
tr.append($('<td/>').text(b.full ? "full" : "increment"));
tr.append($('<td/>').text(nice_size(b.size)));
if (b.deleted_in)
tr.append($('<td/>').text(b.deleted_in));
else
tr.append($('<td class="text-muted">unknown</td>'));
$('#backup-status tbody').append(tr);
total_disk_size += b.size;
}
total_disk_size += b.size;
}
total_disk_size += r.unmatched_file_size;
$('#backup-total-size').text(nice_size(total_disk_size));
})
}
total_disk_size += r.unmatched_file_size;
$('#backup-total-size').text(nice_size(total_disk_size));
})
}
function show_custom_backup() {
$(".backup-target-local, .backup-target-rsync, .backup-target-s3").hide();
api(
"/system/backup/config",
"GET",
{ },
function(r) {
$("#backup-target-user").val(r.target_user);
$("#backup-target-pass").val(r.target_pass);
$("#min-age").val(r.min_age_in_days);
$(".backup-location").text(r.file_target_directory);
$(".backup-encpassword-file").text(r.enc_pw_file);
$("#ssh-pub-key").val(r.ssh_pub_key);
function show_custom_backup() {
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
api(
"/system/backup/config",
"GET",
{},
function (r) {
$("#backup-target-user").val(r.target_user);
$("#backup-target-pass").val(r.target_pass);
$("#backup-min-age").val(r.min_age_in_days);
$(".backup-location").text(r.file_target_directory);
$(".backup-encpassword-file").text(r.enc_pw_file);
$("#ssh-pub-key").val(r.ssh_pub_key);
if (r.target == "file://" + r.file_target_directory) {
$("#backup-target-type").val("local");
} else if (r.target == "off") {
$("#backup-target-type").val("off");
} else if (r.target.substring(0, 8) == "rsync://") {
$("#backup-target-type").val("rsync");
var path = r.target.substring(8).split('//');
var host_parts = path.shift().split('@');
$("#backup-target-rsync-user").val(host_parts[0]);
$("#backup-target-rsync-host").val(host_parts[1]);
$("#backup-target-rsync-path").val('/'+path[0]);
} else if (r.target.substring(0, 5) == "s3://") {
$("#backup-target-type").val("s3");
var hostpath = r.target.substring(5).split('/');
var host = hostpath.shift();
$("#backup-target-s3-host").val(host);
$("#backup-target-s3-path").val(hostpath.join('/'));
}
toggle_form()
})
}
if (r.target == "file://" + r.file_target_directory) {
$("#backup-target-type").val("local");
} else if (r.target == "off") {
$("#backup-target-type").val("off");
} else if (r.target.substring(0, 8) == "rsync://") {
$("#backup-target-type").val("rsync");
let uri = r.target.substring(8)
let i = uri.indexOf("/")
let path = [uri.slice(0, i), uri.slice(i + 1)];
let host_parts = path.shift().split('@');
$("#backup-target-rsync-user").val(host_parts[0]);
$("#backup-target-rsync-host").val(host_parts[1]);
$("#backup-target-rsync-path").val(path[0]);
$("#backup-target-rsync-port").val(r.target_rsync_port)
} else if (r.target.substring(0, 5) == "s3://") {
$("#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://") {
$("#backup-target-type").val("b2");
var targetPath = r.target.substring(5);
var b2_application_keyid = targetPath.split(':')[0];
var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
var b2_bucket = targetPath.split('@')[1];
$("#backup-target-b2-user").val(b2_application_keyid);
$("#backup-target-b2-pass").val(b2_applicationkey);
$("#backup-target-b2-bucket").val(b2_bucket);
}
toggle_form()
})
}
function set_custom_backup() {
var target_type = $("#backup-target-type").val();
var target_user = $("#backup-target-user").val();
var target_pass = $("#backup-target-pass").val();
function set_custom_backup() {
var target_type = $("#backup-target-type").val();
var target_user = $("#backup-target-user").val();
var target_pass = $("#backup-target-pass").val();
let target_port = $("#backup-target-rsync-port").val();
var target;
if (target_type == "local" || target_type == "off")
target = target_type;
else if (target_type == "s3")
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
else if (target_type == "rsync") {
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
+ "/" + $("#backup-target-rsync-path").val();
target_user = '';
}
var target;
if (target_type == "local" || target_type == "off")
target = target_type;
else if (target_type == "s3")
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
else if (target_type == "rsync") {
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
+ "/" + $("#backup-target-rsync-path").val();
target_user = '';
} else if (target_type == "b2") {
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
+ '@' + $('#backup-target-b2-bucket').val()
target_user = '';
target_pass = '';
}
var min_age = $("#backup-min-age").val();
api(
"/system/backup/config",
"POST",
{
target: target,
target_user: target_user,
target_pass: target_pass,
target_rsync_port: target_port,
min_age: min_age
},
function (r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r), function () { if (r == "OK") show_system_backup(); }); // refresh after modal on success
},
function (r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r));
});
return false;
}
var min_age = $("#min-age").val();
api(
"/system/backup/config",
"POST",
{
target: target,
target_user: target_user,
target_pass: target_pass,
min_age: min_age
},
function(r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
},
function(r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r));
});
return false;
}
function init_inputs(target_type) {
function set_host(host, overwrite_other) {
if (host !== 'other') {
$("#backup-target-s3-host").val(host);
} 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(), true);
});
set_host($('#backup-target-s3-host-select').val(), false);
}
}
function init_inputs(target_type) {
function set_host(host) {
if(host !== 'other') {
$("#backup-target-s3-host").val(host);
} 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());
});
set_host($('#backup-target-s3-host-select').val());
}
}
function do_backup(is_full) {
let disclaimer = "The backup process will pause some services (such as PHP, Postfix and Dovecot). Depending on the size of the data this can take a while."
if (!is_full) {
disclaimer += "\nDepending on the amount of incremental backups done after the last full backup, the box may decide to do a full backup instead."
}
show_modal_confirm("Warning!", disclaimer, "Start Backup", () => {
api(
"/system/backup/new",
"POST",
{
full: is_full
},
function(r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
},
function(r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r));
});
return false;
})
}
function do_backup(is_full) {
let disclaimer = "The backup process will pause some services (such as PHP, Postfix and Dovecot). Depending on the size of the data this can take a while."
if (!is_full) {
disclaimer += "\nDepending on the amount of incremental backups done after the last full backup, the box may decide to do a full backup instead."
}
show_modal_confirm("Warning!", disclaimer, "Start Backup", () => {
api(
"/system/backup/new",
"POST",
{
full: is_full
},
function (r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r), function () { if (r == "OK") show_system_backup(); }); // refresh after modal on success
},
function (r) {
// use .text() --- it's a text response, not html
show_modal_error("Backup configuration", $("<p/>").text(r));
});
return false;
})
}
</script>

View file

@ -1,38 +1,81 @@
<h2>System Status Checks</h2>
<style>
#system-checks .heading td {
font-weight: bold;
font-size: 120%;
padding-top: 1.5em;
#system-checks .message {
display: inline;
}
#system-checks .heading.first td {
#system-checks .icon {
min-width: 2em;
}
#system-checks .heading {
font-weight: bold;
font-size: 180%;
padding-top: 1.75em;
}
#system-checks .heading.first {
border-top: none;
padding-top: 0;
}
#system-checks .status-error td {
color: rgb(140, 0, 0);
.status-error .fas {
color: rgb(190, 0, 0);
}
#system-checks .status-warning td {
color: rgb(170, 120, 0);
.status-error.sym {
color: rgb(190, 0, 0);
}
#system-checks .status-ok td {
color: rgb(0, 140, 0);
.status-error .status-text {
color: rgb(70, 0, 0);
}
#system-checks div.extra {
.status-warning .fas {
color: rgb(191, 150, 0);
}
.status-warning.sym {
color: rgb(191, 150, 0);
}
.status-warning .status-text {
color: rgb(69, 54, 0);
}
.status-ok .fas {
color: rgb(0, 190, 0);
}
.status-ok.sym {
color: rgb(0, 190, 0);
}
.status-ok .status-text {
color: rgb(0, 70, 0);
}
.status-na .fas {
color: rgb(100, 100, 100);
}
.status-na.sym {
color: rgb(100, 100, 100);
}
.status-na .status-text {
color: rgb(100, 100, 100);
}
div.extra {
display: none;
margin-top: 1em;
max-width: 50em;
word-wrap: break-word;
}
#system-checks .showhide {
display: none;
display: block;
font-size: 85%;
}
@ -43,37 +86,24 @@
}
</style>
<div class="row">
<div class="col-md-push-9 col-md-3">
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
<div>No reboot is necessary.</div>
</div>
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
<div>No reboot is necessary.</div>
</div>
<div id="system-privacy-setting" style="display: none">
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span>
New-Version Check</a></div>
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of
Mail-in-a-Box.)</small></p>
</div>
<div id="system-privacy-setting" style="display: none">
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span>
New-Version Check</a></div>
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of
Mail-in-a-Box.)</small></p>
</div>
</div> <!-- /col -->
<div class="col-md-pull-3 col-md-8">
<table id="system-checks" class="table" style="max-width: 60em">
<thead>
</thead>
<tbody>
</tbody>
</table>
</div> <!-- /col -->
</div> <!-- /row -->
<div id="system-checks">
</div>
<script>
function show_system_status() {
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
$('#system-checks').html("")
api(
"/system/privacy",
@ -101,24 +131,38 @@
"POST",
{},
function (r) {
$('#system-checks tbody').html("");
for (var i = 0; i < r.length; i++) {
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><p class='showhide btn btn-light' href='#'/><div class='extra'></div></tr>");
for (let i = 0; i < r.length; i++) {
let n = $("<div class='col-12'><div class='icon'></div><p class='message status-text' style='margin: 0'/>");
if (i == 0) n.addClass('first')
if (r[i].type == "heading")
if (r[i].type == "heading") {
n.addClass(r[i].type)
else
} else {
n.addClass("status-" + r[i].type)
if (r[i].type == "ok") n.find('td.status').text("✔️")
if (r[i].type == "error") n.find('td.status').text("❌")
if (r[i].type == "warning") n.find('td.status').text("⚠️")
n.find('td.message p').text(r[i].text)
$('#system-checks tbody').append(n);
n.find(".icon").addClass("fas")
}
if (r[i].type == "ok") n.find(".icon").addClass("fa-check")
if (r[i].type == "error") n.find(".icon").addClass("fa-times")
if (r[i].type == "warning") n.find(".icon").addClass("fa-exclamation-triangle")
n.find('p.status-text').text(r[i].text)
if (r[i].extra.length > 0) {
n.find('.showhide').show().text("Show More").click(function () {
$(this).hide();
$(this).parent().find('.extra').fadeIn();
let open = false
n.append("<p class='showhide btn btn-light mt-3' href='#'>Show More</p><div class='extra ps-4 col-12'></div>")
n.find('.showhide').click(function () {
let extra = $(this).parent().find('.extra')
if (open) {
$(this).html("Show More");
extra.fadeOut();
} else {
$(this).html("Show Less");
extra.fadeIn();
}
open = !open
return false;
});
}
@ -128,7 +172,12 @@
var m = $("<div/>").text(r[i].extra[j].text)
if (r[i].extra[j].monospace)
m.addClass("pre");
n.find('> td.message > div').append(m);
n.find('.extra').append(m);
}
$('#system-checks').append(n);
if (i < r.length - 1) {
$('#system-checks').append($("<hr>"));
}
}
})

View file

@ -1,84 +1,145 @@
<h2>Users</h2>
<style>
#user_table h4 { margin: 1em 0 0 0; }
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
#user_table .actions { margin-top: .33em; font-size: 95%; }
#user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; }
#user_table tr.account_inactive td.address {
color: #888;
text-decoration: line-through;
}
#user_table .actions {
margin-top: .33em;
font-size: 95%;
}
#user_table .account_inactive .if_active {
display: none;
}
#user_table .account_active .if_inactive {
display: none;
}
#user_table .account_active.if_inactive {
display: none;
}
</style>
<h3>Add a mail user</h3>
<p>Add an email address to this system. This will create a new login username/password.</p>
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
<div class="form-group">
<label class="sr-only" for="adduserEmail">Email address</label>
<input type="email" class="form-control" id="adduserEmail" placeholder="Email Address">
</div>
<div class="form-group">
<label class="sr-only" for="adduserPassword">Password</label>
<input type="password" class="form-control" id="adduserPassword" placeholder="Password">
</div>
<div class="form-group">
<select class="form-control" id="adduserPrivs">
<option value="">Normal User</option>
<option value="admin">Administrator</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
<form role="form" onsubmit="return do_add_user(); return false;">
<div class="mb-3">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="input-group">
<label class="input-group-text" for="adduserEmail">Email address</label>
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail"
placeholder="me@example.com">
</div>
</div>
<div class="col-12 col-lg-6">
<div class="input-group">
<label class="input-group-text" for="adduserPassword">Password</label>
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword"
placeholder="Password">
</div>
</div>
</div>
</div>
<div class="col-md-8 col-lg-6 mb-3">
<div class="input-group">
<label class="input-group-text" for="adduserPrivs">User Type</label>
<select class="form-select" id="adduserPrivs">
<option value="">Normal User</option>
<option value="admin">Administrator</option>
</select>
</div>
</div>
<div class="col-md-8 col-lg-6 mb-3">
<div class="input-group">
<label class="input-group-text" for="adduserQuota">Storage Quota</label>
<input type="text" class="form-control" id="adduserQuota" placeholder="0">
</div>
</div>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#"
onclick="return generate_random_password()">generate a random password</a>.</li>
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to
existing accounts.</li>
<li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#"
onclick="return show_panel('aliases');">aliases</a> can.</li>
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are
allowed. For unlimited storage enter 0 (zero)</li>
</ul>
<button type="submit" class="btn btn-primary">Add User</button>
</form>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Passwords must be at least eight characters consisting of English lettters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
<li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
</ul>
<h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto">
<thead>
<tr>
<th width="50%">Email Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
</tbody>
<table id="user_table" class="table col-12">
<caption></caption>
<thead>
<tr>
<th width="35%">Email Address</th>
<th class="">Messages</th>
<th class="">Size</th>
<th class="">Used</th>
<th class="">Quota</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div style="display: none">
<table>
<tr id="user-template">
<td class='address'>
</td>
<td class='actions'>
<span class='privs'>
</span>
<table>
<tr id="user-template">
<td class='address'>
</td>
<td class="box-count"></td>
<td class="box-size"></td>
<td class="percent"></td>
<td class="quota">
</td>
<td class='actions'>
<span class='privs'>
</span>
<span class="if_active">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password
</a>
|
</span>
<span class="if_active">
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
set quota
</a>
|
</span>
<span class='add-privs'>
</span>
<span class="if_active">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password
</a>
|
</span>
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
archive account
</a>
</td>
</tr>
<tr id="user-extra-template" class="if_inactive">
<td colspan="3" style="border: 0; padding-top: 0">
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
</td>
</tr>
</table>
<span class='add-privs'>
</span>
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
archive account
</a>
</td>
</tr>
<tr id="user-extra-template" class="if_inactive">
<td colspan="3" style="border: 0; padding-top: 0">
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this
email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
</td>
</tr>
</table>
</div>
<h3>Mail user API (advanced)</h3>
@ -96,17 +157,57 @@
<h4 style="margin-bottom: 0">Verbs</h4>
<table class="table" style="margin-top: .5em">
<thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>.</td></tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
<caption></caption>
<thead>
<th>Verb</th>
<th>Action</th>
<th></th>
</thead>
<tr>
<td><b>GET</b< /td>
<td><i>(none)</i></td>
<td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded
results.</td>
</tr>
<tr>
<td><b>POST</b< /td>
<td class="font-monospace">/add</td>
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional
parameters: <code>privilege=admin</code> and <code>quota</code></td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/remove</td>
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/privileges/add</td>
<td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and
<code>privilege=admin</code>.</td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/privileges/remove</td>
<td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td>
</tr>
<tr>
<td><b>GET</b></td>
<td class="font-monospace">/quota</td>
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result
</td>
</tr>
<tr>
<td><b>POST</b></td>
<td class="font-monospace">/quota</td>
<td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>
</tr>
</table>
<h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your administrative email address and password.</p>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your administrative email address and password.</p>
<pre># Gives a JSON-encoded list of all mail users
curl -X GET https://{{hostname}}/admin/mail/users?format=json
@ -125,176 +226,228 @@ curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/us
</pre>
<script>
function show_users() {
$('#user_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/mail/users",
"GET",
{ format: 'json' },
function(r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
hdr.find('h4').text(r[i].domain);
$('#user_table tbody').append(hdr);
function show_users() {
api(
"/system/default-quota",
"GET",
{},
function (r) {
$('#adduserQuota').val(r['default-quota']);
}
);
for (var k = 0; k < r[i].users.length; k++) {
var user = r[i].users[k];
$('#user_table tbody').html("<tr><td colspan='6' class='text-muted'>Loading...</td></tr>")
api(
"/mail/users",
"GET",
{ format: 'json' },
function (r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><th colspan='6' class='bg-light'></th></tr>");
hdr.find('th').text(r[i].domain);
$('#user_table tbody').append(hdr);
var n = $("#user-template").clone();
var n2 = $("#user-extra-template").clone();
n.attr('id', '');
n2.attr('id', '');
$('#user_table tbody').append(n);
$('#user_table tbody').append(n2);
for (var k = 0; k < r[i].users.length; k++) {
var user = r[i].users[k];
n.addClass("account_" + user.status);
n2.addClass("account_" + user.status);
var n = $("#user-template").clone();
var n2 = $("#user-extra-template").clone();
n.attr('id', '');
n2.attr('id', '');
$('#user_table tbody').append(n);
$('#user_table tbody').append(n2);
n.attr('data-email', user.email);
n.find('.address').text(user.email)
n2.find('.restore_info tt').text(user.mailbox);
n.addClass("account_" + user.status);
n2.addClass("account_" + user.status);
if (user.status == 'inactive') continue;
n.attr('data-email', user.email);
n.attr('data-quota', user.quota);
n.find('.address').text(user.email);
n.find('.box-count').text((user.box_count).toLocaleString('en'));
if (user.box_count == '?') {
n.find('.box-count').attr('title', 'Message count is unkown')
}
n.find('.box-size').text(user.box_size);
if (user.box_size == '?') {
n.find('.box-size').attr('title', 'Mailbox size is unkown')
}
n.find('.percent').text(user.percent);
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
n2.find('.restore_info tt').text(user.mailbox);
var add_privs = ["admin"];
if (user.status == 'inactive') continue;
for (var j = 0; j < user.privileges.length; j++) {
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
p.find('span.name').text(user.privileges[j]);
n.find('.privs').append(p);
if (add_privs.indexOf(user.privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
}
var add_privs = ["admin"];
for (var j = 0; j < add_privs.length; j++) {
var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
p.find('span.name').text(add_privs[j]);
n.find('.add-privs').append(p);
}
}
}
})
}
for (var j = 0; j < user.privileges.length; j++) {
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
p.find('span.name').text(user.privileges[j]);
n.find('.privs').append(p);
if (add_privs.indexOf(user.privileges[j]) >= 0)
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
}
function do_add_user() {
var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val();
api(
"/mail/users/add",
"POST",
{
email: email,
password: pw,
privileges: privs
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Add User", $("<pre/>").text(r));
show_users()
},
function(r) {
show_modal_error("Add User", r);
});
return false;
}
for (var j = 0; j < add_privs.length; j++) {
var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
p.find('span.name').text(add_privs[j]);
n.find('.add-privs').append(p);
}
}
}
})
}
function users_set_password(elem) {
var email = $(elem).parents('tr').attr('data-email');
function do_add_user() {
var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val();
var quota = $("#adduserQuota").val();
api(
"/mail/users/add",
"POST",
{
email: email,
password: pw,
privileges: privs,
quota: quota
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Add User", $("<pre/>").text(r));
show_users()
},
function (r) {
show_modal_error("Add User", r);
});
return false;
}
var yourpw = "";
if (api_credentials != null && email == api_credentials[0])
yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
function users_set_password(elem) {
var email = $(elem).parents('tr').attr('data-email');
show_modal_confirm(
"Set Password",
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least eight characters and may not contain spaces.</small>" + yourpw + "</p>"),
"Set Password",
function() {
api(
"/mail/users/password",
"POST",
{
email: email,
password: $('#users_set_password_pw').val()
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Set Password", $("<pre/>").text(r));
},
function(r) {
show_modal_error("Set Password", r);
});
});
}
var yourpw = "";
if (api_credentials != null && email == api_credentials.username)
yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
show_modal_confirm(
"Set Password",
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least eight characters.</small>" + yourpw + "</p>"),
"Set Password",
function () {
api(
"/mail/users/password",
"POST",
{
email: email,
password: $('#users_set_password_pw').val()
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Set Password", $("<pre/>").text(r));
},
function (r) {
show_modal_error("Set Password", r);
});
});
}
// can't remove yourself
if (api_credentials != null && email == api_credentials[0]) {
show_modal_error("Archive User", "You cannot archive your own account.");
return;
}
function users_set_quota(elem) {
var email = $(elem).parents('tr').attr('data-email');
var quota = $(elem).parents('tr').attr('data-quota');
show_modal_confirm(
"Archive User",
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
"Archive",
function() {
api(
"/mail/users/remove",
"POST",
{
email: email
},
function(r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove User", $("<pre/>").text(r));
show_users();
},
function(r) {
show_modal_error("Remove User", r);
});
});
}
show_modal_confirm(
"Set Quota",
$("<p>Set quota for <b>" + email + "</b>?</p>" +
"<p>" +
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
"<input type='text' id='users_set_quota' value='" + quota + "'></p>" +
"<p><small>Quotas may not contain any spaces or commas. Suffixes of G (gigabytes) and M (megabytes) are allowed.</small></p>" +
"<p><small>For unlimited storage enter 0 (zero)</small></p>"),
"Set Quota",
function () {
api(
"/mail/users/quota",
"POST",
{
email: email,
quota: $('#users_set_quota').val()
},
function (r) {
show_users();
},
function (r) {
show_modal_error("Set Quota", r);
});
});
}
function mod_priv(elem, add_remove) {
var email = $(elem).parents('tr').attr('data-email');
var priv = $(elem).parents('td').find('.name').text();
function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
// can't remove your own admin access
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
return;
}
// can't remove yourself
if (api_credentials != null && email == api_credentials.username) {
show_modal_error("Archive User", "You cannot archive your own account.");
return;
}
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
show_modal_confirm(
"Modify Privileges",
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
add_remove1,
function() {
api(
"/mail/users/privileges/" + add_remove,
"POST",
{
email: email,
privilege: priv
},
function(r) {
show_users();
});
});
}
show_modal_confirm(
"Archive User",
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
"Archive",
function () {
api(
"/mail/users/remove",
"POST",
{
email: email
},
function (r) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Remove User", $("<pre/>").text(r));
show_users();
},
function (r) {
show_modal_error("Remove User", r);
});
});
}
function generate_random_password() {
var pw = "";
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
for (var i = 0; i < 12; i++)
pw += charset.charAt(Math.floor(Math.random() * charset.length));
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr");
return false; // cancel click
}
function mod_priv(elem, add_remove) {
var email = $(elem).parents('tr').attr('data-email');
var priv = $(elem).parents('td').find('.name').text();
// can't remove your own admin access
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
return;
}
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
show_modal_confirm(
"Modify Privileges",
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
add_remove1,
function () {
api(
"/mail/users/privileges/" + add_remove,
"POST",
{
email: email,
privilege: priv
},
function (r) {
show_users();
});
});
}
function generate_random_password() {
var pw = "";
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
for (var i = 0; i < 12; i++)
pw += charset.charAt(Math.floor(Math.random() * charset.length));
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></p>");
return false; // cancel click
}
</script>

View file

@ -3,87 +3,101 @@
<h2>Static Web Hosting</h2>
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at all domain names that you set up an email user or alias for.</p>
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at
all domain names that you set up an email user or alias for.</p>
<h3>Uploading web files</h3>
<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>
<p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t
help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:
</p>
<ol>
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li>
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status"
onclick="return show_panel(this);">Status Checks</a> page.</li>
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
<li>On your personal computer, install an SSH file transfer program such as <a
href="https://filezilla-project.org/">FileZilla</a> or <a
href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li>
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol
is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this
machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this
control panel. Your SSH credentials probably involves a private key file.</li>
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li>
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine.
They will appear directly and immediately on the web.</li>
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.</li>
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.
</li>
<table id="web_domains_existing" class="table" style="margin-bottom: 1em; width: auto;">
<thead>
<tr>
<th>Site</th>
<th>Directory for Files</th>
<th/>
</tr>
</thead>
<tbody>
</tbody>
</table>
<table id="web_domains_existing" class="table col-12" style="margin-bottom: 1em;">
<caption></caption>
<thead>
<tr>
<th>Site</th>
<th>Directory for Files</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or
<a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a
href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records
to the new domain at your registrar (but <i>not</i> glue records).</p>
</ol>
<script>
function show_web() {
api(
"/web/domains",
"GET",
{
},
function(domains) {
var tb = $('#web_domains_existing tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
if (!domains[i].static_enabled) continue;
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.attr('data-custom-web-root', domains[i].custom_root);
row.find('.domain a').text('https://' + domains[i].domain);
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
row.find('.directory tt').text(domains[i].root);
if (domains[i].root != domains[i].custom_root)
row.find('.change-root').removeClass('hidden');
function show_web() {
api(
"/web/domains",
"GET",
{
},
function (domains) {
var tb = $('#web_domains_existing tbody');
tb.text('');
for (var i = 0; i < domains.length; i++) {
if (!domains[i].static_enabled) continue;
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.attr('data-custom-web-root', domains[i].custom_root);
row.find('.domain a').text('https://' + domains[i].domain);
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
row.find('.directory tt').text(domains[i].root);
if (domains[i].root != domains[i].custom_root)
row.find('.change-root').removeClass('hidden');
}
});
}
});
}
function do_web_update() {
api(
"/web/update",
"POST",
{
},
function (data) {
if (data == "")
data = "Nothing changed.";
else
data = $("<pre/>").text(data);
show_modal_error("Web Update", data, function () { show_web() });
});
}
function do_web_update() {
api(
"/web/update",
"POST",
{
},
function(data) {
if (data == "")
data = "Nothing changed.";
else
data = $("<pre/>").text(data);
show_modal_error("Web Update", data, function() { show_web() });
});
}
function show_change_web_root(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
var root = $(elem).parents('tr').attr('data-custom-web-root');
show_modal_confirm(
'Change Root Directory for ' + domain,
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),
'Update',
function() { do_web_update(); });
}
function show_change_web_root(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
var root = $(elem).parents('tr').attr('data-custom-web-root');
show_modal_confirm(
'Change Root Directory for ' + domain,
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),
'Update',
function () { do_web_update(); });
}
</script>

View file

@ -0,0 +1,3 @@
<h1 class="title">{{hostname}}</h1>
<p class="subtitle">Welcome to your Power Mail-in-a-Box control panel.</p>

View file

@ -0,0 +1,306 @@
<style>
#wkd_table #uids {
white-space: pre-line;
}
#wkd_table .domain-header {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
</style>
<template id="wkd-entry-template">
<tr>
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
<td id="key-dropdown" class="dropdown show" style="width: 100%;">
<button class="btn bg-light" style="width: 100%;" data-bs-toggle="dropdown" id="current-key">
</button>
<div class="dropdown-menu" style="width: 100%;" id="menu">
<button class="btn bg-light" style="width:100%;" id="key-none">
<b>None</b>
<br>
<a>Do not serve a key in WKD.</a>
</button>
<button class="btn bg-light" style="width:100%" id="key-sample">
<div class="row">
<div class="col-6" id="fingerprint">
<b class="font-monospace status-text" id="fpr"></b><span id="subkeys"></span>
</div>
<div class="col-6">
<b>
<span class="sym" id="sign">S</span>
<span class="sym" id="cert">C</span>
<span class="sym" id="encr">E</span>
<span class="sym" id="auth">A</span>
</b>,
<span id="expire"><span class="status-text"></span></span>
</div>
</div>
<div id="key-emails">
</div>
</button>
</div>
</td>
</tr>
</template>
<template id="wkd-nokeys-template">
<tr>
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
<td style="width: 100%; text-align: center;" class="text-muted">
<b>None</b>
<br>
<a>There are no keys in the keyring that contain this email.</a>
</td>
</tr>
</template>
<h2>WKD Management</h2>
<p>WKD (<b>W</b>eb <b>K</b>ey <b>D</b>irectory) is an
<b><a href="https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html">experimental feature</a></b>
that allows users to authoratively publish their public PGP keys on the web, via HTTPS.
</p>
<p>Unlike other solutions (like public keyservers), WKD has the advantage that the owner
of the domain has some degree of control over what keys are published and as such there
is more certainity that the key actually belongs to it's owner.</p>
<p>As of this version, there is no support for WKS - This may or may not change in the future.</p>
<h3>Hosting somewhere else</h3>
<a>
If you have a separate server to host WKD, you can still use it instead of this box on a per-domain basis.
<br>
This box uses the Advanced Method to serve the keys. For example, to host your <code>@some.example.com</code> keys,
you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's
records.
<br><br>
There's not a way to "disable" WKD at the moment - but if you don't want to publish keys, or if you want to use the
Direct method somewhere else, you can always set the records to an unreachable destination
(for example, an A record to <code>0.0.0.0</code>)
</a>
<h3>Emails hosted by this box</h3>
<a>
This box can serve keys to any email address hosted by this box, be it an user or an alias,
granted that the key has one or more user id's with matching emails.
<br>
You can add keys on the PGP Keyring Management menu.
</a>
<div class="input-group mt-3 mb-3">
<label for="wkd-show-all-entries" class="input-group-text"><b>Show all email addresses</b></label>
<div class="input-group-text">
<div class="form-switch">
<input type="checkbox" role="switch" id="wkd-show-all-entries" class="form-check-input" value=false
onclick="toggle_emails_with_no_pgp_key();">
</div>
</div>
</div>
<table id="wkd_table" class="table" style="width: 100%">
<caption></caption>
<thead>
<tr>
<th>Email Address</th>
<th></th>
</tr>
</thead>
<tbody id="wkd_tbody">
</tbody>
</table>
<button id="wkd_submit" class="btn btn-primary" disabled onclick="update_wkd()">Apply</button>
<script>
let dirty_config = {}
let toggleable_lines = []
let show_toggleable_lines = document.getElementById("wkd-show-all-entries").checked
function pretty_long_id(fpr) {
let plid = ""
for (let i = 6; i < 10; ++i) {
plid += `${fpr.substring(i * 4, (i + 1) * 4)} `
}
return plid.substring(0, plid.length - 1)
}
function generate_wkd_menu(email, wkd_config, keys) {
if (wkd_config.options.length == 0) {
// No possible options
let menurep = $("#wkd-nokeys-template").html()
menurep = $(menurep)
menurep.attr("id", `wkd-${email}`)
menurep.find("#email").html(email)
return menurep
}
// else
let menurep = $("#wkd-entry-template").html()
menurep = $(menurep)
menurep.attr("id", `wkd-${email}`)
menurep.find("#email").html(email)
let template = menurep.find("#key-sample")
wkd_config.options.forEach(key => {
let option = template.prop("outerHTML")
option = $(option)
option.attr("id", `key-${key}`)
// Basic information
let keyinfo = keys[key]
let skey = keyinfo.subkeys[0] // Master subkey
let fpr = option.find("#fingerprint")
fpr.find("#fpr").text(pretty_long_id(key))
fpr.find("#subkeys").text(` (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`)
option.click(() => {
$("#wkd_submit").attr("disabled", false)
dirty_config[email] = key
menurep.find("#current-key").html(option.html())
})
option.appendTo(menurep.find("#menu"))
let classType = ""
let expire = option.find("#expire")
let exp = expire.find(".status-text")
// Purpose and expiration
// For evaluation we're only going to use the master subkey
if (keyinfo.revoked) {
classType = "status-error"
exp.text(`revoked`)
} else if (skey.expired) {
classType = "status-error"
exp.text(`expired on ${skey.expires_date}`)
} else if (skey.expires && skey.expires_days <= 14) {
classType = "status-warning"
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
} else if (skey.expires) {
classType = "status-ok"
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
} else {
classType = "status-ok"
exp.text("does not expire")
}
expire.addClass(classType)
fpr.addClass(classType)
let s = option.find("#sign")
let c = option.find("#cert")
let e = option.find("#encr")
let a = option.find("#auth")
s.addClass(skey.sign ? classType : "status-na")
c.addClass(skey.cert ? classType : "status-na")
e.addClass(skey.encr ? classType : "status-na")
a.addClass(skey.auth ? classType : "status-na")
let txt = ""
let n_emails = keyinfo.ids_emails.length
if (n_emails == 1) {
txt = `<b class="font-monospace">${email}</b>`
} else if (email.length >= 70) {
// Absurd situation, but can happen!
// We'll only
txt = `<b class="font-monospace">${email}</b> and ${n_emails - 1} other${n_emails == 2 ? "" : "s"}`
} else {
let buf = 70 - email.length
let others = n_emails - 1
let sorted_emails = keyinfo.ids_emails.filter((v) => {
return v != email
})
sorted_emails.sort((x, y) => {
// Longer emails come first. If there's a tie, use alphabetic order
return x.length == y.length ? x.localeCompare(y) : x.length > y.length ? -1 : 1
})
txt = `<b class="font-monospace">${email}</b>`
// From the remaining emails, pick the longest ones within the buffer constraints
// If none, append the "and x others text"
sorted_emails.forEach(e => {
if (e.length <= buf) {
others--
buf -= e.length
txt += `, <span class="font-monospace">${e}</span>`
}
});
if (others > 0) {
txt += ` and ${others} other${others == 1 ? "" : "s"}`
}
}
option.find("#key-emails").html(txt)
});
template.remove()
let nokeyopt = menurep.find("#key-none")
nokeyopt.click(() => {
$("#wkd_submit").attr("disabled", false)
dirty_config[email] = null
menurep.find("#current-key").html(nokeyopt.html())
})
menurep.find("#current-key").html(menurep.find(`#key-${wkd_config.selected ? wkd_config.selected : "none"}`).html())
return menurep
}
function generate_wkd_dropdowns(wkd_info) {
$("#wkd_tbody").html("")
Object.keys(wkd_info.wkd).forEach(domain => {
$(`<tr><td colspan="2" class="bg-light"><h4 class="domain-header">${domain}</h4></td></tr>`).appendTo("#wkd_tbody")
Object.keys(wkd_info.wkd[domain]).forEach(email => {
let menu = generate_wkd_menu(email, wkd_info.wkd[domain][email], wkd_info.keys)
menu.appendTo("#wkd_tbody")
// No possible keys, we can hide this line to save space
if (wkd_info.wkd[domain][email].options.length === 0) {
toggleable_lines.push(menu)
}
})
});
toggle_emails_with_no_pgp_key()
}
function show_wkd() {
toggleable_lines = []
$("#wkd_tbody").html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
api(
"/system/pgp/wkd",
"GET",
{},
(info) => {
$("#wkd_submit").attr("disabled", true)
dirty_config = {}
Object.keys(info.wkd).forEach(domain => {
Object.keys(info.wkd[domain]).forEach(email => {
dirty_config[email] = info.wkd[domain][email].selected
})
})
generate_wkd_dropdowns(info)
}
)
}
function update_wkd() {
api(
"/system/pgp/wkd",
"POST",
dirty_config,
(r) => {
show_modal_error("WKD Management", $("<p/>").text(r), () => { if (r == "OK") show_wkd() })
},
(r) => {
show_modal_error("WKD Management", $("<p/>").text(r))
}
)
}
function toggle_emails_with_no_pgp_key() {
let show_toggleable_lines = document.getElementById("wkd-show-all-entries").checked
toggleable_lines.forEach(line => {
line.toggle(show_toggleable_lines)
});
}
</script>

View file

@ -6,186 +6,226 @@ import os.path
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
def load_environment():
# Load settings from /etc/mailinabox.conf.
return load_env_vars_from_file("/etc/mailinabox.conf")
# Load settings from /etc/mailinabox.conf.
return load_env_vars_from_file("/etc/mailinabox.conf")
def load_env_vars_from_file(fn):
# Load settings from a KEY=VALUE file.
import collections
env = collections.OrderedDict()
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
return env
# Load settings from a KEY=VALUE file.
import collections
env = collections.OrderedDict()
for line in open(fn):
env.setdefault(*line.strip().split("=", 1))
return env
def save_environment(env):
with open("/etc/mailinabox.conf", "w") as f:
for k, v in env.items():
f.write("%s=%s\n" % (k, v))
with open("/etc/mailinabox.conf", "w") as f:
for k, v in env.items():
f.write("%s=%s\n" % (k, v))
# 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:
f.write(rtyaml.dump(config))
import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
with open(fn, "w") as f:
f.write(rtyaml.dump(config))
def load_settings(env):
import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
try:
config = rtyaml.load(open(fn, "r"))
if not isinstance(config, dict): raise ValueError() # caught below
return config
except:
return { }
import rtyaml
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
try:
config = rtyaml.load(open(fn, "r"))
if not isinstance(config, dict):
raise ValueError() # caught below
return config
except:
return {}
# UTILITIES
def safe_domain_name(name):
# Sanitize a domain name so it is safe to use as a file name on disk.
import urllib.parse
return urllib.parse.quote(name, safe='')
# Sanitize a domain name so it is safe to use as a file name on disk.
import urllib.parse
return urllib.parse.quote(name, safe='')
def sort_domains(domain_names, env):
# Put domain names in a nice sorted order.
# Put domain names in a nice sorted order.
# The nice order will group domain names by DNS zone, i.e. the top-most
# domain name that we serve that ecompasses a set of subdomains. Map
# each of the domain names to the zone that contains them. Walk the domains
# 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 z in zones.values():
if domain.endswith("." + z):
# We found a parent domain already in the list.
zones[domain] = z
break
else:
# 'break' did not occur: there is no parent domain, so it is its
# own zone.
zones[domain] = domain
# The nice order will group domain names by DNS zone, i.e. the top-most
# domain name that we serve that ecompasses a set of subdomains. Map
# each of the domain names to the zone that contains them. Walk the domains
# 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 z in zones.values():
if domain.endswith("." + z):
# We found a parent domain already in the list.
zones[domain] = z
break
else:
# 'break' did not occur: there is no parent domain, so it is its
# own zone.
zones[domain] = domain
# Sort the zones.
zone_domains = sorted(zones.values(),
key = lambda d : (
# PRIMARY_HOSTNAME or the zone that contains it is always first.
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
# Sort the zones.
zone_domains = sorted(
zones.values(),
key=lambda d: (
# PRIMARY_HOSTNAME or the zone that contains it is always first.
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].
endswith("." + d)),
# Then just dumb lexicographically.
d,
))
# Then just dumb lexicographically.
d,
))
# Now sort the domain names that fall within each zone.
domain_names = sorted(domain_names,
key = lambda d : (
# First by zone.
zone_domains.index(zones[d]),
# Now sort the domain names that fall within each zone.
domain_names = sorted(
domain_names,
key=lambda d: (
# First by zone.
zone_domains.index(zones[d]),
# PRIMARY_HOSTNAME is always first within the zone that contains it.
d != env['PRIMARY_HOSTNAME'],
# PRIMARY_HOSTNAME is always first within the zone that contains it.
d != env['PRIMARY_HOSTNAME'],
# Followed by any of its subdomains.
not d.endswith("." + env['PRIMARY_HOSTNAME']),
# Followed by any of its subdomains.
not d.endswith("." + env['PRIMARY_HOSTNAME']),
# Then in right-to-left lexicographic order of the .-separated parts of the name.
list(reversed(d.split("."))),
))
# Then in right-to-left lexicographic order of the .-separated parts of the name.
list(reversed(d.split("."))),
))
return domain_names
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)
ret = []
for domain in sort_domains(domains, env):
domain_emails = set(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
email_addresses = set(email_addresses)
domains = set(
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))
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):
# A safe way to execute processes.
# Some processes like apt-get require being given a sane PATH.
import subprocess
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
kwargs = {
'env': env,
'stderr': None if not capture_stderr else subprocess.STDOUT,
}
if method == "check_output" and input is not None:
kwargs['input'] = input
def shell(method,
cmd_args,
env={},
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
env.update({"PATH": "/sbin:/bin:/usr/sbin:/usr/bin"})
kwargs = {
'env': env,
'stderr': None if not capture_stderr else subprocess.STDOUT,
}
if method == "check_output" and input is not None:
kwargs['input'] = input
if not trap:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
else:
try:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
code = 0
except subprocess.CalledProcessError as e:
ret = e.output
code = e.returncode
if not return_bytes and isinstance(ret, bytes):
ret = ret.decode("utf8")
if not trap:
return ret
else:
return code, ret
if not trap:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
else:
try:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
code = 0
except subprocess.CalledProcessError as e:
ret = e.output
code = e.returncode
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
if not trap:
return ret
else:
return code, ret
def create_syslog_handler():
import logging.handlers
handler = logging.handlers.SysLogHandler(address='/dev/log')
handler.setLevel(logging.WARNING)
return handler
import logging.handlers
handler = logging.handlers.SysLogHandler(address='/dev/log')
handler.setLevel(logging.WARNING)
return handler
def du(path):
# Computes the size of all files in the path, like the `du` command.
# Based on http://stackoverflow.com/a/17936789. Takes into account
# soft and hard links.
total_size = 0
seen = set()
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
fp = os.path.join(dirpath, f)
try:
stat = os.lstat(fp)
except OSError:
continue
if stat.st_ino in seen:
continue
seen.add(stat.st_ino)
total_size += stat.st_size
return total_size
# Computes the size of all files in the path, like the `du` command.
# Based on http://stackoverflow.com/a/17936789. Takes into account
# soft and hard links.
total_size = 0
seen = set()
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
fp = os.path.join(dirpath, f)
try:
stat = os.lstat(fp)
except OSError:
continue
if stat.st_ino in seen:
continue
seen.add(stat.st_ino)
total_size += stat.st_size
return total_size
def wait_for_service(port, public, env, timeout):
# Block until a service on a given port (bound privately or publicly)
# is taking connections, with a maximum timeout.
import socket, time
import socket
import time
start = time.perf_counter()
while True:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout/3)
s.settimeout(timeout / 3)
try:
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
return True
except OSError:
if time.perf_counter() > start+timeout:
if time.perf_counter() > start + timeout:
return False
time.sleep(min(timeout/4, 1))
def fix_boto():
# Google Compute Engine instances install some Python-2-only boto plugins that
# conflict with boto running under Python 3. Disable boto's default configuration
# file prior to importing boto so that GCE's plugin is not loaded:
import os
os.environ["BOTO_CONFIG"] = "/etc/boto3.cfg"
time.sleep(min(timeout / 4, 1))
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_os_code():
# Massive mess incoming
dist = shell("check_output", ["/usr/bin/lsb_release", "-is"]).strip()
version = shell("check_output", ["/usr/bin/lsb_release", "-rs"]).strip()
if dist == "Debian":
if version == "10":
return "Debian10"
elif version == "11":
return "Debian11"
elif dist == "Ubuntu":
if version == "20.04":
return "Ubuntu2004"
elif version == "22.04":
return "Ubuntu2204"
return None
if __name__ == "__main__":
from web_update import get_web_domains
env = load_environment()

View file

@ -2,14 +2,20 @@
# domains for which a mail account has been set up.
########################################################################
import os.path, re, rtyaml
import os.path
import re
import rtyaml
from mailconfig import get_mail_domains
from dns_update import get_custom_dns_config, get_dns_zones
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
from utils import shell, safe_domain_name, sort_domains, get_php_version
def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True):
def get_web_domains(env,
include_www_redirects=True,
include_auto=True,
exclude_dns_elsewhere=True):
# What domains should we serve HTTP(S) for?
domains = set()
@ -18,20 +24,30 @@ def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True)
# if the user wants to make one.
domains |= get_mail_domains(env)
if include_www_redirects:
if include_www_redirects and include_auto:
# 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))
# Add Autoconfiguration domains for domains that there are user accounts at:
# 'autoconfig.' for Mozilla Thunderbird auto setup.
# 'autodiscover.' for Activesync autodiscovery.
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))
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))
# '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))
# '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))
# 'openpgpkey.' for WKD support
domains |= set('openpgpkey.' + 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
@ -48,79 +64,143 @@ def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True)
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'])):
for domain, rtype, value, ttl in dns:
if rtype == "CNAME" or (rtype in ("A", "AAAA")
and value not in ("local", env['PUBLIC_IP'])):
domains.add(domain)
return domains
def get_web_domains_with_root_overrides(env):
# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',
# which means static hosting is not happening.
root_overrides = { }
root_overrides = {}
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
if os.path.exists(nginx_conf_custom_fn):
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
for domain, settings in custom_settings.items():
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
('proxy', settings.get('proxies', {}).get('/'))]:
for type, value in [('redirect', settings.get('redirects',
{}).get('/')),
('proxy', settings.get('proxies',
{}).get('/'))]:
if value:
root_overrides[domain] = (type, value)
return root_overrides
DOMAIN_EXTERNAL = -1
DOMAIN_PRIMARY = 1
DOMAIN_WWW = 2
DOMAIN_REDIRECT = 4
DOMAIN_WKD = 8
def get_web_domain_flags(env):
flags = dict()
zones = get_dns_zones(env)
email_domains = get_mail_domains(env)
user_domains = get_mail_domains(env, users_only=True)
external = get_domains_with_a_records(env)
redirects = get_web_domains_with_root_overrides(env)
for d in email_domains:
flags[d] = flags.get(d, 0)
flags[f"mta-sts.{d}"] = flags.get(d, 0)
flags[f"openpgpkey.{d}"] = flags.get(d, 0) | DOMAIN_WKD
for d in user_domains:
flags[f"autoconfig.{d}"] = flags.get(d, 0)
flags[f"autodiscover.{d}"] = flags.get(d, 0)
for d, _ in zones:
flags[f"www.{d}"] = flags.get(d, 0) | DOMAIN_WWW
for d in redirects:
flags[d] = flags.get(d, 0) | DOMAIN_REDIRECT
flags[env["PRIMARY_HOSTNAME"]] |= DOMAIN_PRIMARY
# Last check for websites hosted elsewhere
for d in flags.keys():
if d in external:
# -1 = All bits set to 1, assuming twos-complement
flags[d] = DOMAIN_EXTERNAL
return flags
def do_web_update(env):
# Pre-load what SSL certificates we will use for each domain.
ssl_certificates = get_ssl_certificates(env)
# Build an nginx configuration file.
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
nginx_conf = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-top.conf")).read()
nginx_conf = re.sub("{{phpver}}", get_php_version(), nginx_conf)
# Add upstream additions
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www",
".upstream.conf")
if not os.path.exists(nginx_upstream_include):
with open(nginx_upstream_include, "a+") as f:
f.writelines([
f"# Add your nginx-wide configurations here.\n",
"# The following names are already defined:\n\n",
"# # php-default: The php socket used for apps managed by the box. (Roundcube, Z-Push, Nextcloud, etc.) - DO NOT USE!\n",
"# # php-fpm: A php socket not managed by the box. Feel free to use it for your PHP applications\n"
])
nginx_conf += "\ninclude %s;\n" % (nginx_upstream_include)
# Load the templates.
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-default.conf")).read()
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
template3 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-custom.conf")).read()
template4 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
template0 = open(
os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
template1 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-alldomains.conf")).read()
template2 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-primaryonly.conf")).read()
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
template4 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-openpgpkey.conf")).read()
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
default_conf = make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)
default_conf_file = os.path.join(get_web_root(env['PRIMARY_HOSTNAME'], env), ".nginx.conf")
if not os.path.exists(default_conf_file):
with open(default_conf_file, "w") as f:
f.write(default_conf)
nginx_conf += default_conf
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'],
[template0, template1, template2],
ssl_certificates, env)
# Add configuration all other web domains.
has_root_proxy_or_redirect = get_web_domains_with_root_overrides(env)
web_domains_not_redirect = get_web_domains(env, include_www_redirects=False)
for domain in get_web_domains(env):
if domain == env['PRIMARY_HOSTNAME']:
pairs = list(get_web_domain_flags(env).items())
# Sort the domains in some way to keep ordering consistency. Keep domains and subdomains together.
pairs.sort(reverse=False, key=lambda x: x[0][::-1])
for domain, flags in pairs:
if flags & DOMAIN_PRIMARY == DOMAIN_PRIMARY or flags == DOMAIN_EXTERNAL:
# PRIMARY_HOSTNAME is handled above.
continue
if domain in web_domains_not_redirect:
if flags & DOMAIN_WWW == 0:
# This is a regular domain.
local_conf = ""
nginx_conf_custom = os.path.join(get_web_root(domain, env), ".nginx.conf")
if os.path.exists(nginx_conf_custom) and not is_default_web_root(domain, env):
with open(nginx_conf_custom, "r") as f:
local_conf = f.read()
elif domain not in has_root_proxy_or_redirect:
local_conf = make_domain_config(domain, [template0, template3], ssl_certificates, env)
if flags & DOMAIN_WKD == DOMAIN_WKD:
nginx_conf += make_domain_config(
domain, [template0, template1, template4],
ssl_certificates, env)
elif flags & DOMAIN_REDIRECT == 0:
nginx_conf += make_domain_config(domain,
[template0, template1],
ssl_certificates, env)
else:
local_conf = make_domain_config(domain, [template0], ssl_certificates, env)
nginx_conf += local_conf
if not is_default_web_root(domain, env):
with open(nginx_conf_custom, "w+") as f:
f.write(local_conf)
nginx_conf += make_domain_config(domain, [template0],
ssl_certificates, env)
else:
# Add default 'www.' redirect.
nginx_conf += make_domain_config(domain, [template0, template4], ssl_certificates, env)
nginx_conf += make_domain_config(domain, [template0, template3],
ssl_certificates, env)
# Did the file change? If not, don't bother writing & restarting nginx.
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
@ -141,6 +221,7 @@ def do_web_update(env):
return "web updated\n"
def make_domain_config(domain, templates, ssl_certificates, env):
# GET SOME VARIABLES
@ -165,7 +246,9 @@ def make_domain_config(domain, templates, ssl_certificates, env):
finally:
f.close()
return sha1.hexdigest()
nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(
tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
# Add in any user customizations in YAML format.
hsts = "yes"
@ -179,17 +262,27 @@ def make_domain_config(domain, templates, ssl_certificates, env):
for path, url in yaml.get("proxies", {}).items():
# Parse some flags in the fragment of the URL.
pass_http_host_header = False
proxy_redirect_off = False
frame_options_header_sameorigin = False
m = re.search("#(.*)$", url)
if m:
for flag in m.group(1).split(","):
if flag == "pass-http-host":
pass_http_host_header = True
elif flag == "no-proxy-redirect":
proxy_redirect_off = True
elif flag == "frame-options-sameorigin":
frame_options_header_sameorigin = True
url = re.sub("#(.*)$", "", url)
nginx_conf_extra += "\tlocation %s {" % path
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url
if proxy_redirect_off:
nginx_conf_extra += "\n\t\tproxy_redirect off;"
if pass_http_host_header:
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;"
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;"
@ -200,77 +293,107 @@ 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 += "\trewrite %s %s permanent;\n" % (path,
url)
# override the HSTS directive type
hsts = yaml.get("hsts", hsts)
# Add the HSTS header.
if hsts == "yes":
nginx_conf_extra += "add_header Strict-Transport-Security max-age=15768000;\n"
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n"
elif hsts == "preload":
nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\";\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")
if os.path.exists(nginx_conf_custom_include):
nginx_conf_extra += "\tinclude %s;\n" % (nginx_conf_custom_include)
nginx_conf_custom_include = os.path.join(
env["STORAGE_ROOT"], "www",
safe_domain_name(domain) + ".conf")
if not os.path.exists(nginx_conf_custom_include):
with open(nginx_conf_custom_include, "a+") as f:
f.writelines([
f"# Custom configurations for {domain} go here\n",
"# To use php: use the \"php-fpm\" alias\n\n",
"index index.html index.htm;\n"
])
nginx_conf_extra += "\tinclude %s;\n" % (nginx_conf_custom_include)
# PUT IT ALL TOGETHER
# 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]:
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t,
nginx_conf)
# Replace substitution strings in the template & return.
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
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
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
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.
for test_domain in (domain, 'default'):
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
if os.path.exists(root) or not test_exists: break
root = os.path.join(env["STORAGE_ROOT"], "www",
safe_domain_name(test_domain))
if os.path.exists(root) or not test_exists:
break
return root
def is_default_web_root(domain, env):
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain))
return not os.path.exists(root)
def get_web_domains_info(env):
www_redirects = set(get_web_domains(env)) - set(get_web_domains(env, include_www_redirects=False))
www_redirects = set(get_web_domains(env)) - \
set(get_web_domains(env, include_www_redirects=False))
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))
ssl_certificates = get_ssl_certificates(env)
# for the SSL config panel, get cert status
def check_cert(domain):
try:
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
except OSError: # PRIMARY_HOSTNAME cert is missing
tls_cert = get_domain_ssl_files(domain,
ssl_certificates,
env,
allow_missing_cert=True)
except OSError: # PRIMARY_HOSTNAME cert is missing
tls_cert = None
if tls_cert is None: return ("danger", "No certificate installed.")
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
if tls_cert is None:
return ("danger", "No certificate installed.")
cert_status, cert_status_details = check_certificate(
domain, tls_cert["certificate"], tls_cert["private-key"])
if cert_status == "OK":
return ("success", "Signed & valid. " + cert_status_details)
elif cert_status == "SELF-SIGNED":
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
return ("warning",
"Self-signed. Get a signed certificate to stop warnings.")
else:
return ("danger", "Certificate has a problem: " + cert_status)
return [
{
"domain": domain,
"root": get_web_root(domain, env),
"custom_root": get_web_root(domain, env, test_exists=False),
"ssl_certificate": check_cert(domain),
"static_enabled": domain not in (www_redirects | has_root_proxy_or_redirect),
}
for domain in get_web_domains(env)
]
return [{
"domain":
domain,
"root":
get_web_root(domain, env),
"custom_root":
get_web_root(domain, env, test_exists=False),
"ssl_certificate":
check_cert(domain),
"static_enabled":
domain not in (www_redirects | has_root_proxy_or_redirect),
} for domain in get_web_domains(env)]

254
management/wkd.py Normal file
View file

@ -0,0 +1,254 @@
#!/usr/local/lib/mailinabox/env/bin/python
# WDK (Web Key Directory) Manager: Facilitates discovery of keys by third-parties
# Current relevant documents: https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html
import pgp
import utils
import rtyaml
import mailconfig
import copy
import shutil
import os
import re
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
env = utils.load_environment()
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
class WKDError(Exception):
"""
Errors specifically related to WKD.
"""
def __init__(self, msg):
self.message = msg
def __str__(self):
return self.message
def sha1(message):
h = hashes.Hash(hashes.SHA1(), default_backend())
h.update(message)
return h.finalize()
def zbase32(digest):
# Crudely check if all quintets are complete
if len(digest) % 5 != 0:
raise ValueError("Digest cannot have incomplete chunks of 40 bits!")
base = "ybndrfg8ejkmcpqxot1uwisza345h769"
encoded = ""
for i in range(0, len(digest), 5):
chunk = int.from_bytes(digest[i:i + 5], byteorder="big")
for j in range(35, -5, -5):
encoded += base[(chunk >> j) & 31]
return encoded
# Strips and exports a key so that only the UID's with the provided email remain.
# This is to comply with the following requirement, set forth in section 5 of the draft:
#
# The mail provider MUST make sure to publish a key in a way
# that only the mail address belonging to the requested user
# is part of the User ID packets included in the returned key.
# Other User ID packets and their associated binding signatures
# MUST be removed before publication.
#
# The arguments "buffer" and "context" are automatically added
# by the pgp.fork_context() decorator.
@pgp.fork_context
def strip_and_export(fpr, target_email, buffer=None, context=None):
context.armor = False # We need to disable armor output for this key
k = pgp.get_key(fpr, context)
if k is None:
return None
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
def parse_key_dump(dump):
UID_REGEX = r".*:.* <(.*)>:.*:([0-9]),.*"
at_least_one_not_deleted = False
lines = dump.decode().split("\n")
for line in lines:
if line[0:3] != "uid":
continue
# It's a uid, find the email and the "tag"
m = re.search(UID_REGEX, line)
if m.group(1) != target_email:
statusref["sequence"].append(f"uid {m.group(2)}")
else:
at_least_one_not_deleted = True
if not at_least_one_not_deleted:
raise WKDError("All UID's in this key would have been deleted!")
statusref["sequence"] += ["deluid", "save"]
statusref["seq_read"] = True
def interaction(request, prompt):
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
return 0
elif request == "GET_BOOL":
# No way to confirm interactively, so we just say yes
return "y" # Yeah, I'd also rather just return True but that doesn't work
elif request == "GET_LINE" and prompt == "keyedit.prompt":
if not statusref["seq_read"]:
buffer.seek(0, os.SEEK_SET)
parse_key_dump(buffer.read())
statusref["seq_number"] += 1
seqnum = statusref["seq_number"]
return statusref["sequence"][seqnum]
else:
raise Exception("No idea of what to do!")
buffer.seek(0, os.SEEK_SET)
buffer.write(b'')
context.interact(k, interaction, sink=buffer)
return pgp.export_key(fpr, context)
def email_compatible_with_key(email, fingerprint):
# 1. Does the user exist?
if not email in mailconfig.get_all_mail_addresses(env):
raise ValueError(f"User or alias {email} not found!")
if fingerprint is not None:
key = pgp.get_key(fingerprint)
# 2. Does the key exist?
if key is None:
raise ValueError(f"The key \"{fingerprint}\" does not exist!")
# 3. Does the key have a user id with the email of the user?
if email not in [u.email for u in key.uids]:
raise WKDError(
f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!"
)
return key
return None
# Gets a table with all the keys that can be served for each user and/or alias
def get_user_fpr_maps():
uk_maps = {}
for email in mailconfig.get_all_mail_addresses(env):
uk_maps[email] = set()
for key in pgp.get_imported_keys() + [pgp.get_daemon_key()]:
for userid in key.uids:
try:
uk_maps[userid.email].add(key.fpr)
except:
# We don't host this email address, so ignore
pass
return uk_maps
# Gets the current WKD configuration
def get_wkd_config():
# Test
try:
with open(wkdpath, "x"):
pass
except:
pass
with open(wkdpath, "r") as wkdfile:
try:
config = rtyaml.load(wkdfile)
if (type(config) != dict):
return {}
return config
except:
return {}
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
# email: An user or alias on this box. e.g. "administrator@example.com"
# fingerprint: The fingerprint of the key we want to bind it to. e.g "0123456789ABCDEF0123456789ABCDEF01234567"
def update_wkd_config(config_sample):
config = dict(config_sample)
for email, fingerprint in config_sample.items():
try:
if fingerprint is None or fingerprint == "":
config.pop(email)
else:
email_compatible_with_key(email, fingerprint)
except Exception as err:
raise err
# All conditions met, do the necessary modifications
with open(wkdpath, "w") as wkdfile:
wkdfile.write(rtyaml.dump(config))
# Looks for incompatible email/key pairs on the WKD configuration file
# and returns the uid indexes for compatible email/key pairs
def parse_wkd_list():
removed = []
uidlist = []
with open(wkdpath, "a+") as wkdfile:
wkdfile.seek(0)
config = {}
try:
config = rtyaml.load(wkdfile)
if (type(config) != dict):
config = {}
except:
config = {}
writeable = copy.deepcopy(config)
for u, k in config.items():
try:
key = email_compatible_with_key(u, k)
# Key is compatible
# Swap with the full-length fingerprint (if somehow this was changed by hand)
writeable[u] = key.fpr
uidlist.append((u, key.fpr))
except:
writeable.pop(u)
removed.append((u, k))
# Shove the updated configuration back in the file
wkdfile.truncate(0)
wkdfile.write(rtyaml.dump(writeable))
return (removed, uidlist)
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
def build_wkd():
# Clean everything
try:
shutil.rmtree(WKD_LOCATION)
except FileNotFoundError:
pass
os.mkdir(WKD_LOCATION, mode=0o755)
# We serve WKD for all our emails and aliases (even if there are no keys)
for domain in mailconfig.get_mail_domains(env, users_only=False):
os.mkdir(f"{WKD_LOCATION}/{domain}/", mode=0o755)
for email, fpr in parse_wkd_list()[1]:
local, domain = email.split("@", 1)
localhash = zbase32(sha1(local.lower().encode()))
with open(f"{WKD_LOCATION}/{domain}/{localhash}", "wb") as k:
k.write(strip_and_export(fpr, email))

7
management/wsgi.py Normal file
View file

@ -0,0 +1,7 @@
from daemon import app
import auth, utils
app.logger.addHandler(utils.create_syslog_handler())
if __name__ == "__main__":
app.run(port=10222)

View file

@ -3,7 +3,12 @@ Mail-in-a-Box Security Guide
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
This page documents the security features of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
Reporting Security Vulnerabilities
----------------------------------
Security vulnerabilities should be reported to the [project's maintainer](https://joshdata.me) via email.
Threat Model
------------
@ -32,7 +37,7 @@ The box's administrator and its (non-administrative) mail users must sometimes c
These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):
* SMTP Submission (port 587). Mail users submit outbound mail through SMTP with STARTTLS on port 587.
* SMTP Submission (ports 465/587). Mail users submit outbound mail through SMTP with TLS (port 465) or STARTTLS (port 587).
* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.
* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.
@ -44,14 +49,12 @@ The services all follow these rules:
Additionally:
* SMTP Submission (port 587) will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
* SMTP Submission on port 587 will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))
### Password Storage
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
When using the web-based administrative control panel, after logging in an API key is placed in the browser's local storage (rather than, say, the user's actual password). The API key is an HMAC based on the user's email address and current password, and it is keyed by a secret known only to the control panel service. By resetting an administrator's password, any HMACs previously generated for that user will expire.
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py)) Password changes (as well as changes to control panel two-factor authentication settings) expire any control panel login sessions.
### Console access
@ -65,12 +68,10 @@ If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that
`fail2ban` provides some protection from brute-force login attacks (repeated logins that guess account passwords) by blocking offending IP addresses at the network level.
The following services are protected: SSH, IMAP (dovecot), SMTP submission (postfix), webmail (roundcube), Nextcloud/CalDAV/CardDAV (over HTTP), and the Mail-in-a-Box control panel & munin (over HTTP).
The following services are protected: SSH, IMAP (dovecot), SMTP submission (postfix), webmail (roundcube), Nextcloud/CalDAV/CardDAV (over HTTP), and the Mail-in-a-Box control panel (over HTTP).
Some other services running on the box may be missing fail2ban filters.
`fail2ban` only blocks IPv4 addresses, however. If the box has a public IPv6 address, it is not protected from these attacks.
Outbound Mail
-------------

View file

@ -2,27 +2,78 @@
#########################################################
# This script is intended to be run like this:
#
# curl https://dvn.pt/power-miab | sudo bash
# curl -L https://power-mailinabox.net/setup.sh | sudo bash
#
#########################################################
if [ -z "$TAG" ]; then
# Make s
OS=`lsb_release -d | sed 's/.*:\s*//'`
if [ "$OS" == "Debian GNU/Linux 10 (buster)" -o "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ]; then
TAG=v0.47.POWER.0
else
echo "This script must be run on a system running Debian 10 OR Ubuntu 20.04 LTS."
exit 1
fi
fi
# Are we running as root?
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Did you leave out sudo?"
exit 1
fi
if [ ! -f /usr/bin/lsb_release ]; then
# Try installing it (apt install lsb-release)
if [ ! -f /usr/bin/apt-get ]; then
echo "This operating system does not have apt-get! This means it is unsupported!"
echo "This script must be run on a system running one of the following OS-es:"
echo "* Debian 10 (buster)"
echo "* Debian 11 (bullseye)"
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
exit 1
fi
echo "Installing lsb-release to understand which operating system we're running..."
apt-get -q -q update
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y lsb-release < /dev/null
fi
if [ -z "$TAG" ]; then
# Make sure we're running on the correct operating system
OS=$(lsb_release -d | sed 's/.*:\s*//')
if [ "$OS" == "Debian GNU/Linux 11 (bullseye)" ] ||
[ "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ] ||
[ "$(echo $OS | grep -o 'Ubuntu 22.04')" == "Ubuntu 22.04" ]
then
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):"
echo "* Debian 11 (bullseye)"
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
echo
echo "IF YOU'RE UPGRADING THE BOX TO THE LATEST VERSION, PLEASE VISIT THIS PAGE FOR NOTES ON HOW TO"
echo "UPGRADE YOUR SISTEM TO DEBIAN 11 (bullseye)"
echo "https://power-mailinabox.net/buster-eol"
while true; do
read -p "Do you want to proceed? ([Y]es/[N]o) " yn
case $yn in
Yes | Y | yes | y )
break
;;
No | N | no | n )
echo "Installation cancelled."
exit 1
;;
* )
;;
esac
done
TAG=v56.5
else
echo "This script must be run on a system running one of the following OS-es:"
echo "* Debian 11 (bullseye)"
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
exit 1
fi
fi
# Clone the Mail-in-a-Box repository if it doesn't exist.
if [ ! -d $HOME/mailinabox ]; then
if [ ! -f /usr/bin/git ]; then
@ -35,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
@ -46,11 +97,11 @@ fi
cd $HOME/mailinabox
# Update it.
if [ "$TAG" != "`git describe --tags`" ]; then
if [ "$TAG" != "$(git describe --tags)" ]; 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`?"
echo "Update failed. Did you modify something in $(pwd)?"
exit 1
fi
echo

View file

@ -62,7 +62,40 @@ chmod go-rwx $STORAGE_ROOT/mail/dkim
management/editconf.py /etc/opendmarc.conf -s \
"Syslog=true" \
"Socket=inet:8893@[127.0.0.1]"
"Socket=inet:8893@[127.0.0.1]" \
"FailureReports=true"
# 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
# itself, or because you don't trust the arriving header. This added header is
# used by spamassassin to evaluate the mail for spamminess.
management/editconf.py /etc/opendmarc.conf -s \
"SPFIgnoreResults=true"
# SPFSelfValidate causes the filter to perform a fallback SPF check itself
# when it can find no SPF results in the message header. If SPFIgnoreResults
# is also set, it never looks for SPF results in headers and always performs
# the SPF check itself when this is set. This added header is used by
# spamassassin to evaluate the mail for spamminess.
management/editconf.py /etc/opendmarc.conf -s \
"SPFSelfValidate=true"
# Enables generation of failure reports for sending domains that publish a
# "none" policy.
management/editconf.py /etc/opendmarc.conf -s \
"FailureReportsOnNone=true"
# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
# unsigned messages from domains with no "signs all" policy. The reported DKIM
# result will be "none" in such cases. Normally unsigned mail from non-strict
# domains does not cause the results header field to be added. This added header
# is used by spamassassin to evaluate the mail for spamminess.
management/editconf.py /etc/opendkim.conf -s \
"AlwaysAddARHeader=true"
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
# intercepts outgoing mail to perform the signing (by adding a mail header)

View file

@ -10,16 +10,9 @@
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install the packages.
#
# * nsd: The non-recursive nameserver that publishes our DNS records.
# * ldnsutils: Helper utilities for signing DNSSEC zones.
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
echo "Installing nsd (DNS server)..."
apt_install ldnsutils openssh-client
# Prepare nsd's configuration.
# We configure nsd before installation as we only want it to bind to some addresses
# and it otherwise will have port / bind conflicts with bind9 used as the local resolver
mkdir -p /var/run/nsd
mkdir -p /etc/nsd
mkdir -p /etc/nsd/zones
@ -46,6 +39,22 @@ server:
EOF
# Since we have bind9 listening on localhost for locally-generated
# DNS queries that require a recursive nameserver, and the system
# might have other network interfaces for e.g. tunnelling, we have
# to be specific about the network interfaces that nsd binds to.
for ip in $PRIVATE_IP $PRIVATE_IPV6; do
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
done
# Create a directory for additional configuration directives, including
# the zones.conf file written out by our management daemon.
echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf;
# Remove the old location of zones.conf that we generate. It will
# now be stored in /etc/nsd/nsd.conf.d.
rm -f /etc/nsd/zones.conf
# Add log rotation
cat > /etc/logrotate.d/nsd <<EOF;
/var/log/nsd.log {
@ -58,44 +67,27 @@ cat > /etc/logrotate.d/nsd <<EOF;
}
EOF
# Since we have bind9 listening on localhost for locally-generated
# DNS queries that require a recursive nameserver, and the system
# might have other network interfaces for e.g. tunnelling, we have
# to be specific about the network interfaces that nsd binds to.
for ip in $PRIVATE_IP $PRIVATE_IPV6; do
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
done
echo "include: /etc/nsd/zones.conf" >> /etc/nsd/nsd.conf;
# Attempting a late install of nsd (after configuration)
apt_install nsd
# Install the packages.
#
# * nsd: The non-recursive nameserver that publishes our DNS records.
# * ldnsutils: Helper utilities for signing DNSSEC zones.
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
echo "Installing nsd (DNS server)..."
apt_install nsd ldnsutils openssh-client
# Create DNSSEC signing keys.
mkdir -p "$STORAGE_ROOT/dns/dnssec";
# TLDs don't all support the same algorithms, so we'll generate keys using a few
# different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
# algorithm that supported NSEC3, which is a security best practice. However TLDs
# will probably be moving away from it to a a SHA256-based algorithm.
#
# Supports `RSASHA1-NSEC3-SHA1` (didn't test with `RSASHA256`):
#
# * .info
# * .me
#
# Requires `RSASHA256`
#
# * .email
# * .guide
#
# Supports `RSASHA256` (and defaulting to this)
#
# * .fund
# TLDs, registrars, and validating nameservers don't all support the same algorithms,
# so we'll generate keys using a few different algorithms so that dns_update.py can
# choose which algorithm to use when generating the zonefiles. See #1953 for recent
# discussion. File for previously used algorithms (i.e. RSASHA1-NSEC3-SHA1) may still
# be in the output directory, and we'll continue to support signing zones with them
# so that trust isn't broken with deployed DS records, but we won't generate those
# keys on new systems.
FIRST=1 #NODOC
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
for algo in RSASHA256 ECDSAP256SHA256; do
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
if [ $FIRST == 1 ]; then
echo "Generating DNSSEC signing keys..."
@ -104,7 +96,7 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
# matter -- we'll use the same keys for all our domains.
# matter -- we'll use the same keys for all our domains.
#
# `ldns-keygen` outputs the new key's filename to stdout, which
# we're capturing into the `KSK` variable.
@ -112,17 +104,22 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
# ldns-keygen uses /dev/random for generating random numbers by default.
# This is slow and unecessary if we ensure /dev/urandom is seeded properly,
# so we use /dev/urandom. See system.sh for an explanation. See #596, #115.
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 2048 -k _domain_);
# (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_);
# Now create a Zone-Signing Key (ZSK) which is expected to be
# rotated more often than a KSK, although we have no plans to
# rotate it (and doing so would be difficult to do without
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 1024 _domain_);
# 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_);
# These generate two sets of files like:
#
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_`)
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_` so we don't use this file)
# * `K_domain_.+007+08882.key`: public key
# * `K_domain_.+007+08882.private`: private key (secret!)
@ -146,7 +143,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,6 @@
# If there aren't any mail users yet, create one.
if [ -z "`tools/mail.py user`" ]; then
# The outut of "tools/mail.py user" is a list of mail users. If there
if [ -z "$(management/cli.py user)" ]; then
# The outut of "management/cli.py user" is a list of mail users. If there
# aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now.
@ -10,7 +10,7 @@ if [ -z "`tools/mail.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
@ -47,11 +47,14 @@ if [ -z "`tools/mail.py user`" ]; then
fi
# Create the user's mail account. This will ask for a password if none was given above.
tools/mail.py user add $EMAIL_ADDR ${EMAIL_PW:-}
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
# Make it an admin.
hide_output tools/mail.py user make-admin $EMAIL_ADDR
hide_output management/cli.py user make-admin $EMAIL_ADDR
# Create an alias to which we'll direct all automatically-created administrative aliases.
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
fi
# Kick everything that may have been missed
management/mailconfig.py update

View file

@ -9,12 +9,12 @@ function hide_output {
# and returns a non-zero exit code.
# Get a temporary file.
OUTPUT=$(tempfile)
OUTPUT=$(mktemp)
# 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,9 +22,9 @@ function hide_output {
if [ $E != 0 ]; then
# Something failed.
echo
echo FAILED: $@
echo FAILED: "$@"
echo -----------------------------------------
cat $OUTPUT
cat "$OUTPUT"
echo -----------------------------------------
exit $E
fi
@ -53,8 +53,7 @@ function apt_install {
# install' for all of the packages. Calling `dpkg` on each package is slow,
# and doesn't affect what we actually do, except in the messages, so let's
# not do that anymore.
PACKAGES=$@
apt_get_quiet install $PACKAGES
apt_get_quiet install "$@"
}
function get_default_hostname {
@ -223,9 +222,41 @@ function git_clone {
}
function php_version {
php --version | head -n 1 | cut -d " " -f 2 | cut -c 1-3
php --version | head -n 1 | cut -d " " -f 2 | cut -d "." -f 1,2
}
function python_version {
python3 --version | cut -d " " -f 2 | cut -c 1-3
python3 --version | cut -d " " -f 2 | cut -d "." -f 1,2
}
export OS_UNSUPPORTED=0
export OS_DEBIAN_10=1
export OS_UBUNTU_2004=2
export OS_DEBIAN_11=3
export OS_UBUNTU_2204=4
function get_os_code {
# A lot of if-statements here - dirty code looking tasting today
ID=$(lsb_release -is)
VER=$(lsb_release -rs)
if [[ $ID == "Debian" ]]; then
if [[ $VER == "10" ]]; then
echo $OS_DEBIAN_10
return 0
elif [[ $VER == "11" ]]; then
echo $OS_DEBIAN_11
return 0
fi
elif [[ $ID == "Ubuntu" ]]; then
if [[ $VER == "20.04" ]]; then
echo $OS_UBUNTU_2004
return 0
elif [[ $VER == "22.04" ]]; then
echo $OS_UBUNTU_2204
return 0
fi
fi
echo $OS_UNSUPPORTED
}

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
management/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=$(echo "$(nproc) * 250" | bc) \
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \
log_path=/var/log/mail.log
# The inotify `max_user_instances` default is 128, which constrains
@ -66,7 +66,9 @@ management/editconf.py /etc/dovecot/conf.d/10-mail.conf \
first_valid_uid=0
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf
cp conf/dovecot/conf.d/15-mailboxes.conf /etc/dovecot/conf.d/
cp conf/dovecot/conf.d/20-imap.conf /etc/dovecot/conf.d/
cp conf/dovecot/conf.d/90-quota.conf /etc/dovecot/conf.d/
# ### IMAP/POP
@ -84,10 +86,11 @@ management/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
ssl=required \
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
"ssl_protocols=TLSv1.2" \
"ssl_min_protocol=TLSv1.2" \
"ssl_cipher_list=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" \
"ssl_prefer_server_ciphers=no" \
"ssl_dh_parameters_length=2048"
"ssl_dh_parameters_length=2048" \
"ssl_dh=<$STORAGE_ROOT/ssl/dh2048.pem"
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
# login credentials outside of an encrypted connection. Only the over-TLS versions
@ -144,7 +147,7 @@ service imap-login {
}
}
protocol imap {
mail_max_userip_connections = 20
mail_max_userip_connections = 40
}
EOF
@ -183,6 +186,8 @@ plugin {
sieve_after = $STORAGE_ROOT/mail/sieve/global_after
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
sieve_redirect_envelope_from = recipient
sieve_vacation_send_from_recipient = yes
}
EOF

View file

@ -13,11 +13,11 @@
# destinations according to aliases, and passses email on to
# another service for local mail delivery.
#
# The first hop in local mail delivery is to Spamassassin via
# LMTP. Spamassassin then passes mail over to Dovecot for
# The first hop in local mail delivery is to spampd via
# LMTP. spampd then passes mail over to Dovecot for
# storage in the user's mailbox.
#
# Postfix also listens on port 587 (SMTP+STARTLS) for
# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for
# connections from users who can authenticate and then sends
# their email out to the outside world. Postfix queries Dovecot
# to authenticate users.
@ -42,7 +42,7 @@ source /etc/mailinabox.conf # load global vars
# * `ca-certificates`: A trust store used to squelch postfix warnings about
# untrusted opportunistically-encrypted connections.
echo "Installing Postfix (SMTP server)..."
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates libsasl2-modules
# ### Basic Settings
@ -58,7 +58,7 @@ management/editconf.py /etc/postfix/main.cf \
smtp_bind_address=$PRIVATE_IP \
smtp_bind_address6=$PRIVATE_IPV6 \
myhostname=$PRIMARY_HOSTNAME\
smtpd_banner="\$myhostname ESMTP Hi, I'm a Power Mail-in-a-Box (Debian/Postfix)" \
smtpd_banner="\$myhostname ESMTP Power Mail-in-a-Box (Postfix)" \
mydestination=localhost
# Tweak some queue settings:
@ -71,7 +71,7 @@ management/editconf.py /etc/postfix/main.cf \
# ### Outgoing Mail
# Enable the 'submission' port 587 smtpd server and tweak its settings.
# Enable the 'submission' ports 465 and 587 and tweak their settings.
#
# * Enable authentication. It's disabled globally so that it is disabled on port 25,
# so we need to explicitly enable it here.
@ -80,13 +80,19 @@ management/editconf.py /etc/postfix/main.cf \
# OpenDKIM milter only. See dkim.sh.
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
# Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below.
# Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below (but this is ignored with smtpd_tls_wrappermode=yes.)
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
# * Add a new cleanup service specific to the submission service ('authclean')
# that filters out privacy-sensitive headers on mail being sent out by
# authenticated users. By default Postfix also applies this to attached
# emails but we turn this off by setting nested_header_checks empty.
management/editconf.py /etc/postfix/master.cf -s -w \
"smtps=inet n - - - - smtpd
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o syslog_name=postfix/submission
-o smtpd_milters=inet:127.0.0.1:8891
-o cleanup_service_name=authclean" \
"submission=inet n - - - - smtpd
-o smtpd_sasl_auth_enable=yes
-o syslog_name=postfix/submission
@ -107,7 +113,7 @@ sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
# Enable TLS on incoming connections. It is not required on port 25, allowing for opportunistic
# encryption. On port 587 it is mandatory (see above). Shared and non-shared settings are
# encryption. On ports 465 and 587 it is mandatory (see above). Shared and non-shared settings are
# given here. Shared settings include:
# * Require TLS before a user is allowed to authenticate.
# * Set the path to the server TLS certificate and 2048-bit DH parameters for old DH ciphers.
@ -116,10 +122,7 @@ sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
# the world are very far behind and if we disable too much, they may not be able to use TLS and
# won't fall back to cleartext. So we don't disable too much. smtpd_tls_exclude_ciphers applies to
# both port 25 and port 587, but because we override the cipher list for both, it probably isn't used.
# Use Mozilla's "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1
# For port 587 (via the 'mandatory' settings):
# * Use Mozilla's "Intermediate" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1
# using and overriding the "high" cipher list so we don't conflict with the more permissive settings for port 25.
# Use Mozilla's "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&version=3.4.8&config=old&openssl=1.1.1&guideline=5.6
management/editconf.py /etc/postfix/main.cf \
smtpd_tls_security_level=may\
smtpd_tls_auth_only=yes \
@ -129,19 +132,24 @@ management/editconf.py /etc/postfix/main.cf \
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 \
smtpd_tls_exclude_ciphers=aNULL,RC4 \
smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 \
tls_preempt_cipherlist=yes \
smtpd_tls_received_header=yes
# For ports 465/587 (via the 'mandatory' settings):
# * Use Mozilla's "Intermediate" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1
# using and overriding the "high" cipher list so we don't conflict with the more permissive settings for port 25.
management/editconf.py /etc/postfix/main.cf \
smtpd_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
smtpd_tls_mandatory_ciphers=high \
tls_high_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 \
smtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 \
tls_preempt_cipherlist=no \
smtpd_tls_received_header=yes
smtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4
# Prevent non-authenticated users from sending mail that requires being
# relayed elsewhere. We don't want to be an "open relay". On outbound
# mail, require one of:
#
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587).
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 465/587).
# * `permit_mynetworks`: Mail that originates locally.
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
management/editconf.py /etc/postfix/main.cf \
@ -185,17 +193,17 @@ management/editconf.py /etc/postfix/main.cf \
# ### Incoming Mail
# Pass any incoming mail over to a local delivery agent. Spamassassin
# will act as the LDA agent at first. It is listening on port 10025
# with LMTP. Spamassassin will pass the mail over to Dovecot after.
# Pass mail to spampd, which acts as the local delivery agent (LDA),
# which then passes the mail over to the Dovecot LMTP server after.
# spampd runs on port 10025 by default.
#
# In a basic setup we would pass mail directly to Dovecot by setting
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
management/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
# Because of a spampd bug, limit the number of recipients in each connection.
management/editconf.py /etc/postfix/main.cf "virtual_transport=lmtp:[127.0.0.1]:10025"
# Clear the lmtp_destination_recipient_limit setting which in previous
# versions of Mail-in-a-Box was set to 1 because of a spampd bug.
# See https://github.com/mail-in-a-box/mailinabox/issues/1523.
management/editconf.py /etc/postfix/main.cf lmtp_destination_recipient_limit=1
management/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# Who can send mail to us? Some basic filters.
#
@ -216,7 +224,7 @@ management/editconf.py /etc/postfix/main.cf lmtp_destination_recipient_limit=1
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
management/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_recipient_restrictions="check_recipient_access sqlite:/etc/postfix/noreply-addresses.cf",permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023","check_policy_service inet:127.0.0.1:12340"
# 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).
@ -224,11 +232,32 @@ management/editconf.py /etc/postfix/main.cf \
# As a matter of fact RFC is not strict about retry timer so postfix and
# 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).
# 180 seconds (default is 300 seconds). We will move the postgrey database
# under $STORAGE_ROOT. This prevents a "warming up" that would have occured
# 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
# /etc/default/postgrey file. --dbdir=... either needs to be a path without spaces
# (luckily $STORAGE_ROOT does not currently work with spaces), or it needs to be a
# symlink without spaces that can point to a folder with spaces). We'll just assume
# $STORAGE_ROOT won't have spaces to simplify things.
management/editconf.py /etc/default/postgrey \
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
POSTGREY_OPTS=\""--inet=127.0.0.1:10023 --delay=180 --dbdir=$STORAGE_ROOT/mail/postgrey/db"\"
# 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
# Stop the service
service postgrey stop
# Ensure the new paths for postgrey db exists
mkdir -p $STORAGE_ROOT/mail/postgrey/db
# Move over database files
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}
# 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;
#!/bin/bash
@ -262,10 +291,10 @@ management/editconf.py /etc/postfix/main.cf \
# Store default configurations for SMTP relays:
management/editconf.py /etc/postfix/main.cf \
smtp_sasl_auth_enable=no \
smtp_sasl_auth_enable=yes \
smtp_sasl_password_maps="hash:/etc/postfix/sasl_passwd" \
smtp_sasl_security_options=anonymous \
smtp_sasl_tls_security_options=anonymous \
smtp_sasl_security_options=noanonymous \
smtp_sasl_tls_security_options=noanonymous \
smtp_tls_security_level=encrypt \
header_size_limit=4096000
@ -276,6 +305,7 @@ postmap /etc/postfix/sasl_passwd
# Allow the two SMTP ports in the firewall.
ufw_allow smtp
ufw_allow smtps
ufw_allow submission
# Restart services

View file

@ -20,10 +20,25 @@ 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 users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');" | 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 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
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.
@ -49,7 +64,7 @@ driver = sqlite
connect = $db_path
default_pass_scheme = SHA512-CRYPT
password_query = SELECT email as user, password FROM users WHERE email='%u';
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home FROM users WHERE email='%u';
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u';
iterate_query = SELECT email AS user FROM users;
EOF
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
@ -99,8 +114,12 @@ EOF
# ### Destination Validation
# Use a Sqlite3 database to check whether a destination email address exists,
# and to perform any email alias rewrites in Postfix.
# and to perform any email alias rewrites in Postfix. Additionally, we disable
# SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does
# not support it, and if a message is received with the SMTPUTF8 flag it will
# bounce.
management/editconf.py /etc/postfix/main.cf \
smtputf8_enable=no \
virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \
virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
@ -109,7 +128,7 @@ management/editconf.py /etc/postfix/main.cf \
# SQL statement to check if we handle incoming mail for a domain, either for users or aliases.
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
dbpath=$db_path
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' UNION SELECT 1 FROM auto_aliases WHERE source LIKE '%%@%s'
EOF
# SQL statement to check if we handle incoming mail for a user.
@ -144,7 +163,13 @@ EOF
# empty destination here so that other lower priority rules might match.
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
dbpath=$db_path
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s' UNION SELECT destination, 2 as priority FROM auto_aliases WHERE source='%s' AND destination<>'') ORDER BY priority LIMIT 1;
EOF
# SQL statement to check if we're sending to a noreply address.
cat > /etc/postfix/noreply-addresses.cf << EOF;
dbpath=$db_path
query = SELECT 'REJECT This address is not ready to receive email.' FROM noreply WHERE email='%s'
EOF
# Restart Services
@ -153,4 +178,5 @@ EOF
restart_service postfix
restart_service dovecot
# force a recalculation of all user quotas
doveadm quota recalc -A

View file

@ -1,36 +1,30 @@
#!/bin/bash
source setup/functions.sh
source /etc/mailinabox.conf # load global vars
echo "Installing Mail-in-a-Box system management daemon..."
# DEPENDENCIES
# We used to install management daemon-related Python packages
# directly to /usr/local/lib. We moved to a virtualenv because
# these packages might conflict with apt-installed packages.
# We may have a lingering version of acme that conflcits with
# certbot, which we're about to install below, so remove it
# first. Once acme is installed by an apt package, this might
# break the package version and `apt-get install --reinstall python3-acme`
# might be needed in that case.
while [ -d /usr/local/lib/python3.4/dist-packages/acme ]; do
pip3 uninstall -y acme;
done
# duplicity is used to make backups of user data. It uses boto
# (via Python 2) to do backups to AWS S3. boto from the Ubuntu
# package manager is too out-of-date -- it doesn't support the newer
# S3 api used in some regions, which breaks backups to those regions.
# See #627, #653.
#
# virtualenv is used to isolate the Python 3 packages we
# install via pip from the system-installed packages.
#
# certbot installs EFF's certbot which we use to
# provision free TLS certificates.
apt_install duplicity python3-pip python3-gpg virtualenv certbot
hide_output pip3 install --upgrade boto
#
# gcc and build tools are required to install the latest version
# of duplicity
apt_install python3-pip python3-gpg virtualenv certbot rsync librsync2 python3-fasteners python3-future python3-lockfile \
gcc python3-dev librsync-dev gettext
apt_get_quiet remove --autoremove --purge duplicity || /bin/true
# Duplicity does the actual backups.
# b2sdk is used for backblaze backups.
# boto3 is used for amazon aws backups.
# Both are installed outside the pipenv, so they can be used by duplicity
hide_output pip3 install --upgrade b2sdk boto3 duplicity
# Create a virtualenv for the installation of Python 3 packages
# used by the management daemon.
@ -39,6 +33,15 @@ mkdir -p $inst_dir
venv=$inst_dir/env
if [ ! -d $venv ]; then
hide_output virtualenv -ppython3 $venv
elif [ ! -f $venv/.oscode ]; then
echo "Re-creating Python environment..."
rm -rf $venv
hide_output virtualenv -ppython3 $venv
elif [ "$(cat $venv/.oscode)" != $(get_os_code) ]; then
echo "Existing management environment is from an earlier version of the OS you're running."
echo "Re-creating Python environment..."
rm -rf $venv
hide_output virtualenv -ppython3 $venv
fi
# Upgrade pip because the Ubuntu-packaged version is out of date.
@ -49,14 +52,17 @@ hide_output $venv/bin/pip install --upgrade pip
# NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
flask dnspython python-dateutil expiringdict gunicorn \
qrcode[pil] pyotp \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver boto3 b2sdk
# Make the venv use the packaged gpgme bindings (the ones pip provides are severely out-of-date)
if [ ! -d $venv/lib/python$(python_version)/site-packages/gpg/ ]; then
ln -s /usr/lib/python3/dist-packages/gpg/ $venv/lib/python$(python_version)/site-packages/
fi
echo $(get_os_code) > $venv/.oscode
# CONFIGURATION
# Create a backup directory and a random key for encrypting backups.
@ -74,28 +80,54 @@ rm -rf $assets_dir
mkdir -p $assets_dir
# jQuery CDN URL
jquery_version=3.5.1
jquery_url=https://code.jquery.com
jquery_version=3.6.1
jquery_url=https://code.jquery.com # Check this link for new versions
# Get jQuery
wget_verify $jquery_url/jquery-$jquery_version.min.js c8e1c8b386dc5b7a9184c763c88d19a346eb3342 $assets_dir/jquery.min.js
wget_verify $jquery_url/jquery-$jquery_version.min.js ea61688671d0c3044f2c5b2f2c4af0a6620ac6c2 $assets_dir/jquery.min.js
# Bootstrap CDN URL
bootstrap_version=4.5.0
# 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
# Get Bootstrap
wget_verify $bootstrap_url 240002ac66f2f6579f266bd07277573d2ad2e63a /tmp/bootstrap.zip
wget_verify $bootstrap_url 740b34c22cef5c2f12a34f084b813ea308fedf74 /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
# FontAwesome CDN URL
# 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 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
# Create an init script to start the management daemon and keep it
# running after a reboot.
# Set a long timeout since some commands take a while to run, matching
# the timeout we set for PHP (fastcgi_read_timeout in the nginx confs).
# Note: Authentication currently breaks with more than 1 gunicorn worker.
cat > $inst_dir/start <<EOF;
#!/bin/bash
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
export LANGUAGE=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8
export LC_TYPE=en_US.UTF-8
mkdir -p /var/lib/mailinabox
tr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key
chmod 640 /var/lib/mailinabox/api.key
source $venv/bin/activate
exec python `pwd`/management/daemon.py
export PYTHONPATH=$(pwd)/management
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
EOF
chmod +x $inst_dir/start
cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first
@ -110,8 +142,19 @@ 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.
restart_service mailinabox
# FOR DEVELOPMENT PURPOSES ONLY:
# If there is a CA certificate in the folder, install it.
# MIAB will only accept a manual certificate installation
# if it is signed by a CA trusted by it.
if [[ -f mailinabox-ca.crt ]]; then
echo "Custom CA certificate detected. Installing..."
rm -f /usr/local/share/ca-certificates/mailinabox-ca.crt
cp mailinabox-ca.crt /usr/local/share/ca-certificates/
hide_output update-ca-certificates --fresh
fi

View file

@ -181,6 +181,22 @@ def migration_12(env):
conn.commit()
conn.close()
def migration_13(env):
# Add the "mfa" table for configuring MFA for login to the control panel.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
def migration_14(env):
# Add the "noreply" table for noreply addresses.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);"])
def migration_15(env):
# Add the "auto_aliases" table.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
###########################################################
def get_current_migration():
ver = 0

View file

@ -9,17 +9,66 @@ source /etc/mailinabox.conf # load global vars
echo "Installing Nextcloud (contacts/calendar)..."
apt-get purge -qq -y owncloud* # we used to use the package manager
# Nextcloud core and app (plugin) versions to install.
# With each version we store a hash to ensure we install what we expect.
# Nextcloud core
# --------------
# * See https://nextcloud.com/changelog for the latest version.
# * Check https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html
# for whether it supports the version of PHP available on this machine.
# * Since Nextcloud only supports upgrades from consecutive major versions,
# 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 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/master/appinfo/info.xml
# https://github.com/nextcloud-releases/calendar/blob/master/appinfo/info.xml
# https://github.com/nextcloud-releases/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.
contacts_ver=4.2.2
contacts_hash=cbab9a7acdc11a9e2779c20b850bb21faec1c80f
calendar_ver=3.5.2
calendar_hash=dcf2cba6933dc8805ca4b4d04ed7b993ff4652a1
user_external_ver=3.0.0
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
# Clear prior packages and install dependencies from apt.
# Just in case someone tries to cross over from the upstream version.
apt-get purge -qq -y owncloud* 2> /dev/null || /bin/true
apt_install php php-fpm \
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \
php-intl php-imagick
php-dev php-xml php-mbstring php-zip php-apcu php-json \
php-intl php-imagick php-gmp php-bcmath
phpenmod apcu
management/editconf.py /etc/php/$(php_version)/mods-available/apcu.ini -c ';' \
apc.enabled=1 \
apc.enable_cli=1
management/editconf.py /etc/php/$(php_version)/cli/php.ini -c ';' \
apc.enable_cli=1
InstallNextcloud() {
version=$1
hash=$2
version_contacts=$3
hash_contacts=$4
version_calendar=$5
hash_calendar=$6
version_user_external=${7:-}
hash_user_external=${8:-}
echo
echo "Upgrading to Nextcloud version $version"
@ -40,18 +89,42 @@ InstallNextcloud() {
# their github repositories.
mkdir -p /usr/local/lib/owncloud/apps
wget_verify https://github.com/nextcloud/contacts/releases/download/v3.3.0/contacts.tar.gz e55d0357c6785d3b1f3b5f21780cb6d41d32443a /tmp/contacts.tgz
IFS='.'
read -a checkVer <<< "$version_contacts"
unset IFS
if [ "${checkVer[0]}" -gt 4 ] || [ "${checkVer[0]}" -eq 4 -a "${checkVer[1]}" -gt 0 ] || [ "${checkVer[0]}" -eq 4 -a "${checkVer[2]}" -gt 0 ]; then
# Contacts 4.0.1 and later are downloaded from here
wget_verify https://github.com/nextcloud-releases/contacts/releases/download/v$version_contacts/contacts-v$version_contacts.tar.gz $hash_contacts /tmp/contacts.tgz
else
# 4.0.0 and earlier are downloaded from here
wget_verify https://github.com/Nextcloud/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz
fi
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/contacts.tgz
wget_verify https://github.com/nextcloud/calendar/releases/download/v2.0.3/calendar.tar.gz 9d9717b29337613b72c74e9914c69b74b346c466 /tmp/calendar.tgz
IFS='.'
read -a checkVer <<< "$version_calendar"
unset IFS
if [ "${checkVer[0]}" -eq 2 -a "${checkVer[1]}" -gt 2 ] || [ "${checkVer[0]}" -gt 2 ]; then
# Calendar 2.3.0 and later are downloaded from here
wget_verify https://github.com/nextcloud-releases/calendar/releases/download/v$version_calendar/calendar-v$version_calendar.tar.gz $hash_calendar /tmp/calendar.tgz
else
wget_verify https://github.com/nextcloud/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz
fi
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/calendar.tgz
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
# we will install from their github repository.
if [[ $version =~ ^1[567] ]]; then
wget_verify https://github.com/nextcloud/user_external/releases/download/v0.10.0/user_external-0.10.0.tar.gz 133c0d65aba1b09c28b21d05477c122041a9abc2 /tmp/user_external.tgz
if [ -n "$version_user_external" ]; then
IFS='.'
read -a checkVer <<< "$version_user_external"
unset IFS
if [ "${checkVer[0]}" -gt 2 ]; 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
else
wget_verify https://github.com/nextcloud/user_external/releases/download/v$version_user_external/user_external-$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
fi
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/user_external.tgz
fi
@ -90,19 +163,33 @@ InstallNextcloud() {
fi
}
# Nextcloud Version to install. Checks are done down below to step through intermediate versions.
nextcloud_ver=19.0.0
nextcloud_hash=d2c631327873ce42ff6a90f9916396f1f7202ea1
# Current Nextcloud Version, #1623
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
# $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
@ -116,7 +203,7 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
# Backup the existing ownCloud/Nextcloud.
# Create a backup directory to store the current installation and database to
BACKUP_DIRECTORY=$STORAGE_ROOT/owncloud-backup/`date +"%Y-%m-%d-%T"`
BACKUP_DIRECTORY=$STORAGE_ROOT/owncloud-backup/$(date +"%Y-%m-%d-%T")
mkdir -p "$BACKUP_DIRECTORY"
if [ -d /usr/local/lib/owncloud/ ]; then
echo "Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY..."
@ -134,30 +221,63 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
# PHP 7.
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup aborting."
exit 1
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
return 0
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[012] ]]; then
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup aborting."
exit 1
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
return 0
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then
# If we are running Nextcloud 13, upgrade to Nextcloud 14
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466
CURRENT_NEXTCLOUD_VER="14.0.6"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
# We will disable it here before the upgrade and install it again after the upgrade.
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="15.0.8"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="16.0.6"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="17.0.6"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then
# Don't exit the install if this column already exists (see #2076)
(echo "ALTER TABLE oc_flow_operations ADD COLUMN entity VARCHAR;" | sqlite3 $STORAGE_ROOT/owncloud/owncloud.db 2>/dev/null) || true
InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
CURRENT_NEXTCLOUD_VER="18.0.10"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
CURRENT_NEXTCLOUD_VER="19.0.4"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^19 ]]; then
InstallNextcloud 20.0.14 92cac708915f51ee2afc1787fd845476fd090c81 4.0.8 9f368bb2be98c5555b7118648f4cc9fa51e8cb30 3.0.6 ca49bb1ce23f20e10911e39055fd59d7f7a84c30 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
# Nextcloud 20 needs to have some optional columns added
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns
CURRENT_NEXTCLOUD_VER="20.0.14"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then
InstallNextcloud 21.0.9 cf8785107c3c079a1f450743558f4f13c85f37a8 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 2.1.0 6e5afe7f36f398f864bfdce9cad72200e70322aa
CURRENT_NEXTCLOUD_VER="21.0.9"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^21 ]]; then
InstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
CURRENT_NEXTCLOUD_VER="22.2.6"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^22 ]]; then
InstallNextcloud 23.0.4 87afec0bf90b3c66289e6fedd851867bc5a58f01 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
CURRENT_NEXTCLOUD_VER="23.0.4"
fi
fi
InstallNextcloud $nextcloud_ver $nextcloud_hash
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
fi
# ### Configuring Nextcloud
@ -183,10 +303,10 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
'overwrite.cli.url' => '/cloud',
'user_backends' => array(
array(
'class' => 'OC_User_IMAP',
'arguments' => array(
'127.0.0.1', 143, null
),
'class' => '\OCA\UserExternal\IMAP',
'arguments' => array(
'127.0.0.1', 143, null, null, false, false
),
),
),
'memcache.local' => '\OC\Memcache\APCu',
@ -213,6 +333,7 @@ EOF
# storage/database
'directory' => '$STORAGE_ROOT/owncloud',
'dbtype' => 'sqlite3',
'dbname' => 'owncloud',
# create an administrator account with a random password so that
# the user does not have to enter anything on first load of Nextcloud
@ -243,11 +364,12 @@ 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");
\$CONFIG['config_is_read_only'] = true;
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
@ -259,7 +381,14 @@ include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
\$CONFIG['user_backends'] = array(array('class' => 'OC_User_IMAP','arguments' => array('127.0.0.1', 143, null),),);
\$CONFIG['user_backends'] = array(
array(
'class' => '\OCA\UserExternal\IMAP',
'arguments' => array(
'127.0.0.1', 143, null, null, false, false
),
),
);
echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG);
@ -283,6 +412,11 @@ hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
# Disable default apps that we don't support
sudo -u www-data \
php /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)
management/editconf.py /etc/php/$(php_version)/fpm/php.ini -c ';' \
@ -303,11 +437,11 @@ management/editconf.py /etc/php/$(php_version)/cli/conf.d/10-opcache.ini -c ';'
opcache.save_comments=1 \
opcache.revalidate_freq=1
# If apc is explicitly disabled we need to enable it
if grep -q apc.enabled=0 /etc/php/$(php_version)/mods-available/apcu.ini; then
management/editconf.py /etc/php/$(php_version)/mods-available/apcu.ini -c ';' \
apc.enabled=1
fi
# Migrate users_external data from <0.6.0 to version 3.0.0 (see https://github.com/nextcloud/user_external).
# 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
# Set up a cron job for Nextcloud.
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
@ -317,14 +451,11 @@ cat > /etc/cron.d/mailinabox-nextcloud << EOF;
EOF
chmod +x /etc/cron.d/mailinabox-nextcloud
# Remove previous hourly cronjob
rm -f /etc/cron.hourly/mailinabox-owncloud
# There's nothing much of interest that a user could do as an admin for Nextcloud,
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
# But if we wanted to, we would do this:
# ```
# for user in $(tools/mail.py user admins); do
# for user in $(management/cli.py user admins); do
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
# done
# ```

View file

@ -13,20 +13,42 @@ export GNUPGHOME # Dump into the environment so that gpg uses it as homedir
# Install gnupg
apt_install gnupg
if [ "$(gpg --list-secret-keys 2> /dev/null)" = "" -o "${PGPKEY-}" = "" ]; then
echo "No keypair found. Generating daemon's PGP keypair..."
gpg --generate-key --batch << EOF;
function gpg_keygen {
# Generates a private key.
gpg --generate-key --batch 2> /tmp/gpg_keygen_log << EOF;
%no-protection
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign,encrypt,auth
Name-Real: Power Mail-in-a-Box Management Daemon
Name-Email: administrator@${PRIMARY_HOSTNAME}
Name-Real: System Management Daemon
Name-Email: noreply-daemon@${PRIMARY_HOSTNAME}
Expire-Date: 180d
%commit
EOF
}
# Generate a new key if:
# - There isn't a fingerprint on /etc/mailinabox.conf
# - The configured fingerprint doesn't actually exist
if [ -z "${PGPKEY:-}" -o "$(gpg --list-secret-keys 2> /dev/null | grep ${PGPKEY:-\r\n})" = "" ]; then
echo "No keypair found. Generating daemon's PGP keypair..."
gpg_keygen
if [ $? -ne 0 ]; then
echo ""
echo "Key generation failed!" 1>&2
echo "============================" 1>&2
cat /tmp/gpg_keygen_log 1>&2
echo "============================" 1>&2
exit 1
fi
FPR=$(cat /tmp/gpg_keygen_log | tr -d '\n' | sed -r 's/.*([0-9A-F]{40}).*/\1/g')
echo "Generated key with fingerprint $FPR"
chown -R root:root $GNUPGHOME
# Remove the old key fingerprint if it exists, and add the new one
# Remove the old key fingerprint from the configuration if it exists, and add the new one
echo "$(cat /etc/mailinabox.conf | grep -v "PGPKEY")" > /etc/mailinabox.conf
echo "PGPKEY=$(gpg --list-secret-keys --with-colons | grep fpr | head -n 1 | sed 's/fpr//g' | sed 's/://g')" >> /etc/mailinabox.conf
echo "PGPKEY=$FPR" >> /etc/mailinabox.conf
fi

View file

@ -1,3 +1,5 @@
source setup/functions.sh
# Are we running as root?
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root. Please re-run like this:"
@ -7,16 +9,33 @@ if [[ $EUID -ne 0 ]]; then
exit 1
fi
# Check that we are running on Debian GNU/Linux, or Ubuntu 20.04
OS=`lsb_release -d | sed 's/.*:\s*//'`
if [ "$OS" != "Debian GNU/Linux 10 (buster)" -a "$(echo $OS | grep -o 'Ubuntu 20.04')" != "Ubuntu 20.04" ]; then
echo "Mail-in-a-Box only supports being installed on Debian 10 or Ubuntu 20.04 LTS, sorry. You are running:"
echo
lsb_release -d | sed 's/.*:\s*//'
echo
echo "We can't write scripts that run on every possible setup, sorry."
exit 1
fi
# Check that we are running on Debian GNU/Linux, or Ubuntu 20.04/22.04
case $(get_os_code) in
$OS_UNSUPPORTED)
echo "This version of Power Mail-in-a-Box only supports being installed on one of these operating systems:"
# echo "* Debian 10 (buster)"
echo "* Debian 11 (bullseye)"
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
echo
echo "You're running:"
lsb_release -ds
echo
echo "We can't write scripts that run on every possible setup, sorry."
exit 1
;;
$OS_DEBIAN_10)
echo "You're trying to install Power Mail-in-a-Box on Debian 10 (buster), which is no longer supported."
echo "You can install the latest version of Power Mail-in-a-Box supporting Debian 10 by running the following command:"
echo
echo "curl -L https://power-mailinabox.net/setup.sh | sudo bash"
echo
echo "Then upgrade to Debian 11 (bullseye). A short guide on how to do so is available here:"
echo "https://power-mailinabox.net/buster-eol"
exit 1
;;
esac
# Check that we have enough memory.
#

View file

@ -67,6 +67,56 @@ management/editconf.py /etc/spamassassin/local.cf -s \
"add_header all Report"=_REPORT_ \
"add_header all Score"=_SCORE_
# Authentication-Results SPF/Dmarc checks
# ---------------------------------------
# OpenDKIM and OpenDMARC are configured to validate and add "Authentication-Results: ..."
# headers by checking the sender's SPF & DMARC policies. Instead of blocking mail that fails
# these checks, we can use these headers to evaluate the mail as spam.
#
# Our custom rules are added to their own file so that an update to the deb package config
# does not remove our changes.
#
# We need to escape period's in $PRIMARY_HOSTNAME since spamassassin config uses regex.
escapedprimaryhostname="${PRIMARY_HOSTNAME//./\\.}"
cat > /etc/spamassassin/miab_spf_dmarc.cf << EOF
# Evaluate DMARC Authentication-Results
header DMARC_PASS Authentication-Results =~ /$escapedprimaryhostname; dmarc=pass/
describe DMARC_PASS DMARC check passed
score DMARC_PASS -0.1
header DMARC_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=none/
describe DMARC_NONE DMARC record not found
score DMARC_NONE 0.1
header DMARC_FAIL_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=none/
describe DMARC_FAIL_NONE DMARC check failed (p=none)
score DMARC_FAIL_NONE 2.0
header DMARC_FAIL_QUARANTINE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=quarantine/
describe DMARC_FAIL_QUARANTINE DMARC check failed (p=quarantine)
score DMARC_FAIL_QUARANTINE 5.0
header DMARC_FAIL_REJECT Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=reject/
describe DMARC_FAIL_REJECT DMARC check failed (p=reject)
score DMARC_FAIL_REJECT 10.0
# Evaluate SPF Authentication-Results
header SPF_PASS Authentication-Results =~ /$escapedprimaryhostname; spf=pass/
describe SPF_PASS SPF check passed
score SPF_PASS -0.1
header SPF_NONE Authentication-Results =~ /$escapedprimaryhostname; spf=none/
describe SPF_NONE SPF record not found
score SPF_NONE 2.0
header SPF_FAIL Authentication-Results =~ /$escapedprimaryhostname; spf=fail/
describe SPF_FAIL SPF check failed
score SPF_FAIL 5.0
EOF
# Bayesean learning
# -----------------
#

View file

@ -10,7 +10,7 @@
#
# * DNSSEC DANE TLSA records
# * IMAP
# * SMTP (opportunistic TLS for port 25 and submission on port 587)
# * SMTP (opportunistic TLS for port 25 and submission on ports 465/587)
# * HTTPS
#
# The certificate is created with its CN set to the PRIMARY_HOSTNAME. It is
@ -19,7 +19,7 @@
#
# The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a
# Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
# provides Perfect Forward Secrecy.
# provides Perfect Forward Secrecy.
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars

View file

@ -14,9 +14,14 @@ source setup/preflight.sh
# Python may not be able to read/write files. This is also
# in the management daemon startup script and the cron script.
# Make sure we have locales at all (some images are THAT minimal)
apt_get_quiet install locales
if ! locale -a | grep en_US.utf8 > /dev/null; then
echo "Generating locales..."
# Generate locale if not exists
hide_output locale-gen en_US.UTF-8
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
hide_output locale-gen
fi
export LANGUAGE=en_US.UTF-8
@ -44,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`
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
@ -67,6 +72,10 @@ fi
fi
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
#
# Set the directory and all of its parent directories' permissions to world
# readable since it holds files owned by different processes.
#
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
# 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
@ -77,11 +86,15 @@ fi
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
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
fi
chmod 751 $STORAGE_ROOT
# Save the global options in /etc/mailinabox.conf so that standalone
# tools know where to look for data. The default MTA_STS_MODE setting
# is blank unless set by an environment variable, but see web.sh for
@ -94,9 +107,9 @@ PUBLIC_IP=$PUBLIC_IP
PUBLIC_IPV6=$PUBLIC_IPV6
PRIVATE_IP=$PRIVATE_IP
PRIVATE_IPV6=$PRIVATE_IPV6
MTA_STS_MODE=${MTA_STS_MODE-}
GNUPGHOME=${STORAGE_ROOT}/.gnupg/
PGPKEY=${DEFAULT_PGPKEY-}
MTA_STS_MODE=${DEFAULT_MTA_STS_MODE:-enforce}
EOF
# Start service configuration.
@ -116,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

@ -14,6 +14,15 @@ source setup/functions.sh # load our functions
echo $PRIMARY_HOSTNAME > /etc/hostname
hostname $PRIMARY_HOSTNAME
# ### Enable IPv6 at Kernel Level
# This doesn't mean that the cloud provider must provide IPv6 connectivity. We just want
# 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"
hide_output sysctl --system
# ### Fix permissions
# The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect
@ -75,7 +84,12 @@ then
fi
fi
# Certbot doesn't require a PPA in Debian
# ### Set log retention policy.
# Set the systemd journal log retention from infinite to 10 days,
# since over time the logs take up a large amount of space.
# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)
management/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day
# ### Update Packages
@ -97,9 +111,6 @@ apt_get_quiet autoremove
# Install basic utilities.
#
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
# when generating random numbers for private keys (e.g. during
# ldns-keygen).
# * unattended-upgrades: Apt tool to install security updates automatically.
# * cron: Runs background processes periodically.
# * ntp: keeps the system time correct
@ -112,9 +123,9 @@ apt_get_quiet autoremove
# * openssh-client: provides ssh-keygen
echo Installing system packages...
apt_install python3 python3-dev python3-pip \
netcat-openbsd wget curl git sudo coreutils bc \
haveged pollinate openssh-client unzip \
apt_install python3 python3-dev python3-pip python3-setuptools \
netcat-openbsd wget curl git sudo coreutils bc file \
pollinate openssh-client unzip \
unattended-upgrades cron ntp fail2ban rsyslog
# ### Suppress Upgrade Prompts
@ -209,7 +220,11 @@ dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null
# is really any good on virtualized systems, we'll also seed from Ubuntu's
# pollinate servers:
pollinate -q -r
if ! pollinate -q -r --strict 2> /dev/null; then
# In the case pollinate is ill-configured (e.g. server is example.com), try using a server we know that works
# Even if this fails - don't bail and carry on.
pollinate -q -r -s entropy.ubuntu.com 2> /dev/null
fi
# Between these two, we really ought to be all set.
@ -239,20 +254,23 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
# Install `ufw` which provides a simple firewall configuration.
apt_install ufw
# Allow incoming connections to SSH.
ufw_limit ssh;
# Check if we have got an SSH server installed.
# It's not critical for us to have one, so if it isn't installed,
# no need to open the port
if [ -x "$(command -v sshd)" ]; then
# Allow incoming connections to SSH.
ufw_limit ssh;
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
# 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 [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_limit $SSH_PORT #NODOC
fi
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
# 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 [ "$SSH_PORT" != "22" ]; then
echo Opening alternate SSH port $SSH_PORT. #NODOC
ufw_limit $SSH_PORT #NODOC
fi
fi
fi
ufw --force enable;
@ -301,14 +319,25 @@ fi #NODOC
# name server, on IPV6.
# * The listen-on directive in named.conf.options restricts `bind9` to
# binding to the loopback interface instead of all interfaces.
# * The max-recursion-queries directive increases the maximum number of iterative queries.
# If more queries than specified are sent, bind9 returns SERVFAIL. After flushing the cache during system checks,
# we ran into the limit thus we are increasing it from 75 (default value) to 100.
apt_install bind9
touch /etc/default/bind9
touch /etc/default/named
management/editconf.py /etc/default/bind9 \
"OPTIONS=\"-u bind -4\""
management/editconf.py /etc/default/named \
"OPTIONS=\"-u bind -4\""
if ! grep -q "listen-on " /etc/bind/named.conf.options; then
# Add a listen-on directive if it doesn't exist inside the options block.
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
fi
if ! grep -q "max-recursion-queries " /etc/bind/named.conf.options; then
# Add a max-recursion-queries directive if it doesn't exist inside the options block.
sed -i "s/^}/\n\tmax-recursion-queries 100;\n}/" /etc/bind/named.conf.options
fi
# First we'll disable systemd-resolved's management of resolv.conf and its stub server.
# Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means
@ -331,6 +360,7 @@ systemctl restart systemd-resolved
rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore
rm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config
cat conf/fail2ban/jails.conf \
| sed "s/PUBLIC_IPV6/$PUBLIC_IPV6/g" \
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
| sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
> /etc/fail2ban/jail.d/mailinabox.conf

View file

@ -95,6 +95,14 @@ else
pm.max_spare_servers=18
fi
# Duplicate the socket to isolate MiaB apps from user apps that happen to run php
cp /etc/php/$(php_version)/fpm/pool.d/www.conf /etc/php/$(php_version)/fpm/pool.d/miab.conf
management/editconf.py /etc/php/$(php_version)/fpm/pool.d/miab.conf -c ';' \
listen=/run/php/php-default.sock
sed -i 's/\[www\]/[miab]/' /etc/php/$(php_version)/fpm/pool.d/miab.conf
# Other nginx settings will be configured by the management service
# since it depends on what domains we're serving, which we don't know
# until mail accounts have been created.
@ -124,15 +132,15 @@ chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
# Create a generic mta-sts.txt file which is exposed via the
# nginx configuration at /.well-known/mta-sts.txt
# more documentation is available on:
# more documentation is available on:
# https://www.uriports.com/blog/mta-sts-explained/
# default mode is "enforce". Change to "testing" which means
# "Messages will be delivered as though there was no failure
# but a report will be sent if TLS-RPT is configured" if you
# are not sure you want this yet. Or "none".
# default mode is "enforce". In /etc/mailinabox.conf change
# "MTA_STS_MODE=testing" which means "Messages will be delivered
# as though there was no failure but a report will be sent if
# TLS-RPT is configured" if you are not sure you want this yet. Or "none".
PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2)
cat conf/mta-sts.txt \
| sed "s/MODE/${MTA_STS_MODE:-enforce}/" \
| sed "s/MODE/${MTA_STS_MODE}/" \
| sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \
> /var/lib/mailinabox/mta-sts.txt
chmod a+r /var/lib/mailinabox/mta-sts.txt
@ -141,7 +149,7 @@ chmod a+r /var/lib/mailinabox/mta-sts.txt
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
sed "s/{{PRIMARY_HOSTNAME}}/$PRIMARY_HOSTNAME/" conf/www_default.html | sed "s#{{STORAGE_ROOT}}#$STORAGE_ROOT#" > $STORAGE_ROOT/www/default/index.html
fi
chown -R $STORAGE_USER $STORAGE_ROOT/www
@ -152,3 +160,6 @@ restart_service php$(php_version)-fpm
# Open ports.
ufw_allow http
ufw_allow https
# Allow the webserver to access directories group-owned by user-data
usermod -a -G user-data www-data

View file

@ -23,17 +23,24 @@ echo "Installing Roundcube (webmail)..."
apt_install \
dbconfig-common \
php-cli php-sqlite3 php-intl php-json php-common php-curl php-ldap \
php-gd php-pspell tinymce libjs-jquery libjs-jquery-mousewheel libmagic1 php-mbstring php-gnupg
php-gd php-pspell libjs-jquery libjs-jquery-mousewheel libmagic1 php-mbstring php-gnupg
# Install Roundcube from source if it is not already present or if it is out of date.
# Combine the Roundcube version number with the commit hash of plugins to track
# whether we have the latest version of everything.
VERSION=1.4.7
HASH=49F194D25AC7B9BF175BD52285BB61CDE7BAED44
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
CARDDAV_VERSION=3.0.3
CARDDAV_HASH=d1e3b0d851ffa2c6bd42bf0c04f70d0e1d0d78f8
# 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/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.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
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
@ -46,7 +53,7 @@ needs_update=0 #NODOC
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
# not installed yet #NODOC
needs_update=1 #NODOC
elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
elif [[ "$UPDATE_KEY" != $(cat /usr/local/lib/roundcubemail/version) ]]; then
# checks if the version is what we want
needs_update=1 #NODOC
fi
@ -69,20 +76,20 @@ if [ $needs_update == 1 ]; then
rm -f /tmp/roundcube.tgz
# install roundcube persistent_login plugin
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' ${RCM_PLUGIN_DIR}/persistent_login
git_clone https://github.com/mfreiholz/persistent_login.git $PERSISTENT_LOGIN_VERSION '' ${RCM_PLUGIN_DIR}/persistent_login
# install roundcube html5_notifier plugin
git_clone https://github.com/kitist/html5_notifier.git $HTML5_NOTIFIER_VERSION '' ${RCM_PLUGIN_DIR}/html5_notifier
# download and verify the full release of the carddav plugin
wget_verify \
https://github.com/blind-coder/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-${CARDDAV_VERSION}.zip \
https://github.com/mstilkerich/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-v${CARDDAV_VERSION}.tar.gz \
$CARDDAV_HASH \
/tmp/carddav.zip
/tmp/carddav.tar.gz
# unzip and cleanup
unzip -q /tmp/carddav.zip -d ${RCM_PLUGIN_DIR}
rm -f /tmp/carddav.zip
tar -C ${RCM_PLUGIN_DIR} -zxf /tmp/carddav.tar.gz
rm -f /tmp/carddav.tar.gz
# record the version we've installed
echo $UPDATE_KEY > ${RCM_DIR}/version
@ -90,8 +97,9 @@ fi
# ### Configuring Roundcube
# Generate a safe 24-character secret key of safe characters.
SECRET_KEY=$(dd if=/dev/urandom bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
# Generate a secret key of PHP-string-safe characters appropriate
# for the cipher algorithm selected below.
SECRET_KEY=$(dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | sed s/=//g)
# Create a configuration file.
#
@ -107,36 +115,42 @@ cat > $RCM_CONFIG <<EOF;
\$config['log_dir'] = '/var/log/roundcubemail/';
\$config['temp_dir'] = '/var/tmp/roundcubemail/';
\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';
\$config['default_host'] = 'ssl://localhost';
\$config['default_port'] = 993;
\$config['imap_host'] = 'ssl://localhost:993';
\$config['imap_conn_options'] = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
),
);
'ssl'=> array(
'verify_peer' => false,
'verify_peer_name' => false,
),
);
\$config['imap_timeout'] = 15;
\$config['smtp_server'] = 'tls://127.0.0.1';
\$config['smtp_host'] = 'tls://127.0.0.1:587';
\$config['smtp_conn_options'] = array(
'ssl' => array(
'verify_peer' => false,
'verify_peer_name' => false,
),
);
\$config['support_url'] = 'https://mailinabox.email/';
'ssl'=> array(
'verify_peer' => false,
'verify_peer_name' => false,
),
);
\$config['support_url'] = 'https://power-mailinabox.net/';
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
\$config['des_key'] = '$SECRET_KEY';
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav', 'enigma');
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
\$config['des_key'] = '$SECRET_KEY'; # 37 characters -> ~256 bits for AES-256, see above
\$config['skin'] = 'elastic';
\$config['login_autocomplete'] = 2;
\$config['login_username_filter'] = 'email';
\$config['password_charset'] = 'UTF-8';
\$config['junk_mbox'] = 'Spam';
?>
/* ensure roudcube session id's aren't leaked to other parts of the server */
\$config['session_path'] = '/mail/';
/* prevent CSRF, requires php 7.3+ */
\$config['session_samesite'] = 'Strict';
\$config['quota_zero_as_unlimited'] = true;
EOF
mkdir -p ${STORAGE_ROOT}/userkeys/
chmod 700 ${STORAGE_ROOT}/userkeys/
chown www-data:www-data ${STORAGE_ROOT}/userkeys/
mkdir -p ${STORAGE_ROOT}/.enigma/
chmod 700 ${STORAGE_ROOT}/.enigma/
chown www-data:www-data ${STORAGE_ROOT}/.enigma/
# Configure Enigma
cat > ${RCM_PLUGIN_DIR}/enigma/config.inc.php <<EOF;
@ -173,7 +187,7 @@ cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
'name' => 'ownCloud',
'username' => '%u', // login username
'password' => '%p', // login password
'url' => 'https://${PRIMARY_HOSTNAME}/cloud/remote.php/carddav/addressbooks/%u/contacts',
'url' => 'https://${PRIMARY_HOSTNAME}/cloud/remote.php/dav/addressbooks/users/%u/contacts/',
'active' => true,
'readonly' => false,
'refresh_time' => '02:00:00',
@ -197,13 +211,12 @@ sudo -u www-data touch /var/log/roundcubemail/errors.log
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 \
"\$config['password_minimum_length']=8;" \
"\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
"\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" \
"\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \
"\$config['password_dovecotpw_method']='SHA512-CRYPT';" \
"\$config['password_dovecotpw_with_method']=true;"
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'] = '';"
# so PHP can use doveadm, for the password changing plugin
usermod -a -G dovecot www-data
@ -221,7 +234,7 @@ 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)
${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
php ${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

View file

@ -17,33 +17,31 @@ source /etc/mailinabox.conf # load global vars
echo "Installing Z-Push (Exchange/ActiveSync server)..."
apt_install \
php-soap php-imap libawl-php php-xsl
php-soap php-imap libawl-php php-xml
phpenmod -v php imap
# Copy Z-Push into place.
VERSION=2.5.2
TARGETHASH=2dc3dbd791b96b0ba2638df0d3d1e03c7e1cbab2
VERSION=2.6.2
TARGETHASH=f0e8091a8030e5b851f5ba1f9f0e1a05b8762d80
needs_update=0 #NODOC
if [ ! -f /usr/local/lib/z-push/version ]; then
needs_update=1 #NODOC
elif [[ $VERSION != `cat /usr/local/lib/z-push/version` ]]; then
elif [[ $VERSION != $(cat /usr/local/lib/z-push/version) ]]; then
# checks if the version
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
# Download
wget_verify "https://stash.z-hub.io/rest/api/latest/projects/ZP/repos/z-push/archive?at=refs%2Ftags%2F$VERSION&format=zip" $TARGETHASH /tmp/z-push.zip
wget_verify "https://github.com/Z-Hub/Z-Push/archive/refs/tags/$VERSION.zip" $TARGETHASH /tmp/z-push.zip
# Extract into place.
rm -rf /usr/local/lib/z-push /tmp/z-push
unzip -q /tmp/z-push.zip -d /tmp/z-push
mv /tmp/z-push/src /usr/local/lib/z-push
mv /tmp/z-push/*/src /usr/local/lib/z-push
rm -rf /tmp/z-push.zip /tmp/z-push
rm -f /usr/sbin/z-push-{admin,top}
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
echo $VERSION > /usr/local/lib/z-push/version
fi
@ -106,4 +104,4 @@ restart_service php$(php_version)-fpm
# Fix states after upgrade
hide_output z-push-admin -a fixstates
hide_output php /usr/local/lib/z-push/z-push-admin.php -a fixstates

View file

@ -232,7 +232,7 @@ if __name__ == "__main__":
run_test(managesieve_test, [], 20, 30, 4)
# Mail-in-a-Box control panel
run_test(http_test, ["/admin/me", 200], 20, 30, 1)
run_test(http_test, ["/admin/login", 200], 20, 30, 1)
# Munin via the Mail-in-a-Box control panel
run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)

View file

@ -30,7 +30,7 @@ def test(server, description):
(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)
@ -48,7 +48,7 @@ def test2(tests, server, description):
for qname, rtype, expected_answer in tests:
# do the query and format the result as a string
try:
response = dns.resolver.query(qname, rtype)
response = dns.resolver.resolve(qname, rtype)
except dns.resolver.NoNameservers:
# host did not have an answer for this query
print("Could not connect to %s for DNS query." % server)

View file

@ -41,15 +41,14 @@ 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(host, 587)
server = smtplib.SMTP_SSL(host)
#server.set_debuglevel(1)
server.starttls()
# Verify that the EHLO name matches the server's reverse DNS.
ipaddr = socket.gethostbyname(host) # IPv4 only!
reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
try:
reverse_dns = dns.resolver.query(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
except dns.resolver.NXDOMAIN:
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
reverse_dns = None

View file

@ -1,131 +0,0 @@
#!/usr/bin/python3
import sys, getpass, urllib.request, urllib.error, json, re
def mgmt(cmd, data=None, is_json=False):
# The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
try:
response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
if e.code == 401:
try:
print(e.read().decode("utf8"))
except:
pass
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr)
else:
print(e, file=sys.stderr)
sys.exit(1)
resp = response.read().decode('utf8')
if is_json: resp = json.loads(resp)
return resp
def read_password():
while True:
first = getpass.getpass('password: ')
if len(first) < 8:
print("Passwords must be at least eight characters.")
continue
if re.search(r'[\s]', first):
print("Passwords cannot contain spaces.")
continue
second = getpass.getpass(' (again): ')
if first != second:
print("Passwords not the same. Try again.")
continue
break
return first
def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler()
auth_handler.add_password(
realm='Mail-in-a-Box Management Server',
uri=mgmt_uri,
user=key,
passwd='')
opener = urllib.request.build_opener(auth_handler)
urllib.request.install_opener(opener)
if len(sys.argv) < 2:
print("Usage: ")
print(" tools/mail.py user (lists users)")
print(" tools/mail.py user add user@domain.com [password]")
print(" tools/mail.py user password user@domain.com [password]")
print(" tools/mail.py user remove user@domain.com")
print(" tools/mail.py user make-admin user@domain.com")
print(" tools/mail.py user remove-admin user@domain.com")
print(" tools/mail.py user admins (lists admins)")
print(" tools/mail.py alias (lists aliases)")
print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com")
print(" tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'")
print(" tools/mail.py alias remove incoming.name@domain.com")
print()
print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.")
print()
elif sys.argv[1] == "user" and len(sys.argv) == 2:
# Dump a list of users, one per line. Mark admins with an asterisk.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if user['status'] == 'inactive': continue
print(user['email'], end='')
if "admin" in user['privileges']:
print("*", end='')
print()
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
if len(sys.argv) < 5:
if len(sys.argv) < 4:
email = input("email: ")
else:
email = sys.argv[3]
pw = read_password()
else:
email, pw = sys.argv[3:5]
if sys.argv[2] == "add":
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
elif sys.argv[2] == "password":
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
if sys.argv[2] == "make-admin":
action = "add"
else:
action = "remove"
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
# Dump a list of admin users.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if "admin" in user['privileges']:
print(user['email'])
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
else:
print("Invalid command-line arguments.")
sys.exit(1)

View file

@ -8,16 +8,16 @@
source /etc/mailinabox.conf # load global vars
ADMIN=$(./mail.py user admins | head -n 1)
test -z "$1" || ADMIN=$1
ADMIN=$(management/cli.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
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
echo Press enter to continue.
read
sudo -u www-data php /usr/local/lib/owncloud/occ group:adduser admin $ADMIN && echo Done.
sudo -u www-data php /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo Done.

View file

@ -413,7 +413,7 @@ class BashScript(Grammar):
result = parser.parse_string(string)
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
% ("https://github.com/ddavness/power-mailinabox/tree/main/" + fn, fn)
mode = 0
for item in result.value():