1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2026-03-12 17:07:23 +01:00

Compare commits

...

127 Commits

Author SHA1 Message Date
Joshua Tauberer
289936db7a 0.13a (August 23, 2015)
Work-around for ownCloud 8.1.1 upgrade bug and tweaking munin's setup.

v0.13a (August 23, 2015)
------------------------

Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud bug that prevented upgrades. v0.13a works around that problem.

Mail:

* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability.
* Some MIME messages would hang Roundcube due to a missing package.
* The users permitted to send as an alias can now be different from where an alias forwards to.

DNS:

* The secondary nameservers option in the control panel now accepts more than one nameserver and a special xfr:IP format to specify zone-transfer-only IP addresses.
* A TLSA record is added for HTTPS for DNSSEC-aware clients that support it.

System:

* Backups can now be turned off, or stored in Amazon S3, through new control panel options.
* Munin was not working on machines confused about their hostname and had lots of errors related to PANGO, NTP peers and network interfaces that were not up.
* ownCloud updated to version 8.1.1 (with upgrade work-around), its memcached caching enabled.
* When upgrading, network checks like blocked port 25 are now skipped.
* Tweaks to the intrusion detection rules for IMAP.
* Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages.

Control panel:

* SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin.
* Improve the sort order of the domains in the status checks.
* Some links in the control panel were only working in Chrome.
2015-08-23 12:52:43 -04:00
Joshua Tauberer
6c71abbb09 suppress PANGO warning when running munin during setup 2015-08-23 12:42:39 -04:00
Joshua Tauberer
841181ed55 ownCloud 8.1.1 broke upgrades: do "occ upgrade" twice
Per https://github.com/owncloud/core/issues/18224 and https://discourse.mailinabox.email/t/v13-upgrade-issue-with-calendar/757/10, upgrades from anything to ownCloud 8.1.1 were broken. But the workaround is to run the upgrade step twice.
2015-08-23 12:37:25 -04:00
Joshua Tauberer
c3821e9165 munin: configure the plugins better
* let munin choose what it wants to monitor, it may need an update periodically
* deactivate monitoring of NTP peers (this makes no sense)
* deactivate monitoring of network interfaces that aren't up (also makes no sense)
* we get errors in munin-node.log if we don't create /var/lib/munin-node/plugin-state/
2015-08-23 16:04:37 +00:00
Joshua Tauberer
6b35d8402c pulling v0.13 2015-08-19 17:58:47 -04:00
Joshua Tauberer
3b6f7250ee v0.13
v0.13 (August 19, 2015)
-----------------------

Mail:

* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability.
* Some MIME messages would hang Roundcube due to a missing package.
* The users permitted to send as an alias can now be different from where an alias forwards to.

DNS:

* The secondary nameservers option in the control panel now accepts more than one nameserver and a special xfr:IP format to specify zone-transfer-only IP addresses.
* A TLSA record is added for HTTPS for DNSSEC-aware clients that support it.

System:

* Backups can now be turned off, or stored in Amazon S3, through new control panel options.
* Munin was not working on machines confused about their hostname.
* ownCloud updated to version 8.1.1, its memcached caching enabled.
* When upgrading, network checks like blocked port 25 are now skipped.
* Tweaks to the intrusion detection rules for IMAP.
* Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages.

Control panel:

* SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin.
* Improve the sort order of the domains in the status checks.
* Some links in the control panel were only working in Chrome.
2015-08-19 16:37:18 -04:00
Joshua Tauberer
c6bf061b26 changelog entries 2015-08-19 16:34:39 -04:00
Joshua Tauberer
a8074ae3e4 suppress some status output regarding new automatic aliases on first installation 2015-08-19 16:30:32 -04:00
Joshua Tauberer
cfc4e6b48b automatic administrator aliases are probably not bidirectional because the administrator@ address is an alias and not a user 2015-08-19 16:06:09 -04:00
Joshua Tauberer
73fbcd7fa3 silence all of the installing/already installed package messages on installation
Querying dpkg for each package is slow, and we have way too much output on installation because of it.
2015-08-19 15:58:35 -04:00
Joshua Tauberer
79317ed67e dont run ownCloud's upgrade before it's been installed to remove a noisy message 2015-08-19 15:43:34 -04:00
Joshua Tauberer
1b26632bb4 readme tweaks 2015-08-17 08:20:31 -04:00
Joshua Tauberer
5b415c6895 tweak security.md for new alias permitted_senders controls 2015-08-17 08:18:32 -04:00
Joshua Tauberer
078f3bff70 ownCloud set overwrite.cli.url setting 2015-08-16 23:47:51 +00:00
Joshua Tauberer
d02f800d1b ownCloud: enable memcached properly 2015-08-16 23:46:23 +00:00
Joshua Tauberer
83d3643e8f changelog entries 2015-08-15 15:58:27 +00:00
Joshua Tauberer
6d35ae56d3 Merge pull request #517 from fxaguessy/master
fix problem with certificate verification on OpenVZ servers
2015-08-15 11:55:30 -04:00
root
39270a8e35 fix problem with certificate verification on OpenVZ servers 2015-08-15 17:32:40 +02:00
Joshua Tauberer
8c08f957cd bidirectional alias controls: a new permitted_senders column in the aliases table allows setting who can send as an address independently of where the address forwards to
But the default permitted senders are the same as the addresses the alias forwards to.

Merge branch 'dhpiggott-bidirectional-alias-controls'
2015-08-14 23:09:22 +00:00
Joshua Tauberer
5924d0fe0d various cleanup related to the new permitted_senders column for aliases 2015-08-14 23:05:08 +00:00
Joshua Tauberer
c23a34d475 tweak README 2015-08-14 17:11:32 -04:00
Joshua Tauberer
1bd3a725ca use the right version of the ownCloud apps
Contacts and Calendar are now versioned separately from ownCloud core.

Contacts appears to be in a v0.4.x.x series. This is the hash of v0.4.0.0.

Per https://github.com/owncloud/calendar/issues/892, Calendar is in a 0.7.x series. This is the hash for 0.7.2.
2015-08-12 19:05:55 +00:00
Joshua Tauberer
848dea83ab additional error handling for backups with an invalid target 2015-08-12 11:19:59 +00:00
Joshua Tauberer
bded529535 changelog entries 2015-08-12 11:10:36 +00:00
Joshua Tauberer
ec665a97db ownCloud 8.1.1
Only core. The apps don't seem to have been updated.
2015-08-12 11:07:30 +00:00
Joshua Tauberer
fc536b6ff3 Merge pull request #512 from ponychicken/backup-fixes
S3 backups fail if no prefix is used
2015-08-11 07:59:07 -04:00
Leo Koppelkamm
f96bef43cc If no prefix is specified, set the path to '', otherwise boto won't list the files 2015-08-11 13:54:30 +02:00
Joshua Tauberer
f4e8ee0af9 html errors in the backup template, my bad 2015-08-09 20:34:08 +00:00
Joshua Tauberer
c2e5f5cb2b merge - duplicity configuration in the control panel and disabling backups
Merges branch 'ponychicken-backup'.
2015-08-09 20:16:50 +00:00
Joshua Tauberer
9ca116d545 add an option to disable backups 2015-08-09 20:15:43 +00:00
Joshua Tauberer
cdd3a64638 after-backup was run with the wrong environment 2015-08-09 20:08:33 +00:00
Joshua Tauberer
99e51f8a52 use boto to get actual file sizes of backup files when S3 is used 2015-08-09 20:08:33 +00:00
Joshua Tauberer
3b4b57c081 switching between backup options in the admin wasn't working at all
* going from s3 to file target wasn't working
* use 'local' in the config instead of a file: url, for the local target, so it is not path-specific
* break out the S3 fields since users can't be expected to know how to form a URL
* use boto to generate a list of S3 hosts
* use boto to validate that the user input for s3 is valid
* fix lots of html errors in the backup admin
2015-08-09 20:08:33 +00:00
Joshua Tauberer
c7f8ead496 clean up the new backup configuration panel 2015-08-09 20:08:30 +00:00
Joshua Tauberer
3f15879578 remove global variables in backup.py 2015-08-09 17:54:46 +00:00
Leo Koppelkamm
1cdd205eb7 Missed one max_age 2015-07-28 20:58:39 +02:00
Leo Koppelkamm
77099b3bce Reword backup min_time label 2015-07-28 00:42:00 +02:00
Leo Koppelkamm
0d8a4099c1 Add placeholder attribute; use input instead of textarea 2015-07-28 00:37:48 +02:00
Leo Koppelkamm
606cf6a941 Fix API typo 2015-07-28 00:34:26 +02:00
Leo Koppelkamm
ba9065cada Don't write collection_status output to file but parse it directly 2015-07-27 22:30:22 +02:00
Leo Koppelkamm
e693802091 Rename max_age to min_age
Also clarify a comment and remove an unneeded type check
2015-07-27 22:18:19 +02:00
Leo Koppelkamm
fa0dd684da Add archive-dir argument to collection-status 2015-07-27 22:13:28 +02:00
Leo Koppelkamm
43fb7fe635 Remove unused variable 2015-07-27 22:11:43 +02:00
Leo Koppelkamm
91e4ea6e2f Infer target_type from url 2015-07-27 22:09:58 +02:00
Leo Koppelkamm
1e3e34f15f Make backup API RESTful 2015-07-27 22:00:36 +02:00
Leo Koppelkamm
96fb0f78f7 Add comment regarding the use of pip instead of pip3 2015-07-27 21:56:08 +02:00
Leo Koppelkamm
2e6c410336 Make backups more configurable
Backup location and maximum age can now be configured in the admin panel.
For now only S3 is supported, but adding other duplicity supported backends should be straightforward.
2015-07-27 21:53:34 +02:00
Joshua Tauberer
0293e04311 fix control panel links, broken in Firefox (worked in Chrome)
see https://discourse.mailinabox.email/t/bug-present-for-ages/694/3
2015-07-25 14:12:45 +00:00
Joshua Tauberer
8c066764d9 munin-node gets confused about the machine's name in some cases
Some users report munin is broken because munin and munin-node disagree about the name of the machine. I think this occurs if hostname (used by munin-node) reports a different name than PRIMARY_HOSTNAME (which we put in the munin config).

Hard-code PRIMARY_HOSTNAME in munin-node.conf.

Fixes #474.
See https://discourse.mailinabox.email/t/404-not-found-on-admin-munin/623/24.
2015-07-22 21:03:55 +00:00
Joshua Tauberer
1900e512f2 improve the sort order of domains - siblings to the primary hostname were not sorted right 2015-07-21 11:25:11 +00:00
Joshua Tauberer
d0ccde7b48 changelog entries 2015-07-21 06:50:00 -04:00
Joshua Tauberer
1e261e347a missing dependency php-mail-mimedecode for roundcube, fixes #447 2015-07-21 10:25:10 +00:00
Joshua Tauberer
2cb4cdc645 dont run network checks during upgrades since this is a bad reason to block an upgrade from going through 2015-07-21 06:21:56 -04:00
David Piggott
123ac4fd33 s/email/address/ in aliases UI variable names
This makes the frontend consistent with the backend.
2015-07-20 12:51:57 +01:00
David Piggott
423bb8e317 Fix remove-alias button breakage 2015-07-20 12:51:57 +01:00
David Piggott
e6ff280984 Store and set alias receivers and senders separately for maximum control 2015-07-20 12:51:57 +01:00
David Piggott
3fdfad27cd Add support for bidirectional mail alias controls
This is an extension of #427. Building on that change it adds support in the
aliases table for flagging aliases as:
 1. Applicable to inbound and outbound mail.
 2. Applicable to inbound mail only.
 3. Applicable to outbound mail only.
 4. Disabled.

The aliases UI is also updated to allow administrators to set the direction of
each alias.

Using this extra information, the sqlite queries executed by Postfix are
updated so only the relevant alias types are checked.

The goal and result of this change is that outbound-only catch-all aliases can
now be defined (in fact catch-all aliases of any type can be defined).

This allow us to continue supporting relaying as described at
https://mailinabox.email/advanced-configuration.html#relay
without requiring that administrators either create regular aliases for each
outbound *relay* address, or that they create a catch-all alias and then face a
flood of spam.

I have tested the code as it is in this commit and fixed every issue I found,
so in that regard the change is complete. However I see room for improvement
in terms of updating terminology to make the UI etc. easier to understand.
I'll make those changes as subsequent commits so that this tested checkpoint is
not lost, but also so they can be rejected independently of the actual change
if not wanted.
2015-07-20 12:51:57 +01:00
Joshua Tauberer
d3bbc0ec95 bug in new secondary nameservers
forgot a 'continue' statement
see 216acb0eeb
fixes #497
2015-07-20 11:25:16 +00:00
Joshua Tauberer
e54608c282 fix occ upgrade to not bail when occ returns 'ownCloud is already latest version' exit code 3, see #496 2015-07-19 13:06:38 +00:00
Joshua Tauberer
9b9a40ddd7 Merge tag 'v0.12c'
v0.12c

remove live dependency on Sourceforge

everything was already on master
2015-07-19 08:34:16 -04:00
Joshua Tauberer
1b00184c89 v0.12c release to work-around Sourceforge outage 2015-07-19 08:30:03 -04:00
Joshua Tauberer
e11825392d use a temporary mirror for roundcube while Sourceforge is recovering from an outage https://twitter.com/sfnet_ops/status/622171668497076224 2015-07-19 08:25:04 -04:00
Joshua Tauberer
1a995d9e26 forgot to create the pyzor home_dir in 3f606feea3 2015-07-19 08:25:04 -04:00
Joshua Tauberer
53d4820d74 hard-code pyzor sevice URL because 'pyzor discover' is failing because Sourceforge is offline, fixes #496 2015-07-19 08:25:04 -04:00
Joshua Tauberer
40a5fa46d1 use a temporary mirror for roundcube while Sourceforge is recovering from an outage https://twitter.com/sfnet_ops/status/622171668497076224 2015-07-17 20:27:59 -04:00
Joshua Tauberer
05e33edb0d forgot to create the pyzor home_dir in 3f606feea3 2015-07-17 20:26:36 -04:00
Joshua Tauberer
76dba1a521 the ownCloud upgrade must be run after apps are (re-)enabled after an upgrade 2015-07-17 11:44:28 +00:00
Joshua Tauberer
f7298a45bd update to ownCloud 8.1.0 2015-07-17 11:44:28 +00:00
Joshua Tauberer
3f606feea3 hard-code pyzor sevice URL because 'pyzor discover' is failing because Sourceforge is offline, fixes #496 2015-07-17 11:44:28 +00:00
Joshua Tauberer
541d9252f6 allow PEM files to have non-Unix line endings 2015-07-17 11:44:28 +00:00
Joshua Tauberer
cbbbb117e0 Merge pull request #492 from PortableTech/tlsa
Add TLSA record for SSL connections.
2015-07-13 09:15:12 -04:00
PortableTech
415f95b792 Add TLSA record for HTTPS connections.
While not widely supported, there are some browser addons that can
validate DNSSEC and TLSA for additional out-of-band verification of
certificates when browsing the web.  Costs nothing to implement and
might improve security in some situations.
2015-07-13 09:12:13 -04:00
Joshua Tauberer
5f17abc856 Merge pull request #463 from PortableTech/master
outgoing_mail_header_filters use local hostname and ip
2015-07-11 17:21:55 -04:00
Joshua Tauberer
a91995f0a7 Merge pull request #486 from anoma/fail2ban-organise
Optimise FAIL2BAN jail.local
2015-07-11 17:20:34 -04:00
Joshua Tauberer
216acb0eeb merge #476 - multiple secondary NS servers and zone xfr-only servers
closes #476
2015-07-10 15:42:44 +00:00
Joshua Tauberer
5dd5fc4a1c clean up multiple secondary nameservers and zone xfr ip addresses 2015-07-10 15:42:33 +00:00
Brian Bustin
09133c8f59 Initial backend changes to make it possible to have one or more secondary name servers 2015-07-10 14:59:38 +00:00
Joshua Tauberer
d08a3095a9 tweak security.md 2015-07-09 13:30:25 -04:00
anoma
e591d9082f Ultra safe dovecot findtime and maxretry settings
Explicitly set the timings and counts for the dovecot jail rather than change the global [DEFAULT] and inherit it for this one jail. These settings are far too safe so a future PR should increase security here.
2015-07-06 13:44:53 +01:00
anoma
b6f26c0f1e Revert to defaults FAIL2BAN findtime and maxretry
Reverts the remaining FAIL2BAN settings to default: findtime 600 and maxretry 3. As jail settings override default settings this was hardly being used anyway so it is better to explicitly set it per jail as and when required.
2015-07-06 13:42:41 +01:00
Joshua Tauberer
34b7638342 v0.12b 2015-07-04 11:31:51 -04:00
Joshua Tauberer
acd91665b5 setting an alias to forward to two or more addresses was broken since aa33428311
fixes #482
2015-07-04 15:28:45 +00:00
Joshua Tauberer
b503ea1cf7 v0.12
--------------------

This is a minor update to v0.11, which was a major update. Please read v0.11's advisories.

* The administrator@ alias was incorrectly created starting with v0.11. If your first install was v0.11, check that the administrator@ alias forwards mail to you.
* Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked).
* SSL certificates could not be installed for the new automatic 'www.' redirect domains.
* PHP's default character encoding is changed from no default to UTF8. The effect of this change is unclear but should prevent possible future text conversion issues.
* User-installed SSL private keys in the BEGIN PRIVATE KEY format were not accepted.
* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.
* Some IDNA functionality was using IDNA 2003 rather than IDNA 2008.
2015-07-03 10:34:33 -04:00
Joshua Tauberer
091c2e45bf always attempt to upgrade pip packages during setup 2015-07-03 14:25:41 +00:00
Joshua Tauberer
0a78d1d2fa update changelog 2015-07-03 14:15:14 +00:00
Joshua Tauberer
ff4780d5fb better error handling of invalid PEM files 2015-07-03 14:00:59 +00:00
PortableTech
07beef3db2 outgoing_mail_header_filters use local hostname and ip
Modify outgoing_mail_header_filters and mail-postfix.sh
files to result in the primary hostname, and the public
ip of the server showing in the first mail header route
instead of unknown and 127.0.0.1.  This could help lower
the spam score of mail sent from your server to some
public mail services.
2015-07-02 16:04:56 -04:00
Joshua Tauberer
0924f8ca7a allow for PEM private keys in the 'BEGIN PRIVATE KEY' format too
see https://discourse.mailinabox.email/t/another-upgrade-failure/630/5
2015-07-02 15:37:26 -04:00
Joshua Tauberer
6302aa6c12 Merge pull request #479 from hnk/patch-1
update docstring to clarify usage of -c option
2015-07-02 13:44:03 -04:00
Hnk Reno
da4d9ff607 update docstring to clarify usage of -c option 2015-07-02 19:27:05 +02:00
Joshua Tauberer
e57e08088a the control panel would not allow installing a certificate for a www redirect domain, fixes #475 2015-07-02 10:53:54 +00:00
Joshua Tauberer
5e43c394d5 Merge pull request #477 from anoma/master
cleanup and harden of fail2ban
2015-07-02 06:22:57 -04:00
anoma
b2eaaeca4b Revert to default 6 ssh/ddos login attempts
No legitimate admin will require 20 login attempts. The default 6 is a sane middle ground especially since in 10 minutes they can try again  or immediately from another IP anyway.
2015-07-02 10:23:48 +01:00
anoma
e2d9a523c3 Cleanup blank lines, comments and whitespace to make it easier to follow 2015-07-02 10:19:37 +01:00
anoma
11df1e4680 Unnecessary config item, inherited from default jail.conf 2015-07-02 10:10:50 +01:00
anoma
53d5542402 Revert to default 600 second ban time
A 60 second/1 minute ban time is not long enough to counter brute force attacks which is the main purpose of fail2ban for mail in a box. The default bantime of 10 minutes is still sane and I think we have proven fail2ban is reliable enough not to cause problems in general. It is not worth sacrificing security for the rare case where an admin locks themselves out for 10 minutes.
2015-07-02 10:08:50 +01:00
anoma
bfda3f40b9 Unnecessary config item, inherited from default jail.conf 2015-07-02 09:55:59 +01:00
Joshua Tauberer
c0ddceb2bd Merge pull request #471 from hnk/patch-1
Set PHPs default charset to UTF-8, since we use it. Closes #367.
2015-06-30 12:00:27 -04:00
Joshua Tauberer
42a506231b don't automatically create the administrator@ alias (e.g. on first user creation) because we dont know what it should be an alias to (leave this to be resolved manually), fixes #470
Was broken by 462a79cf47.
2015-06-30 09:16:22 -04:00
Joshua Tauberer
e3252f53da idna domains in certificate subject alternative names were not handled correctly after switching to cryptography package 2015-06-30 13:09:18 +00:00
Joshua Tauberer
aa33428311 some IDNA functionality was still using Python's built-in IDNA 2003 encoder rather than the idna package's IDNA 2008 encoder 2015-06-30 13:09:18 +00:00
Hnk Reno
ca5d228be6 Set PHPs default charset to UTF-8, since we use it. Closes #367. 2015-06-30 11:31:43 +02:00
Joshua Tauberer
f89a98c78a v0.11b to fix missing package for apt-add-repository 2015-06-29 21:52:47 -04:00
Joshua Tauberer
a3087d8815 must install software-properties-common to have add-apt-repository 2015-06-29 21:47:54 -04:00
Joshua Tauberer
23d2df7a93 v0.11
---------------------

Advisories:
* Users can no longer spoof arbitrary email addresses in outbound mail. When sending mail, the email address configured in your mail client must match the SMTP login username being used, or the email address must be an alias with the SMTP login username listed as one of the alias's targets.
* This update replaces your DKIM signing key with a stronger key. Because of DNS caching/propagation, mail sent within a few hours after this update could be marked as spam by recipients. If you use External DNS, you will need to update your DNS records.
* The box will now install software from a new Mail-in-a-Box PPA on Launchpad.net, where we are distributing two of our own packages: a patched postgrey and dovecot-lucene.

Mail:
* Greylisting will now let some reputable senders pass through immediately.
* Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin.
* Users can no longer spoof arbitrary email addresses in outbound mail (see above).
* Fix for deleting admin@ and postmaster@ addresses.
* Roundcube is updated to version 1.1.2, plugins updated.
* Exchange/ActiveSync autoconfiguration was not working on all devices (e.g. iPhone) because of a case-sensitive URL.
* The DKIM signing key has been increased to 2048 bits, from 1024, replacing the existing key.

Web:
* 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate).
* OCSP no longer uses Google Public DNS.
* The installed PHP version is no longer exposed through HTTP response headers, for better security.

DNS:
* Default IPv6 AAAA records were missing since version 0.09.

Control panel:
* Resetting a user's password now forces them to log in again everywhere.
* Status checks were not working if an ssh server was not installed.
* SSL certificate validation now uses the Python cryptography module in some places where openssl was used.
* There is a new tab to show the installed version of Mail-in-a-Box and to fetch the latest released version.

System:
* The munin system monitoring tool is now installed and accessible at /admin/munin.
* ownCloud updated to version 8.0.4. The ownCloud installation step now is reslient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine.
* The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available.
2015-06-29 20:58:35 -04:00
Joshua Tauberer
1cd97d46a2 press hit 2015-06-27 10:10:33 -04:00
Joshua Tauberer
53f84a8092 set ssl_stapling_verify back to on, reverts part of 47de93961e
The sslmate guidance changed. See #458.
2015-06-27 07:14:16 -04:00
Joshua Tauberer
6441de63ba typo in security.md 2015-06-26 11:38:40 -04:00
Joshua Tauberer
b2553aea33 note the new sender email address spoofing restriction at the top of the changelog 2015-06-26 11:36:10 -04:00
Joshua Tauberer
5ef1cfbdc7 forgot new version.html template file 2015-06-25 17:43:50 +00:00
Joshua Tauberer
7527b4dc27 show the Mail-in-a-Box version in the control panel and a button to ping the MiaB website for the latest version
fixes #441
2015-06-25 13:43:11 +00:00
Joshua Tauberer
1367816b04 merge #451 - Increase DKIM key length to 2048 2015-06-25 13:07:29 +00:00
Joshua Tauberer
299a2315c1 dkim 2048 bits - migration and zone file generation changes
* Add a migration to delete any existing DKIM key so that existing machines get a fresh 2048-bit key. (Sadly we don't support key rotation so the change is immediate.)
* Because the DNS record for a 2048-bit key is so much longer, the way we read OpenDKIM's DNS record text file had to be modified to combine an arbitrary number of TXT record quoted ("...") strings.
* When writing out the TXT record value, the string must be split into quoted ("...") strings with a maximum length of 255 bytes each, per the DNS spec.
* Added a changelog entry.
2015-06-25 13:06:29 +00:00
Joshua Tauberer
9a6aea6940 changelog entry for z-push autodiscover case insensitivity 2015-06-25 12:32:07 +00:00
Joshua Tauberer
98cd04cccf Merge pull request #452 from m4rcs/master
Z-Push autoconfiguration fails due to URL case sensitivity
2015-06-25 08:28:44 -04:00
Marc Schiller
0cc20cbb97 Fixed a bug where autoconfiguration for Z-Push fails due to case of URL. 2015-06-25 11:56:33 +02:00
PortableTech
ef6a17d4a6 Increase DKIM key length to 2048
Currently MiaB creates 1024 bit keys which is seen as a minimum standard
by several providers such as Google who already uses a 2048 bit key.
Increasing the keysize beyond 2048 is an issue as it often goes beyond
supported DNS record sizes.
2015-06-24 18:49:19 -04:00
Joshua Tauberer
17a149947a other CHANGELOG updates 2015-06-24 18:16:25 -04:00
Joshua Tauberer
a2c50ae967 note the new SMTP mail from restriction in the changelog and security guide 2015-06-24 18:12:41 -04:00
Joshua Tauberer
13958ba4df Merge pull request #427 from pichak/add-sender-login-mismatch
Reject outgoing mail if MAIL FROM (envelope sender) does not match login name or is not an alias that directs mail (directly) to login name.
2015-06-24 18:03:03 -04:00
Joshua Tauberer
8eb71483f3 Merge pull request #450 from agriffaut/patch-1
ownCloud breaks if download fails (Issue #449)
2015-06-24 08:11:30 -04:00
aLeX
d8e30883fa Issue #449
If the downloaded file doesn't pass hash verification, the script exits and leaves a broken system
Just make hash verification before moving owncloud directory
2015-06-24 14:06:01 +02:00
Joshua Tauberer
47acbbf332 bump to latest version of my email_validator library 2015-06-23 16:43:35 -04:00
Joshua Tauberer
dece359c90 validate certificates using the cryptography python package as much as possible, shelling out to openssl just once instead of four times per certificate
* Use `cryptography` instead of parsing openssl's output.
* When checking if we can reuse the primary domain certificate or a www-parent-domain certificate for a domain, avoid shelling out to openssl entirely.
2015-06-21 14:53:37 +00:00
Joshua Tauberer
6a9eb4e367 improve inline documentation for the virtual-alias-maps query 2015-06-21 08:22:33 -04:00
Morteza Milani
fc03ce9b2f Fix login map. Now includes both emails and aliases 2015-06-20 03:27:18 -07:00
Morteza Milani
cf904a05cc Reject outgoing mail if FROM does not match Login 2015-06-01 21:26:01 -07:00
43 changed files with 1285 additions and 484 deletions

View File

@@ -1,18 +1,86 @@
CHANGELOG
=========
In Development
--------------
v0.13a (August 23, 2015)
------------------------
Note: v0.13 (no 'a', August 19, 2015) was pulled immediately due to an ownCloud bug that prevented upgrades. v0.13a works around that problem.
Mail:
* Outbound mail headers (the Recieved: header) are tweaked to possibly improve deliverability.
* Some MIME messages would hang Roundcube due to a missing package.
* The users permitted to send as an alias can now be different from where an alias forwards to.
DNS:
* The secondary nameservers option in the control panel now accepts more than one nameserver and a special xfr:IP format to specify zone-transfer-only IP addresses.
* A TLSA record is added for HTTPS for DNSSEC-aware clients that support it.
System:
* Backups can now be turned off, or stored in Amazon S3, through new control panel options.
* Munin was not working on machines confused about their hostname and had lots of errors related to PANGO, NTP peers and network interfaces that were not up.
* ownCloud updated to version 8.1.1 (with upgrade work-around), its memcached caching enabled.
* When upgrading, network checks like blocked port 25 are now skipped.
* Tweaks to the intrusion detection rules for IMAP.
* Mail-in-a-Box's setup is a lot quieter, hiding lots of irrelevant messages.
Control panel:
* SSL certificate checks were failing on OVH/OpenVZ servers due to missing /dev/stdin.
* Improve the sort order of the domains in the status checks.
* Some links in the control panel were only working in Chrome.
v0.12c (July 19, 2015)
----------------------
v0.12c was posted to work around the current Sourceforge.net outage: pyzor's remote server is now hard-coded rather than accessing a file hosted on Sourceforge, and roundcube is now downloaded from a Mail-in-a-Box mirror rather than from Sourceforge.
v0.12b (July 4, 2015)
---------------------
This version corrects a minor regression in v0.12 related to creating aliases targetting multiple addresses.
v0.12 (July 3, 2015)
--------------------
This is a minor update to v0.11, which was a major update. Please read v0.11's advisories.
* The administrator@ alias was incorrectly created starting with v0.11. If your first install was v0.11, check that the administrator@ alias forwards mail to you.
* Intrusion detection rules (fail2ban) are relaxed (i.e. less is blocked).
* SSL certificates could not be installed for the new automatic 'www.' redirect domains.
* PHP's default character encoding is changed from no default to UTF8. The effect of this change is unclear but should prevent possible future text conversion issues.
* User-installed SSL private keys in the BEGIN PRIVATE KEY format were not accepted.
* SSL certificates with SAN domains with IDNA encoding were broken in v0.11.
* Some IDNA functionality was using IDNA 2003 rather than IDNA 2008.
v0.11b (June 29, 2015)
----------------------
v0.11b was posted shortly after the initial posting of v0.11 to correct a missing dependency for the new PPA.
v0.11 (June 29, 2015)
---------------------
Advisories:
* Users can no longer spoof arbitrary email addresses in outbound mail. When sending mail, the email address configured in your mail client must match the SMTP login username being used, or the email address must be an alias with the SMTP login username listed as one of the alias's targets.
* This update replaces your DKIM signing key with a stronger key. Because of DNS caching/propagation, mail sent within a few hours after this update could be marked as spam by recipients. If you use External DNS, you will need to update your DNS records.
* The box will now install software from a new Mail-in-a-Box PPA on Launchpad.net, where we are distributing two of our own packages: a patched postgrey and dovecot-lucene.
Mail:
* Greylisting will now let some reputable senders pass through immediately.
* Searching mail (via IMAP) will now be much faster using the dovecot lucene full text search plugin.
* Users can no longer spoof arbitrary email addresses in outbound mail (see above).
* Fix for deleting admin@ and postmaster@ addresses.
* Roundcube is updated to version 1.1.2, plugins updated.
* Exchange/ActiveSync autoconfiguration was not working on all devices (e.g. iPhone) because of a case-sensitive URL.
* The DKIM signing key has been increased to 2048 bits, from 1024, replacing the existing key.
Web:
* 'www' subdomains now automatically redirect to their parent domain (but you'll need to install an SSL certificate).
* OCSP no longer uses Google Public DNS.
* The installed PHP version is no longer exposed through HTTP response headers, for better security.
DNS:
* Default IPv6 AAAA records were missing since version 0.09.
@@ -20,10 +88,13 @@ DNS:
Control panel:
* Resetting a user's password now forces them to log in again everywhere.
* Status checks were not working if an ssh server was not installed.
* SSL certificate validation now uses the Python cryptography module in some places where openssl was used.
* There is a new tab to show the installed version of Mail-in-a-Box and to fetch the latest released version.
System:
* The munin system monitoring tool is now installed and accessible at /admin/munin.
* ownCloud updated to version 8.0.4.
* ownCloud updated to version 8.0.4. The ownCloud installation step now is reslient to download problems. The ownCloud configuration file is now stored in STORAGE_ROOT to fix loss of data when moving STORAGE_ROOT to a new machine.
* The setup scripts now run `apt-get update` prior to installing anything to ensure the apt database is in sync with the packages actually available.
v0.10 (June 1, 2015)

View File

@@ -24,7 +24,7 @@ The Box
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
It is a one-click email appliance (see the [setup guide](https://mailinabox.email/guide.html)). There are no user-configurable setup options. It "just works".
It is a one-click email appliance. There are no user-configurable setup options. It "just works".
The components installed are:
@@ -32,39 +32,53 @@ The components installed are:
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
* DNS ([nsd4](http://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), and [SSHFP](https://tools.ietf.org/html/rfc4255) records automatically set
* Firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/))
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), system monitoring ([munin](http://munin-monitoring.org/))
It also includes:
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and detailed system monitoring.
* Our own builds of postgrey and dovecot-lucene distributed via the [Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa) on Launchpad.
* Our own builds of postgrey (adding better whitelisting) and dovecot-lucene (faster search for mail) distributed via the [Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa) on Launchpad.
For more information on how Mail-in-a-Box handles your privacy, see the [security details page](security.md).
The Security
Installation
------------
See the [security guide](security.md) for more information about the box's security configuration (TLS, password storage, etc).
See the [setup guide](https://mailinabox.email/guide.html) for detailed, user-friendly instructions.
I sign the release tags on git. To verify that a tag is signed by me, you can perform the following steps:
For experts, start with a completely fresh (really, I mean it) Ubuntu 14.04 LTS 64-bit machine. On the machine...
# Download my PGP key.
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
Clone this repository:
# Clone this repository.
$ git clone https://github.com/mail-in-a-box/mailinabox
$ cd mailinabox
# Verify the tag.
$ git verify-tag v0.10
_Optional:_ Download my PGP key and then verify that the sources were signed
by me. You'll get a lot of warnings, but the fingerprint should match the
fingerprint in the key details at [https://keybase.io/joshdata](https://keybase.io/joshdata)
and on my [personal homepage](https://razor.occams.info/). (Of course, if this repository has been compromised you can't trust these instructions anyway.)
$ 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.13
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
The key ID and fingerprint above should match my [Keybase.io key](https://keybase.io/joshdata) and the fingerprint I publish on [my homepage](https://razor.occams.info/).
Checkout the tag corresponding to the most recent release:
$ git checkout v0.13
Begin the installation.
$ sudo setup/start.sh
For help, DO NOT contact me 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 me and other Mail-in-a-Box users may be able to help you.
The Acknowledgements
--------------------
@@ -80,3 +94,4 @@ The History
* 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, and [May](https://news.ycombinator.com/item?id=9624267) 2015.
* FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.

View File

@@ -1,34 +1,15 @@
# Fail2Ban configuration file.
# For Mail-in-a-Box
[DEFAULT]
# Fail2Ban configuration file for Mail-in-a-Box
# bantime in seconds
bantime = 60
# This should ban dumb brute-force attacks, not oblivious users.
findtime = 30
maxretry = 20
#
# JAILS
#
[ssh]
enabled = true
logpath = /var/log/auth.log
maxretry = 20
[ssh-ddos]
enabled = true
maxretry = 20
[sasl]
enabled = true
[dovecot]
enabled = true
filter = dovecotimap
findtime = 30
maxretry = 20

View File

@@ -55,7 +55,7 @@
# file upload limit to match the corresponding Postfix limit.
client_max_body_size 128M;
}
location /autodiscover/autodiscover.xml {
location ~* ^/autodiscover/autodiscover.xml$ {
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";

View File

@@ -69,6 +69,6 @@ ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
# 8.8.8.8 and 8.8.4.4 below are Google's public IPv4 DNS servers.
# nginx will use them to talk to the CA.
ssl_stapling on;
ssl_stapling_verify off;
ssl_stapling_verify on;
resolver 127.0.0.1 valid=86400;
resolver_timeout 10;

View File

@@ -1,7 +1,7 @@
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is
# where the user's home IP address would be.
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (unknown [127.0.0.1])$1
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1
# Remove other typically private information.
/^\s*User-Agent:/ IGNORE

View File

@@ -10,20 +10,27 @@
import os, os.path, shutil, glob, re, datetime
import dateutil.parser, dateutil.relativedelta, dateutil.tz
import rtyaml
from utils import exclusive_process, load_environment, shell, wait_for_service
# Destroy backups when the most recent increment in the chain
# that depends on it is this many days old.
keep_backups_for_days = 3
def backup_status(env):
# What is the current status of backups?
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
# get a list of all of the backups taken and sum up file sizes to
# see how large the storage is.
# Root folder
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
# What is the current status of backups?
# Query duplicity to get a list of all backups.
# Use the number of volumes to estimate the size.
config = get_backup_config(env)
now = datetime.datetime.now(dateutil.tz.tzlocal())
# Are backups dissbled?
if config["target"] == "off":
return { }
backups = { }
backup_cache_dir = os.path.join(backup_root, 'cache')
def reldate(date, ref, clip):
if ref < date: return clip
rd = dateutil.relativedelta.relativedelta(ref, date)
@@ -34,27 +41,43 @@ def backup_status(env):
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
backups = { }
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_dir = os.path.join(backup_root, 'encrypted')
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
for fn in os.listdir(backup_dir):
# Get duplicity collection status and parse for a list of backups.
def parse_line(line):
keys = line.strip().split()
date = dateutil.parser.parse(keys[1])
return {
"date": keys[1],
"date_str": date.strftime("%x %X"),
"date_delta": reldate(date, now, "the future?"),
"full": keys[0] == "full",
"size": 0, # collection-status doesn't give us the size
"volumes": keys[2], # number of archive volumes for this backup (not really helpful)
}
code, collection_status = shell('check_output', [
"/usr/bin/duplicity",
"collection-status",
"--archive-dir", backup_cache_dir,
"--gpg-options", "--cipher-algo=AES256",
"--log-fd", "1",
config["target"],
],
get_env(env),
trap=True)
if code != 0:
# Command failed. This is likely due to an improperly configured remote
# destination for the backups.
return { }
for line in collection_status.split('\n'):
if line.startswith(" full") or line.startswith(" inc"):
backup = parse_line(line)
backups[backup["date"]] = backup
# Look at the target to get the sizes of each of the backups. There is more than one file per backup.
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: raise ValueError(fn)
if not m: continue # not a part of a current backup chain
key = m.group("date")
if key not in backups:
date = dateutil.parser.parse(m.group("date"))
backups[key] = {
"date": m.group("date"),
"date_str": date.strftime("%x %X"),
"date_delta": reldate(date, now, "the future?"),
"full": m.group("incbase") is None,
"previous": m.group("incbase"),
"size": 0,
}
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
backups[key]["size"] += size
# Ensure the rows are sorted reverse chronologically.
# This is relied on by should_force_full() and the next step.
@@ -79,11 +102,11 @@ def backup_status(env):
# when the threshold is met.
deleted_in = None
if incremental_count > 0 and first_full_size is not None:
deleted_in = "approx. %d days" % round(keep_backups_for_days + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
deleted_in = "approx. %d days" % round(config["min_age_in_days"] + (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count) + .5)
# When will a backup be deleted?
saw_full = False
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
days_ago = now - datetime.timedelta(days=config["min_age_in_days"])
for bak in backups:
if deleted_in:
# Subsequent backups are deleted when the most recent increment
@@ -99,8 +122,6 @@ def backup_status(env):
bak["deleted_in"] = deleted_in
return {
"directory": backup_dir,
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
"tz": now.tzname(),
"backups": backups,
}
@@ -124,15 +145,48 @@ def should_force_full(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.
# gpg will only take the first line of text, so sanity check that
# that line is long enough to be a reasonable passphrase. It
# only needs to be 43 base64-characters to match AES256's key
# length of 32 bytes.
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
passphrase = f.readline().strip()
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
return passphrase
def get_env(env):
config = get_backup_config(env)
env = { "PASSPHRASE" : get_passphrase(env) }
if get_target_type(config) == 's3':
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
return env
def get_target_type(config):
protocol = config["target"].split(":")[0]
return protocol
def perform_backup(full_backup):
env = load_environment()
exclusive_process("backup")
config = get_backup_config(env)
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
backup_cache_dir = os.path.join(backup_root, 'cache')
backup_dir = os.path.join(backup_root, 'encrypted')
# Are backups dissbled?
if config["target"] == "off":
return
# In an older version of this script, duplicity was called
# such that it did not encrypt the backups it created (in
# backup/duplicity), and instead openssl was called separately
@@ -169,17 +223,6 @@ def perform_backup(full_backup):
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
# Get the encryption passphrase. secret_key.txt is 2048 random
# bits base64-encoded and with line breaks every 65 characters.
# gpg will only take the first line of text, so sanity check that
# that line is long enough to be a reasonable passphrase. It
# only needs to be 43 base64-characters to match AES256's key
# length of 32 bytes.
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!")
env_with_passphrase = { "PASSPHRASE" : passphrase }
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
# --allow-source-mismatch is needed in case the box's hostname is changed
# after the first backup. See #396.
@@ -192,10 +235,10 @@ def perform_backup(full_backup):
"--volsize", "250",
"--gpg-options", "--cipher-algo=AES256",
env["STORAGE_ROOT"],
"file://" + backup_dir,
"--allow-source-mismatch"
config["target"],
"--allow-source-mismatch"
],
env_with_passphrase)
get_env(env))
finally:
# Start services again.
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
@@ -210,12 +253,12 @@ def perform_backup(full_backup):
shell('check_call', [
"/usr/bin/duplicity",
"remove-older-than",
"%dD" % keep_backups_for_days,
"%dD" % config["min_age_in_days"],
"--archive-dir", backup_cache_dir,
"--force",
"file://" + backup_dir
config["target"]
],
env_with_passphrase)
get_env(env))
# From duplicity's manual:
# "This should only be necessary after a duplicity session fails or is
@@ -227,13 +270,14 @@ def perform_backup(full_backup):
"cleanup",
"--archive-dir", backup_cache_dir,
"--force",
"file://" + backup_dir
config["target"]
],
env_with_passphrase)
get_env(env))
# Change ownership of backups to the user-data user, so that the after-bcakup
# script can access them.
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
if get_target_type(config) == 'file':
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
@@ -241,7 +285,7 @@ def perform_backup(full_backup):
post_script = os.path.join(backup_root, 'after-backup')
if os.path.exists(post_script):
shell('check_call',
['su', env['STORAGE_USER'], '-c', post_script],
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
env=env)
# Our nightly cron job executes system status checks immediately after this
@@ -254,9 +298,9 @@ def perform_backup(full_backup):
def run_duplicity_verification():
env = load_environment()
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
config = get_backup_config(env)
backup_cache_dir = os.path.join(backup_root, 'cache')
backup_dir = os.path.join(backup_root, 'encrypted')
env_with_passphrase = { "PASSPHRASE" : open(os.path.join(backup_root, 'secret_key.txt')).read() }
shell('check_call', [
"/usr/bin/duplicity",
"--verbosity", "info",
@@ -264,9 +308,118 @@ def run_duplicity_verification():
"--compare-data",
"--archive-dir", backup_cache_dir,
"--exclude", backup_root,
"file://" + backup_dir,
config["target"],
env["STORAGE_ROOT"],
], env_with_passphrase)
], get_env(env))
def list_target_files(config):
import urllib.parse
try:
p = urllib.parse.urlparse(config["target"])
except ValueError:
return "invalid target"
if p.scheme == "file":
return [(fn, os.path.getsize(os.path.join(p.path, fn))) for fn in os.listdir(p.path)]
elif p.scheme == "s3":
# match to a Region
import boto.s3
from boto.exception import BotoServerError
for region in boto.s3.regions():
if region.endpoint == p.hostname:
break
else:
raise ValueError("Invalid S3 region/host.")
bucket = p.path[1:].split('/')[0]
path = '/'.join(p.path[1:].split('/')[1:]) + '/'
# If no prefix is specified, set the path to '', otherwise boto won't list the files
if path == '/':
path = ''
if bucket == "":
raise ValueError("Enter an S3 bucket name.")
# connect to the region & bucket
try:
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
bucket = conn.get_bucket(bucket)
except BotoServerError as e:
if e.status == 403:
raise ValueError("Invalid S3 access key or secret access key.")
elif e.status == 404:
raise ValueError("Invalid S3 bucket name.")
elif e.status == 301:
raise ValueError("Incorrect region for this bucket.")
raise ValueError(e.reason)
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
else:
raise ValueError(config["target"])
def backup_set_custom(env, target, target_user, target_pass, 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)
config["target"] = target
config["target_user"] = target_user
config["target_pass"] = target_pass
config["min_age_in_days"] = min_age
# Validate.
try:
if config["target"] not in ("off", "local"):
# these aren't supported by the following function, which expects a full url in the target key,
# which is what is there except when loading the config prior to saving
list_target_files(config)
except ValueError as e:
return str(e)
write_backup_config(env, config)
return "Updated backup config"
def get_backup_config(env, for_save=False):
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
# Defaults.
config = {
"min_age_in_days": 3,
"target": "local",
}
# 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
config.update(custom_config)
except:
pass
# When updating config.yaml, don't do any further processing on what we find.
if for_save:
return config
# helper fields for the admin
config["file_target_directory"] = os.path.join(backup_root, 'encrypted')
config["enc_pw_file"] = os.path.join(backup_root, 'secret_key.txt')
if config["target"] == "local":
# Expand to the full URL.
config["target"] = "file://" + config["file_target_directory"]
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
@@ -274,6 +427,7 @@ if __name__ == "__main__":
# Run duplicity's verification command to check a) the backup files
# are readable, and b) report if they are up to date.
run_duplicity_verification()
else:
# Perform a backup. Add --full to force a full backup rather than
# possibly performing an incremental backup.

View File

@@ -45,7 +45,7 @@ def authorized_personnel_only(viewfunc):
# Authorized to access an API view?
if "admin" in privs:
# Call view func.
# Call view func.
return viewfunc(*args, **kwargs)
elif not error:
error = "You are not an administrator."
@@ -90,13 +90,19 @@ def json_response(data):
def index():
# Render the control panel. This route does not require user authentication
# so it must be safe!
no_users_exist = (len(get_mail_users(env)) == 0)
no_admins_exist = (len(get_admins(env)) == 0)
import boto.s3
backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]
return render_template('index.html',
hostname=env['PRIMARY_HOSTNAME'],
storage_root=env['STORAGE_ROOT'],
no_users_exist=no_users_exist,
no_admins_exist=no_admins_exist,
backup_s3_hosts=backup_s3_hosts,
)
@app.route('/me')
@@ -179,14 +185,15 @@ def mail_aliases():
if request.args.get("format", "") == "json":
return json_response(get_mail_aliases_ex(env))
else:
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders in get_mail_aliases(env))
@app.route('/mail/aliases/add', methods=['POST'])
@authorized_personnel_only
def mail_aliases_add():
return add_mail_alias(
request.form.get('source', ''),
request.form.get('destination', ''),
request.form.get('address', ''),
request.form.get('forwards_to', ''),
request.form.get('permitted_senders', ''),
env,
update_if_exists=(request.form.get('update_if_exists', '') == '1')
)
@@ -194,7 +201,7 @@ def mail_aliases_add():
@app.route('/mail/aliases/remove', methods=['POST'])
@authorized_personnel_only
def mail_aliases_remove():
return remove_mail_alias(request.form.get('source', ''), env)
return remove_mail_alias(request.form.get('address', ''), env)
@app.route('/mail/domains')
@authorized_personnel_only
@@ -222,14 +229,14 @@ def dns_update():
@authorized_personnel_only
def dns_get_secondary_nameserver():
from dns_update import get_custom_dns_config, get_secondary_dns
return json_response({ "hostname": get_secondary_dns(get_custom_dns_config(env)) })
return json_response({ "hostnames": get_secondary_dns(get_custom_dns_config(env), mode=None) })
@app.route('/dns/secondary-nameserver', methods=['POST'])
@authorized_personnel_only
def dns_set_secondary_nameserver():
from dns_update import set_secondary_dns
try:
return set_secondary_dns(request.form.get('hostname'), env)
return set_secondary_dns([ns.strip() for ns in re.split(r"[, ]+", request.form.get('hostnames') or "") if ns.strip() != ""], env)
except ValueError as e:
return (str(e), 400)
@@ -283,7 +290,7 @@ def dns_set_record(qname, rtype="A"):
# make this action set (replace all records for this
# qname-rtype pair) rather than add (add a new record).
action = "set"
elif request.method == "DELETE":
if value == '':
# Delete all records for this qname-type pair.
@@ -340,6 +347,24 @@ def web_update():
# System
@app.route('/system/version', methods=["GET"])
@authorized_personnel_only
def system_version():
from status_checks import what_version_is_this
try:
return what_version_is_this(env)
except Exception as e:
return (str(e), 500)
@app.route('/system/latest-upstream-version', methods=["POST"])
@authorized_personnel_only
def system_latest_upstream_version():
from status_checks import get_latest_miab_version
try:
return get_latest_miab_version()
except Exception as e:
return (str(e), 500)
@app.route('/system/status', methods=["POST"])
@authorized_personnel_only
def system_status():
@@ -384,6 +409,23 @@ def backup_status():
from backup import backup_status
return json_response(backup_status(env))
@app.route('/system/backup/config', methods=["GET"])
@authorized_personnel_only
def backup_get_custom():
from backup import get_backup_config
return json_response(get_backup_config(env))
@app.route('/system/backup/config', methods=["POST"])
@authorized_personnel_only
def backup_set_custom():
from backup import backup_set_custom
return json_response(backup_set_custom(env,
request.form.get('target', ''),
request.form.get('target_user', ''),
request.form.get('target_pass', ''),
request.form.get('min_age', '')
))
# MUNIN
@app.route('/munin/')
@@ -414,4 +456,3 @@ if __name__ == '__main__':
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
app.run(port=10222)

View File

@@ -144,8 +144,11 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
records.append((None, "NS", secondary_ns+'.', False))
# User may provide one or more additional nameservers
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
or ["ns2." + env["PRIMARY_HOSTNAME"]]
for secondary_ns in secondary_ns_list:
records.append((None, "NS", secondary_ns+'.', False))
# In PRIMARY_HOSTNAME...
@@ -167,6 +170,9 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
# Add a DANE TLSA record for SMTP.
records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used."))
# Add a DANE TLSA record for HTTPS, which some browser extensions might make use of.
records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it."))
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
for value in build_sshfp_records():
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh."))
@@ -250,8 +256,8 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
# Skip if the user has set a DKIM record already.
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
with open(opendkim_record_file) as orf:
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
val = m.group(2) + m.group(3)
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
@@ -373,9 +379,16 @@ $TTL 1800 ; default time to live
zone += subdomain
zone += "\tIN\t" + querytype + "\t"
if querytype == "TXT":
value = value.replace('\\', '\\\\') # escape backslashes
value = value.replace('"', '\\"') # escape quotes
value = '"' + value + '"' # wrap in quotes
# Divide into 255-byte max substrings.
v2 = ""
while len(value) > 0:
s = value[0:255]
value = value[255:]
s = s.replace('\\', '\\\\') # escape backslashes
s = s.replace('"', '\\"') # escape quotes
s = '"' + s + '"' # wrap in quotes
v2 += s + " "
value = v2
zone += value + "\n"
# DNSSEC requires re-signing a zone periodically. That requires
@@ -455,17 +468,10 @@ zone:
zonefile: %s
""" % (domain, zonefile)
# If a custom secondary nameserver has been set, allow zone transfers
# and notifies to that nameserver.
if get_secondary_dns(additional_records):
# Get the IP address of the nameserver by resolving it.
hostname = get_secondary_dns(additional_records)
resolver = dns.resolver.get_default_resolver()
response = dns.resolver.query(hostname+'.', "A")
ipaddr = str(response[0])
nsdconf += """\tnotify: %s NOKEY
provide-xfr: %s NOKEY
""" % (ipaddr, ipaddr)
# If custom secondary nameservers have been set, allow zone transfers
# and notifies to them.
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
nsdconf += "\n\tnotify: %s NOKEY\n\tprovide-xfr: %s NOKEY\n" % (ipaddr, ipaddr)
# Check if the file is changing. If it isn't changing,
# return False to flag that no change was made.
@@ -778,33 +784,64 @@ def set_custom_dns_record(qname, rtype, value, action, env):
if made_change:
# serialize & save
write_custom_dns_config(newconfig, env)
return made_change
########################################################################
def get_secondary_dns(custom_dns):
def get_secondary_dns(custom_dns, mode=None):
resolver = dns.resolver.get_default_resolver()
values = []
for qname, rtype, value in custom_dns:
if qname == "_secondary_nameserver":
return value
return None
if qname != '_secondary_nameserver': continue
for hostname in value.split(" "):
hostname = hostname.strip()
if mode == None:
# Just return the setting.
values.append(hostname)
continue
def set_secondary_dns(hostname, env):
# This is a hostname. Before including in zone xfr lines,
# resolve to an IP address. Otherwise just return the hostname.
if not hostname.startswith("xfr:"):
if mode == "xfr":
response = dns.resolver.query(hostname+'.', "A")
hostname = str(response[0])
values.append(hostname)
if hostname in (None, ""):
# Clear.
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
else:
# Validate.
hostname = hostname.strip().lower()
# This is a zone-xfer-only IP address. Do not return if
# we're querying for NS record hostnames. Only return if
# we're querying for zone xfer IP addresses - return the
# IP address.
elif mode == "xfr":
values.append(hostname[4:])
return values
def set_secondary_dns(hostnames, env):
if len(hostnames) > 0:
# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.
resolver = dns.resolver.get_default_resolver()
try:
response = dns.resolver.query(hostname, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
raise ValueError("Could not resolve the IP address of %s." % hostname)
for item in hostnames:
if not item.startswith("xfr:"):
# Resolve hostname.
try:
response = dns.resolver.query(item, "A")
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
raise ValueError("Could not resolve the IP address of %s." % item)
else:
# Validate IP address.
try:
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
if not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
except ValueError:
raise ValueError("'%s' is not an IPv4 address." % item[4:])
# Set.
set_custom_dns_record("_secondary_nameserver", "A", hostname, "set", env)
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
else:
# Clear.
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
# Apply.
return do_dns_update(env)

View File

@@ -3,6 +3,7 @@
import subprocess, shutil, os, sqlite3, 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.
@@ -52,11 +53,13 @@ def sanitize_idn_email_address(email):
# to the underlying protocols.
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("idna").decode('ascii')
domainpart = idna.encode(domainpart).decode('ascii')
return localpart + "@" + domainpart
except:
# Domain part is not IDNA-valid, so leave unchanged. If there
# are non-ASCII characters it will be filtered out by
except (ValueError, idna.IDNAError):
# ValueError: String does not have a single @-sign, so it is not
# a valid email address. IDNAError: Domain part is not IDNA-valid.
# Validation is not this function's job, so return value unchanged.
# If there are non-ASCII characters it will be filtered out by
# validate_email.
return email
@@ -65,10 +68,11 @@ def prettify_idn_email_address(email):
# names in IDNA in the database, but we want to show Unicode to the user.
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("ascii").decode('idna')
domainpart = idna.decode(domainpart.encode("ascii"))
return localpart + "@" + domainpart
except:
# Failed to decode IDNA. Should never happen.
except (ValueError, UnicodeError, idna.IDNAError):
# Failed to decode IDNA, or the email address does not have a
# single @-sign. Should never happen.
return email
def is_dcv_address(email):
@@ -177,13 +181,13 @@ def get_admins(env):
return users
def get_mail_aliases(env):
# Returns a sorted list of tuples of (alias, forward-to string).
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders).
c = open_database(env)
c.execute('SELECT source, destination FROM aliases')
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
c.execute('SELECT source, destination, permitted_senders FROM 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 = [ (source, aliases[source]) for source 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):
@@ -195,9 +199,10 @@ def get_mail_aliases_ex(env):
# domain: "domain.tld",
# alias: [
# {
# source: "name@domain.tld", # IDNA-encoded
# source_display: "name@domain.tld", # full Unicode
# destination: ["target1@domain.com", "target2@domain.com", ...],
# address: "name@domain.tld", # IDNA-encoded
# 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
# },
# ...
@@ -208,10 +213,10 @@ def get_mail_aliases_ex(env):
required_aliases = get_required_aliases(env)
domains = {}
for source, destination in get_mail_aliases(env):
for address, forwards_to, permitted_senders in get_mail_aliases(env):
# get alias info
domain = get_domain(source)
required = (source in required_aliases)
domain = get_domain(address)
required = (address in required_aliases)
# add to list
if not domain in domains:
@@ -220,33 +225,34 @@ def get_mail_aliases_ex(env):
"aliases": [],
}
domains[domain]["aliases"].append({
"source": source,
"source_display": prettify_idn_email_address(source),
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
"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,
})
# Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
# Sort aliases within each domain first by required-ness then lexicographically by source address.
# 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["source"]))
domain["aliases"].sort(key = lambda alias : (alias["required"], alias["address"]))
return domains
def get_domain(emailaddr, as_unicode=True):
# Gets the domain part of an email address. Turns IDNA
# back to Unicode for display.
ret = emailaddr.split('@', 1)[1]
if as_unicode: ret = ret.encode('ascii').decode('idna')
if as_unicode: ret = idna.decode(ret.encode('ascii'))
return ret
def get_mail_domains(env, filter_aliases=lambda alias : True):
# Returns the domain names (IDNA-encoded) of all of the email addresses
# configured on the system.
return set(
[get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
+ [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
[get_domain(login, as_unicode=False) for login in get_mail_users(env)]
+ [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ]
)
def add_mail_user(email, pw, privs, env):
@@ -402,67 +408,91 @@ def add_remove_mail_user_privilege(email, priv, action, env):
return "OK"
def add_mail_alias(source, destination, 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
source = sanitize_idn_email_address(source)
address = sanitize_idn_email_address(address)
# Our database is case sensitive (oops), which affects mail delivery
# (Postfix always queries in lowercase?), so force lowercase.
source = source.lower()
address = address.lower()
# validate source
source = source.strip()
if source == "":
return ("No incoming email address provided.", 400)
if not validate_email(source, mode='alias'):
return ("Invalid incoming email address (%s)." % source, 400)
# validate address
address = address.strip()
if address == "":
return ("No email address provided.", 400)
if not validate_email(address, mode='alias'):
return ("Invalid email address (%s)." % address, 400)
# validate forwards_to
validated_forwards_to = []
forwards_to = forwards_to.strip()
# extra checks for email addresses used in domain control validation
is_dcv_source = is_dcv_address(source)
# validate destination
dests = []
destination = destination.strip()
is_dcv_source = is_dcv_address(address)
# Postfix allows a single @domain.tld as the destination, which means
# the local part on the address is preserved in the rewrite. We must
# try to convert Unicode to IDNA first before validating that it's a
# legitimate alias address. Don't allow this sort of rewriting for
# DCV source addresses.
d1 = sanitize_idn_email_address(destination)
if validate_email(d1, mode='alias') and not is_dcv_source:
dests.append(d1)
r1 = sanitize_idn_email_address(forwards_to)
if validate_email(r1, mode='alias') and not is_dcv_source:
validated_forwards_to.append(r1)
else:
# Parse comma and \n-separated destination emails & validate. In this
# case, the recipients must be complete email addresses.
for line in destination.split("\n"):
# case, the forwards_to must be complete email addresses.
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 not validate_email(email):
return ("Invalid destination email address (%s)." % email, 400)
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(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)
dests.append(email)
if len(destination) == 0:
return ("No destination email address(es) provided.", 400)
destination = ",".join(dests)
validated_forwards_to.append(email)
# validate permitted_senders
valid_logins = get_mail_users(env)
validated_permitted_senders = []
permitted_senders = permitted_senders.strip()
# Parse comma and \n-separated sender logins & validate. The permitted_senders must be
# valid usernames.
for line in permitted_senders.split("\n"):
for login in line.split(","):
login = login.strip()
if login == "": continue
if login not in valid_logins:
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)
# save to db
forwards_to = ",".join(validated_forwards_to)
if len(validated_permitted_senders) == 0:
permitted_senders = None
else:
permitted_senders = ",".join(validated_permitted_senders)
conn, c = open_database(env, with_connection=True)
try:
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
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)." % source, 400)
return ("Alias already exists (%s)." % address, 400)
else:
c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source))
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
return_status = "alias updated"
conn.commit()
@@ -471,15 +501,15 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
# Update things in case any new domains are added.
return kick(env, return_status)
def remove_mail_alias(source, env, do_kick=True):
def remove_mail_alias(address, env, do_kick=True):
# convert Unicode domain to IDNA
source = sanitize_idn_email_address(source)
address = sanitize_idn_email_address(address)
# remove
conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (source,))
c.execute("DELETE FROM aliases WHERE source=?", (address,))
if c.rowcount != 1:
return ("That's not an alias (%s)." % source, 400)
return ("That's not an alias (%s)." % address, 400)
conn.commit()
if do_kick:
@@ -503,8 +533,8 @@ def get_required_aliases(env):
# 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[0].startswith("postmaster@") and not alias[0].startswith("admin@")
and not alias[0].startswith("@")
not alias.startswith("postmaster@") and not alias.startswith("admin@")
and not alias.startswith("@")
)
# Create postmaster@ and admin@ for all domains we serve mail on.
@@ -528,36 +558,38 @@ def kick(env, mail_result=None):
# Ensure every required alias exists.
existing_users = get_mail_users(env)
existing_aliases = get_mail_aliases(env)
existing_alias_records = get_mail_aliases(env)
existing_aliases = set(a for a, *_ in existing_alias_records) # just first entry in tuple
required_aliases = get_required_aliases(env)
def ensure_admin_alias_exists(source):
def ensure_admin_alias_exists(address):
# If a user account exists with that address, we're good.
if source in existing_users:
if address in existing_users:
return
# Does this alias exists?
for s, t in existing_aliases:
if s == source:
return
# If the alias already exists, we're good.
if address in existing_aliases:
return
# Doesn't exist.
administrator = get_system_administrator(env)
add_mail_alias(source, administrator, env, do_kick=False)
results.append("added alias %s (=> %s)\n" % (source, administrator))
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 alias in required_aliases:
ensure_admin_alias_exists(alias)
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 source, target in existing_aliases:
user, domain = source.split("@")
for address, forwards_to, *_ in existing_alias_records:
user, domain = address.split("@")
if user in ("postmaster", "admin") \
and source not in required_aliases \
and target == get_system_administrator(env):
remove_mail_alias(source, env, do_kick=False)
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
and address not in required_aliases \
and forwards_to == get_system_administrator(env):
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))
# Update DNS and nginx in case any domains are added/removed.

View File

@@ -10,6 +10,7 @@ import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
import dns.reversename, dns.resolver
import dateutil.parser, dateutil.tz
import idna
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns
from web_update import get_web_domains, get_default_www_redirects, get_domain_ssl_files
@@ -32,7 +33,7 @@ def run_checks(rounded_values, env, output, pool):
# (ignore errors; if bind9/rndc isn't running we'd already report
# that in run_services checks.)
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
run_system_checks(rounded_values, env, output)
# perform other checks asynchronously
@@ -259,14 +260,14 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
output = BufferedOutput()
# The domain is IDNA-encoded, but for display use Unicode.
output.add_heading(domain.encode('ascii').decode('idna'))
output.add_heading(idna.decode(domain.encode('ascii')))
if domain == env["PRIMARY_HOSTNAME"]:
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
if domain in dns_domains:
check_dns_zone(domain, env, output, dns_zonefiles)
if domain in mail_domains:
check_mail_domain(domain, env, output)
@@ -350,11 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
def check_alias_exists(alias_name, alias, env, output):
mail_alises = dict(get_mail_aliases(env))
if alias in mail_alises:
output.print_ok("%s exists as a mail alias. [%s%s]" % (alias_name, alias, mail_alises[alias]))
mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)])
if alias in mail_aliases:
if mail_aliases[alias]:
output.print_ok("%s exists as a mail alias. [%s%s]" % (alias_name, alias, mail_aliases[alias]))
else:
output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias)
else:
output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias)
output.print_error("""You must add a mail alias for %s which directs email to you or another administrator.""" % alias)
def check_dns_zone(domain, env, output, dns_zonefiles):
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
@@ -369,12 +373,9 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
# the TLD, and so we're not actually checking the TLD. For that we'd need
# to do a DNS trace.
ip = query_dns(domain, "A")
secondary_ns = get_secondary_dns(get_custom_dns_config(env)) or "ns2." + env['PRIMARY_HOSTNAME']
secondary_ns = get_secondary_dns(get_custom_dns_config(env), mode="NS") or ["ns2." + env['PRIMARY_HOSTNAME']]
existing_ns = query_dns(domain, "NS")
correct_ns = "; ".join(sorted([
"ns1." + env['PRIMARY_HOSTNAME'],
secondary_ns,
]))
correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns))
if existing_ns.lower() == correct_ns.lower():
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
elif ip == env['PUBLIC_IP']:
@@ -494,7 +495,7 @@ def check_mail_domain(domain, env, output):
# Check that the postmaster@ email address exists. Not required if the domain has a
# catch-all address or domain alias.
if "@" + domain not in dict(get_mail_aliases(env)):
if "@" + domain not in [address for address, *_ in get_mail_aliases(env)]:
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
# Stop if the domain is listed in the Spamhaus Domain Block List.
@@ -605,103 +606,115 @@ def check_ssl_cert(domain, rounded_time, env, output):
output.print_line(cert_status_details)
output.print_line("")
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=True, rounded_time=False):
# Use openssl verify to check the status of a certificate.
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=True, rounded_time=False, just_check_domain=False):
# Check that the ssl_certificate & ssl_private_key files are good
# for the provided domain.
# First check that the certificate is for the right domain. The domain
# must be found in the Subject Common Name (CN) or be one of the
# Subject Alternative Names. A wildcard might also appear as the CN
# or in the SAN list, so check for that tool.
retcode, cert_dump = shell('check_output', [
"openssl", "x509",
"-in", ssl_certificate,
"-noout", "-text", "-nameopt", "rfc2253",
], trap=True)
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.x509 import Certificate, DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
import idna
# If the certificate is catastrophically bad, catch that now and report it.
# More information was probably written to stderr (which we aren't capturing),
# but it is probably not helpful to the user anyway.
if retcode != 0:
return ("The SSL certificate appears to be corrupted or not a PEM-formatted SSL certificate file. (%s)" % ssl_certificate, None)
# The ssl_certificate file may contain a chain of certificates. We'll
# need to split that up before we can pass anything to openssl or
# parse them in Python. Parse it with the cryptography library.
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.")
except ValueError as e:
return ("There is a problem with the certificate file: %s" % str(e), None)
cert_dump = cert_dump.split("\n")
certificate_names = set()
cert_expiration_date = None
while len(cert_dump) > 0:
line = cert_dump.pop(0)
# First check that the domain name is one of the names allowed by
# the certificate.
if domain is not None:
# The domain may be found in the Subject Common Name (CN). This comes back as an IDNA (ASCII)
# string, which is the format we store domains in - so good.
certificate_names = set()
try:
certificate_names.add(
cert.subject.get_attributes_for_oid(OID_COMMON_NAME)[0].value
)
except IndexError:
# No common name? Certificate is probably generated incorrectly.
# But we'll let it error-out when it doesn't find the domain.
pass
# Grab from the Subject Common Name. We include the indentation
# at the start of the line in case maybe the cert includes the
# common name of some other referenced entity (which would be
# indented, I hope).
m = re.match(" Subject: CN=([^,]+)", line)
if m:
certificate_names.add(m.group(1))
# Grab from the Subject Alternative Name, which is a comma-delim
# list of names, like DNS:mydomain.com, DNS:otherdomain.com.
m = re.match(" X509v3 Subject Alternative Name:", line)
if m:
names = re.split(",\s*", cert_dump.pop(0).strip())
for n in names:
m = re.match("DNS:(.*)", n)
if m:
certificate_names.add(m.group(1))
# ... or be one of the Subject Alternative Names. The cryptography library handily IDNA-decodes
# the names for us. We must encode back to ASCII, but wildcard certificates can't pass through
# IDNA encoding/decoding so we must special-case. See https://github.com/pyca/cryptography/pull/2071.
def idna_decode_dns_name(dns_name):
if dns_name.startswith("*."):
return "*." + idna.encode(dns_name[2:]).decode('ascii')
else:
return idna.encode(dns_name).decode('ascii')
# Grab the expiration date for testing later.
m = re.match(" Not After : (.*)", line)
if m:
cert_expiration_date = dateutil.parser.parse(m.group(1))
try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans:
certificate_names.add(idna_decode_dns_name(san))
except ExtensionNotFound:
pass
wildcard_domain = re.sub("^[^\.]+", "*", domain)
if domain is not None and 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)
# Check that the domain appears among the acceptable names, or a wildcard
# form of the domain name (which is a stricter check than the specs but
# should work in normal cases).
wildcard_domain = re.sub("^[^\.]+", "*", domain)
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)
# Second, check that the certificate matches the private key. Get the modulus of the
# private key and of the public key in the certificate. They should match. The output
# of each command looks like "Modulus=XXXXX".
# Second, check that the certificate matches the private key.
if ssl_private_key is not None:
private_key_modulus = shell('check_output', [
"openssl", "rsa",
"-inform", "PEM",
"-noout", "-modulus",
"-in", ssl_private_key])
cert_key_modulus = shell('check_output', [
"openssl", "x509",
"-in", ssl_certificate,
"-noout", "-modulus"])
if private_key_modulus != cert_key_modulus:
return ("The certificate installed at %s does not correspond to the private key at %s." % (ssl_certificate, ssl_private_key), 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)
if not isinstance(priv_key, RSAPrivateKey):
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)
# 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".
# $ openssl rsa -inform PEM -noout -modulus -in ssl_private_key
# $ openssl x509 -in ssl_certificate -noout -modulus
# Third, check if the certificate is self-signed. Return a special flag string.
if cert.issuer == cert.subject:
return ("SELF-SIGNED", None)
# When selecting which certificate to use for non-primary domains, we check if the primary
# certificate or a www-parent-domain certificate is good for the domain. There's no need
# to run extra checks beyond this point.
if just_check_domain:
return ("OK", None)
# Check that the certificate hasn't expired. The datetimes returned by the
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
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)
# 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
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
# expired.
# In order to verify with openssl, we need to split out any
# intermediary certificates in the chain (if any) from our
# certificate (at the top). They need to be passed separately.
cert = open(ssl_certificate).read()
m = re.match(r'(-*BEGIN CERTIFICATE-*.*?-*END CERTIFICATE-*)(.*)', cert, re.S)
if m == None:
return ("The certificate file is an invalid PEM certificate.", None)
mycert, chaincerts = m.groups()
# 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 chaincerts.strip() == "" else ["-untrusted", "/dev/stdin"])
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
+ [ssl_certificate],
input=chaincerts.encode('ascii'),
input=b"\n\n".join(ssl_cert_chain[1:]),
trap=True)
if "self signed" in verifyoutput:
# Certificate is self-signed.
# Certificate is self-signed. Probably we detected this above.
return ("SELF-SIGNED", None)
elif retcode != 0:
@@ -716,7 +729,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# good.
# But is it expiring soon?
now = datetime.datetime.now(dateutil.tz.tzlocal())
cert_expiration_date = cert.not_valid_after
ndays = (cert_expiration_date-now).days
if not rounded_time or ndays < 7:
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
@@ -733,6 +746,33 @@ 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
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.
from cryptography.x509 import load_pem_x509_certificate
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
pem_type = re.match(b"-+BEGIN (.*?)-+[\r\n]", pem)
if pem_type is None:
raise ValueError("File is not a valid PEM-formatted file.")
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())
if pem_type == b"CERTIFICATE":
return load_pem_x509_certificate(pem, default_backend())
raise ValueError("Unsupported PEM object type: " + pem_type.decode("ascii", "replace"))
_apt_updates = None
def list_apt_updates(apt_update=True):
# See if we have this information cached recently.
@@ -767,6 +807,20 @@ def list_apt_updates(apt_update=True):
return pkgs
def what_version_is_this(env):
# This function runs `git describe` on the Mail-in-a-Box installation directory.
# Git may not be installed and Mail-in-a-Box may not have been cloned from github,
# so this function may raise all sorts of exceptions.
miab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
tag = shell("check_output", ["/usr/bin/git", "describe"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip()
return tag
def get_latest_miab_version():
# This pings https://mailinabox.email/bootstrap.sh and extracts the tag named in
# the script to determine the current product version.
import urllib.request
return re.search(b'TAG=(.*)', urllib.request.urlopen("https://mailinabox.email/bootstrap.sh?ping=1").read()).group(1).decode("utf8")
def run_and_output_changes(env, pool, send_via_email):
import json
from difflib import SequenceMatcher
@@ -833,7 +887,7 @@ def run_and_output_changes(env, pool, send_via_email):
if category not in cur_status:
out.add_heading(category)
out.print_warning("This section was removed.")
if send_via_email:
# If there were changes, send off an email.
buf = out.buf.getvalue()
@@ -845,7 +899,7 @@ def run_and_output_changes(env, pool, send_via_email):
msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME']
msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME']
msg.set_payload(buf, "UTF-8")
# send to administrator@
import smtplib
mailserver = smtplib.SMTP('localhost', 25)
@@ -855,7 +909,7 @@ def run_and_output_changes(env, pool, send_via_email):
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
msg.as_string())
mailserver.quit()
# Store the current status checks output for next time.
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
with open(cache_fn, "w") as f:
@@ -947,3 +1001,6 @@ if __name__ == "__main__":
if cert_status != "OK":
sys.exit(1)
sys.exit(0)
elif sys.argv[1] == "--version":
print(what_version_is_this(env))

View File

@@ -7,33 +7,61 @@
<h3>Add a mail alias</h3>
<p>Aliases are email forwarders. An alias can forward email to a <a href="javascript: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>
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<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 active" data-mode="regular">Regular</button>
<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. Enter just a part of an email address starting with the @-sign.</span>
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched mail from one domain to another domain, preserving the part before the @-sign.</span>
<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="addaliasEmail" class="col-sm-1 control-label">Alias</label>
<label for="addaliasAddress" class="col-sm-1 control-label">Alias</label>
<div class="col-sm-10">
<input type="email" class="form-control" id="addaliasEmail">
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters for the domain part of the email address only.</div>
<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="addaliasTargets" class="col-sm-1 control-label">Forward To</label>
<label for="addaliasForwardsTo" class="col-sm-1 control-label">Forwards To</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea>
<textarea class="form-control" rows="3" id="addaliasForwardsTo"></textarea>
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
<span class="domainalias">Enter just the part of an email address starting with the @-sign.</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 Fowards 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">
@@ -51,6 +79,7 @@
<th></th>
<th>Alias<br></th>
<th>Forwards To</th>
<th>Permitted Senders</th>
</tr>
</thead>
<tbody>
@@ -70,8 +99,9 @@
<span class="glyphicon glyphicon-trash"></span>
</a>
</td>
<td class='email'> </td>
<td class='target'> </td>
<td class='address'> </td>
<td class='forwardsTo'> </td>
<td class='senders'> </td>
</tr>
</table>
</div>
@@ -98,10 +128,12 @@ function show_aliases() {
n.attr('id', '');
if (alias.required) n.addClass('alias-required');
n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
n.find('td.email').text(alias.source_display)
for (var j = 0; j < alias.destination.length; j++)
n.find('td.target').append($("<div></div>").text(alias.destination[j]))
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);
}
}
@@ -111,25 +143,25 @@ function show_aliases() {
$('#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") {
$('#addaliasEmail').attr('type', 'email');
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#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") {
$('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#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();
$('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.catchall').removeClass('hidden');
$('#addalias-form .catchall').removeClass('hidden');
} else if ($(this).attr('data-mode') == "domainalias") {
$('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
$('#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();
$('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.domainalias').removeClass('hidden');
$('#addalias-form .domainalias').removeClass('hidden');
}
})
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
@@ -139,15 +171,21 @@ function show_aliases() {
var is_alias_add_update = false;
function do_add_alias() {
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
var email = $("#addaliasEmail").val();
var targets = $("#addaliasTargets").val();
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',
source: email,
destination: targets
address: form_address,
forwards_to: form_forwardsto,
permitted_senders: form_senders
},
function(r) {
// Responses are multiple lines of pre-formatted text.
@@ -162,48 +200,55 @@ function do_add_alias() {
}
function aliases_reset_form() {
$("#addaliasEmail").prop('disabled', false);
$("#addaliasEmail").val('')
$("#addaliasTargets").val('')
$("#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 email = $(elem).parents('tr').attr('data-email');
var targetdivs = $(elem).parents('tr').find('.target div');
var targets = "";
for (var i = 0; i < targetdivs.length; i++)
targets += $(targetdivs[i]).text() + "\n";
is_alias_add_update = true;
$('#alias-cancel').removeClass('hidden');
$("#addaliasEmail").prop('disabled', true);
$("#addaliasEmail").val(email);
$("#addaliasTargets").val(targets);
$('#add-alias-button').text('Update');
if (email.charAt(0) == '@' && targets.charAt(0) == '@')
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 (email.charAt(0) == '@')
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 email = $(elem).parents('tr').attr('data-email');
var row_address = $(elem).parents('tr').attr('data-address');
show_modal_confirm(
"Remove Alias",
"Remove " + email + "?",
"Remove " + row_address + "?",
"Remove",
function() {
api(
"/mail/aliases/remove",
"POST",
{
source: email
address: row_address
},
function(r) {
// Responses are multiple lines of pre-formatted text.

View File

@@ -67,7 +67,8 @@
<h3>Using a Secondary Nameserver</h3>
<p>If your TLD requires you to have two separate nameservers, you can either set up a secondary (aka &ldquo;slave&rdquo;) nameserver or, alternatively, set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box. If you choose to use a seconday/slave nameserver, you must find a seconday/slave nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday/slave nameserver service, enter the hostname of <em>their</em> secondary nameserver:</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 seconday nameserver, you must find a seconday nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the seconday 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">
@@ -83,7 +84,11 @@
</div>
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
<div class="col-sm-offset-1 col-sm-11">
<p 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>
<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 <code>xfr:IPADDRESS</code>.
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>
</form>
@@ -152,8 +157,8 @@ function show_custom_dns() {
"GET",
{ },
function(data) {
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
$('#secondarydnsHostname').val(data.hostnames.join(' '));
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
});
api(
@@ -210,7 +215,7 @@ function do_set_secondary_dns() {
"/dns/secondary-nameserver",
"POST",
{
hostname: $('#secondarydnsHostname').val()
hostnames: $('#secondarydnsHostname').val()
},
function(data) {
if (data == "") return; // nothing updated

View File

@@ -114,6 +114,7 @@
</li>
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
<li><a href="#version" onclick="return show_panel(this);">Version</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
@@ -167,6 +168,10 @@
{% include "ssl.html" %}
</div>
<div id="panel_version" class="admin_panel">
{% include "version.html" %}
</div>
<hr>
<footer>

View File

@@ -5,17 +5,77 @@
<h2>Backup Status</h2>
<h3>Copying Backup Files</h3>
<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. The backup is stored on the machine itself. You are responsible for copying the backup files off of the machine.</p>
<h3>Configuration</h3>
<p>Many cloud providers make this easy by allowing you to take snapshots of the machine's disk.</p>
<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-2">
<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="s3">Amazon S3</option>
</select>
</div>
</div>
<div class="form-group backup-target-local">
<div class="col-sm-10 col-sm-offset-2">
<div>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 id="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</div>
</div>
</div>
<div class="form-group backup-target-s3">
<div class="col-sm-10 col-sm-offset-2">
<div>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</div>
</div>
</div>
<div class="form-group backup-target-local backup-target-s3">
<label for="min-age" class="col-sm-2 control-label">How many days should backups be kept?</label>
<div class="col-sm-8">
<input type="number" class="form-control" rows="1" id="min-age">
</div>
</div>
<div class="form-group backup-target-s3">
<label for="backup-target-s3-host" 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">
{% for name, host in backup_s3_hosts %}
<option value="{{host}}">{{name}}</option>
{% endfor %}
</select>
</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>
<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>
</form>
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrypted, so they are safe to store anywhere. Copy the encryption password from <tt id="backup-encpassword-file"></tt> also but keep it in a safe location.</p>
<p>Copy the encryption password from <tt id="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
<h3>Current Backups</h3>
<h3>Available Backups</h3>
<p>The backup directory currently contains the backups listed below. The total size on disk 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>
@@ -27,8 +87,14 @@
<tbody>
</tbody>
</table>
<script>
function toggle_form() {
var target_type = $("#backup-target-type").val();
$(".backup-target-local, .backup-target-s3").hide();
$(".backup-target-" + target_type).show();
}
function nice_size(bytes) {
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
while (true) {
@@ -46,19 +112,22 @@ function nice_size(bytes) {
}
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) {
$('#backup-location').text(r.directory);
$('#backup-encpassword-file').text(r.encpwfile);
$('#backup-status tbody').html("");
var total_disk_size = 0;
if (r.backups.length == 0) {
if (typeof r.backups == "undefined") {
var tr = $('<tr><td colspan="3">Backups are turned off.</td></tr>');
$('#backup-status tbody').append(tr);
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);
}
@@ -83,4 +152,62 @@ function show_system_backup() {
$('#backup-total-size').text(nice_size(total_disk_size));
})
}
function show_custom_backup() {
$(".backup-target-local, .backup-target-s3").hide();
api(
"/system/backup/config",
"GET",
{ },
function(r) {
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, 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('/'));
}
$("#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);
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();
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();
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) {
// Responses are multiple lines of pre-formatted text.
show_modal_error("Backup configuration", $("<pre/>").text(r), function() { show_system_backup(); }); // refresh after modal
},
function(r) {
show_modal_error("Backup configuration (error)", r);
});
return false;
}
</script>

View File

@@ -32,9 +32,9 @@
</form>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Passwords must be at least four characters and may not contain spaces.</li>
<li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</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="javascript:show_panel('aliases')">aliases</a> can.</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>

View File

@@ -0,0 +1,36 @@
<style>
</style>
<h2>Mail-in-a-Box Version</h2>
<p>You are running Mail-in-a-Box version <span id="miab-version" style="font-weight: bold">...</span>.</p>
<p>The latest version of Mail-in-a-Box is <button id="miab-get-latest-upstream" onclick="check_latest_version()">Check</button>.</p>
<p>To find the latest version and for upgrade instructions, see <a href="https://mailinabox.email/">https://mailinabox.email/</a>, <a href="https://github.com/mail-in-a-box/mailinabox/blob/master/CHANGELOG.md">release notes</a>, and <a href="https://mailinabox.email/maintenance.html#updating-mail-in-a-box">upgrade instructions</a>.</p>
<script>
function show_version() {
$('#miab-version').text('loading...');
api(
"/system/version",
"GET",
{
},
function(version) {
$('#miab-version').text(version);
});
}
function check_latest_version() {
$('#miab-get-latest-upstream').text('loading...');
api(
"/system/latest-upstream-version",
"POST",
{
},
function(version) {
$('#miab-get-latest-upstream').text(version);
});
}
</script>

View File

@@ -24,32 +24,52 @@ def safe_domain_name(name):
return urllib.parse.quote(name, safe='')
def sort_domains(domain_names, env):
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
# must appear first so it becomes the nginx default server.
# First group PRIMARY_HOSTNAME and its subdomains, then parent domains of PRIMARY_HOSTNAME, then other domains.
groups = ( [], [], [] )
for d in domain_names:
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
groups[0].append(d)
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
groups[1].append(d)
# 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:
groups[2].append(d)
# 'break' did not occur: there is no parent domain, so it is its
# own zone.
zones[domain] = domain
# Within each group, sort parent domains before subdomains and after that sort lexicographically.
def sort_group(group):
# Find the top-most domains.
top_domains = sorted(d for d in group if len([s for s in group if d.endswith("." + s)]) == 0)
ret = []
for d in top_domains:
ret.append(d)
ret.extend( sort_group([s for s in group if s.endswith("." + d)]) )
return ret
groups = [sort_group(g) for g in groups]
# 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)),
return groups[0] + groups[1] + groups[2]
# 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]),
# 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']),
# Then in right-to-left lexicographic order of the .-separated parts of the name.
list(reversed(d.split("."))),
))
return domain_names
def sort_email_addresses(email_addresses, env):
email_addresses = set(email_addresses)
@@ -200,3 +220,12 @@ def wait_for_service(port, public, env, timeout):
if time.perf_counter() > start+timeout:
return False
time.sleep(min(timeout/4, 1))
if __name__ == "__main__":
from dns_update import get_dns_domains
from web_update import get_web_domains, get_default_www_redirects
env = load_environment()
domains = get_dns_domains(env) | set(get_web_domains(env) + get_default_www_redirects(env))
domains = sort_domains(domains, env)
for domain in domains:
print(domain)

View File

@@ -201,14 +201,14 @@ def get_domain_ssl_files(domain, env, allow_shared_cert=True):
# the user has uploaded a different private key for this domain.
if not ssl_key_is_alt and allow_shared_cert:
from status_checks import check_certificate
if check_certificate(domain, ssl_certificate_primary, None)[0] == "OK":
if check_certificate(domain, ssl_certificate_primary, None, just_check_domain=True)[0] == "OK":
ssl_certificate = ssl_certificate_primary
ssl_via = "Using multi/wildcard certificate of %s." % env['PRIMARY_HOSTNAME']
# For a 'www.' domain, see if we can reuse the cert of the parent.
elif domain.startswith('www.'):
ssl_certificate_parent = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain[4:]))
if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None)[0] == "OK":
if os.path.exists(ssl_certificate_parent) and check_certificate(domain, ssl_certificate_parent, None, just_check_domain=True)[0] == "OK":
ssl_certificate = ssl_certificate_parent
ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
@@ -254,7 +254,7 @@ def create_csr(domain, ssl_key, env):
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
def install_cert(domain, ssl_cert, ssl_chain, env):
if domain not in get_web_domains(env):
if domain not in get_web_domains(env) + get_default_www_redirects(env):
return "Invalid domain name."
# Write the combined cert+chain to a temporary path and validate that it is OK.

View File

@@ -17,8 +17,11 @@ The primary goal of Mail-in-a-Box is to make deploying a good mail server easy,
On the other hand, we do assume that adversaries are performing passive surveillance and, possibly, active man-in-the-middle attacks. And so:
* User credentials are always sent through SSH/TLS, never in the clear.
* Outbound mail is sent with the highest level of TLS possible (more on that below).
* User credentials are always sent through SSH/TLS, never in the clear, with modern TLS settings.
* Outbound mail is sent with the highest level of TLS possible.
* The box advertises its support for [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), when DNSSEC is enabled at the domain name registrar, so that inbound mail is more likely to be transmitted securely.
Additional details follow.
User Credentials
----------------
@@ -85,7 +88,13 @@ If the recipient's domain name supports DNSSEC and has published a [DANE TLSA](h
### Domain Policy Records
Domain policy records allow recipient MTAs to detect when the _domain_ part of incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and "quarantine" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is "From:" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))
Domain policy records allow recipient MTAs to detect when the _domain_ part of of the sender address in incoming mail has been spoofed. All outbound mail is signed with [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) and "quarantine" [DMARC](https://en.wikipedia.org/wiki/DMARC) records are automatically set in DNS. Receiving MTAs that implement DMARC will automatically quarantine mail that is "From:" a domain hosted by the box but which was not sent by the box. (Strong [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) records are also automatically set in DNS.) ([source](management/dns_update.py))
### User Policy
While domain policy records prevent other servers from sending mail with a "From:" header that matches a domain hosted on the box (see above), those policy records do not guarnatee that the user portion of the sender email address matches the actual sender. In enterprise environments where the box may host the mail of untrusted users, it is important to guard against users impersonating other users.
The box restricts the envelope sender address (also called the return path or MAIL FROM address --- this is different from the "From:" header) that users may put into outbound mail. The envelope sender address must be either their own email address (their SMTP login username) or any alias that they are listed as a permitted sender of. (There is currently no restriction on the contents of the "From:" header.)
Incoming Mail
-------------

View File

@@ -7,7 +7,7 @@
#########################################################
if [ -z "$TAG" ]; then
TAG=v0.10
TAG=v0.13a
fi
# Are we running as root?

View File

@@ -10,6 +10,7 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# Install DKIM...
echo Installing OpenDKIM/OpenDMARC...
apt_install opendkim opendkim-tools opendmarc
# Make sure configuration directories exist.
@@ -35,13 +36,18 @@ RequireSafeKeys false
EOF
fi
# Create a new DKIM key. This creates
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
# is the actual private key and the latter is the suggested DNS TXT
# entry which we'll want to include in our DNS setup.
# Create a new DKIM key. This creates mail.private and mail.txt
# in $STORAGE_ROOT/mail/dkim. The former is the private key and
# the latter is the suggested DNS TXT entry which we'll include
# in our DNS setup. Note tha the files are named after the
# 'selector' of the key, which we can change later on to support
# key rotation.
#
# A 1024-bit key is seen as a minimum standard by several providers
# such as Google. But they and others use a 2048 bit key, so we'll
# do the same. Keys beyond 2048 bits may exceed DNS record limits.
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
# Should we specify -h rsa-sha256?
opendkim-genkey -r -s mail -D $STORAGE_ROOT/mail/dkim
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim
fi
# Ensure files are owned by the opendkim user and are private otherwise.

View File

@@ -15,7 +15,7 @@ source /etc/mailinabox.conf # load global vars
# * 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
# Prepare nsd's configuration.

View File

@@ -53,5 +53,5 @@ if [ -z "`tools/mail.py user`" ]; then
hide_output tools/mail.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
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
fi

View File

@@ -39,32 +39,12 @@ function apt_get_quiet {
}
function apt_install {
# Report any packages already installed.
# Install a bunch of packages. We used to report which packages were already
# installed and which needed installing, before just running an 'apt-get
# 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=$@
TO_INSTALL=""
ALREADY_INSTALLED=""
for pkg in $PACKAGES; do
if dpkg -s $pkg 2>/dev/null | grep "^Status: install ok installed" > /dev/null; then
if [[ ! -z "$ALREADY_INSTALLED" ]]; then ALREADY_INSTALLED="$ALREADY_INSTALLED, "; fi
ALREADY_INSTALLED="$ALREADY_INSTALLED$pkg (`dpkg -s $pkg | grep ^Version: | sed -e 's/.*: //'`)"
else
TO_INSTALL="$TO_INSTALL""$pkg "
fi
done
# List the packages already installed.
if [[ ! -z "$ALREADY_INSTALLED" ]]; then
echo already installed: $ALREADY_INSTALLED
fi
# List the packages about to be installed.
if [[ ! -z "$TO_INSTALL" ]]; then
echo installing $TO_INSTALL...
fi
# We still include the whole original package list in the apt-get command in
# case it wants to upgrade anything, I guess? Maybe we can remove it. Doesn't normally make
# a difference.
apt_get_quiet install $PACKAGES
}

View File

@@ -23,6 +23,7 @@ source /etc/mailinabox.conf # load global vars
# but dovecot-lucene is packaged by *us* in the Mail-in-a-Box PPA,
# not by Ubuntu.
echo "Installing Dovecot (IMAP server)..."
apt_install \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
dovecot-sieve dovecot-managesieved dovecot-lucene

View File

@@ -49,6 +49,7 @@ source /etc/mailinabox.conf # load global vars
# > infrastructure and every commercial vendor of dnswl.org data (eg through
# > anti-spam solutions) must register with dnswl.org and purchase a subscription.
echo "Installing Postfix (SMTP server)..."
apt_install postfix postfix-pcre postgrey ca-certificates
# ### Basic Settings
@@ -93,6 +94,12 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.
cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters
# Modify the `outgoing_mail_header_filters` file to use the local machine name and ip
# on the first received header line. This may help reduce the spam score of email by
# removing the 127.0.0.1 reference.
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
# require TLS before a user is allowed to authenticate. This also makes
# opportunistic TLS available on *incoming* mail.
@@ -160,6 +167,7 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
#
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
# * `reject_authenticated_sender_login_mismatch`: Reject if mail FROM address does not match the client SASL login
# * `reject_rhsbl_sender`: Reject return paths that use blacklisted domains.
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
@@ -173,7 +181,7 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into #NODOC
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_rhsbl_sender dbl.spamhaus.org" \
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"
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that

View File

@@ -5,7 +5,7 @@
#
# This script configures user authentication for Dovecot
# and Postfix (which relies on Dovecot) and destination
# validation by quering an Sqlite3 database of mail users.
# validation by quering an Sqlite3 database of mail users.
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
if [ ! -f $db_path ]; then
echo Creating new user database: $db_path;
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | 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;
fi
# ### User Authentication
@@ -69,6 +69,27 @@ tools/editconf.py /etc/postfix/main.cf \
smtpd_sasl_path=private/auth \
smtpd_sasl_auth_enable=yes
# ### Sender Validation
# We use Postfix's reject_authenticated_sender_login_mismatch filter to
# prevent intra-domain spoofing by logged in but untrusted users in outbound
# email. In all outbound mail (the sender has authenticated), the MAIL FROM
# address (aka envelope or return path address) must be "owned" by the user
# who authenticated. An SQL query will find who are the owners of any given
# address.
tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
# Postfix will query the exact address first, where the priority will be alias
# records first, then user records. If there are no matches for the exact
# address, then Postfix will query just the domain part, which we call
# catch-alls and domain aliases. A NULL permitted_senders column means to
# take the value from the destination column.
cat > /etc/postfix/sender-login-maps.cf << EOF;
dbpath=$db_path
query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
EOF
# ### Destination Validation
# Use a Sqlite3 database to check whether a destination email address exists,
@@ -79,29 +100,45 @@ tools/editconf.py /etc/postfix/main.cf \
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
local_recipient_maps=\$virtual_mailbox_maps
# SQL statement to check if we handle mail for a domain, either for users or aliases.
# 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'
EOF
# SQL statement to check if we handle mail for a user.
# SQL statement to check if we handle incoming mail for a user.
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
dbpath=$db_path
query = SELECT 1 FROM users WHERE email='%s'
EOF
# SQL statement to rewrite an email address if an alias is present.
# Aliases have precedence over users, but that's counter-intuitive for
# catch-all aliases ("@domain.com") which should *not* catch mail users.
# To fix this, not only query the aliases table but also the users
# table, i.e. turn users into aliases from themselves to themselves.
#
# Postfix makes multiple queries for each incoming mail. It first
# queries the whole email address, then just the user part in certain
# locally-directed cases (but we don't use this), then just `@`+the
# domain part. The first query that returns something wins. See
# http://www.postfix.org/virtual.5.html.
#
# virtual-alias-maps has precedence over virtual-mailbox-maps, but
# we don't want catch-alls and domain aliases to catch mail for users
# that have been defined on those domains. To fix this, we not only
# query the aliases table but also the users table when resolving
# aliases, i.e. we turn users into aliases from themselves to
# themselves. That means users will match in postfix's first query
# before postfix gets to the third query for catch-alls/domain alises.
#
# If there is both an alias and a user for the same address either
# might be returned by the UNION, so the whole query is wrapped in
# another select that prioritizes the alias definition.
# another select that prioritizes the alias definition to preserve
# postfix's preference for aliases for whole email addresses.
#
# Since we might have alias records with an empty destination because
# it might have just permitted_senders, skip any records with an
# 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' 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') ORDER BY priority LIMIT 1;
EOF
# Restart Services

View File

@@ -2,9 +2,17 @@
source setup/functions.sh
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
hide_output pip3 install rtyaml "email_validator==0.1.0-rc5"
# email_validator is repeated in setup/questions.sh
echo "Installing Mail-in-a-Box system management daemon..."
# build-essential libssl-dev libffi-dev python3-dev: Required to pip install cryptography.
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil \
build-essential libssl-dev libffi-dev python3-dev python-pip
hide_output pip3 install --upgrade rtyaml email_validator idna cryptography boto
# duplicity uses python 2 so we need to use the python 2 package of boto
hide_output pip install --upgrade boto
# email_validator is repeated in setup/questions.sh
# Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup

View File

@@ -95,6 +95,21 @@ def migration_7(env):
# Save.
conn.commit()
def migration_8(env):
# Delete DKIM keys. We had generated 1024-bit DKIM keys.
# By deleting the key file we'll automatically generate
# a new key, which will be 2048 bits.
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
def migration_9(env):
# Add a column to the aliases table to store permitted_senders,
# which is a list of user account email addresses that are
# permitted to send mail using this alias instead of their own
# address. This was motivated by the addition of #427 ("Reject
# outgoing mail if FROM does not match Login") - which introduced
# the notion of outbound permitted-senders.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD permitted_senders TEXT"])
def get_current_migration():
ver = 0

View File

@@ -6,6 +6,7 @@ source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
# install Munin
echo "Installing Munin (system monitoring)..."
apt_install munin munin-node
# edit config
@@ -28,5 +29,33 @@ contact.admin.command mail -s "Munin notification ${var:host}" administrator@$PR
contact.admin.always_send warning critical
EOF
# ensure munin-node knows the name of this machine
tools/editconf.py /etc/munin/munin-node.conf -s \
host_name=$PRIMARY_HOSTNAME
# Update the activated plugins through munin's autoconfiguration.
munin-node-configure --shell --remove-also 2>/dev/null | sh
# Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change
# (which is taken care of my munin-node-configure, but only when we re-run it.)
find /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm
# Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.
for f in $(find /etc/munin/plugins/ \( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ \)); do
IF=$(echo $f | sed s/.*_//);
if ! ifquery $IF >/dev/null 2>/dev/null; then
rm $f;
fi;
done
# Create a 'state' directory. Not sure why we need to do this manually.
mkdir -p /var/lib/munin-node/plugin-state/
# Restart services.
restart_service munin
restart_service munin-node
# generate initial statistics so the directory isn't empty
sudo -u munin munin-cron
# (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied"
# if we don't explicitly set the HOME directory when sudo'ing.)
sudo -H -u munin munin-cron

View File

@@ -7,16 +7,18 @@ source /etc/mailinabox.conf # load global vars
# ### Installing ownCloud
echo "Installing ownCloud (contacts/calendar)..."
apt_install \
dbconfig-common \
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
php5 php5-dev php5-gd php5-fpm memcached php5-memcache unzip
php5 php5-dev php5-gd php5-fpm memcached php5-memcached unzip
apt-get purge -qq -y owncloud*
# Install ownCloud from source of this version:
owncloud_ver=8.0.4
owncloud_hash=625b1c561ea51426047a3e79eda51ca05e9f978a
owncloud_ver=8.1.1
owncloud_hash=34077e78575a3e689825a00964ee37fbf83fbdda
# Migrate <= v0.10 setups that stored the ownCloud config.php in /usr/local rather than
# in STORAGE_ROOT. Move the file to STORAGE_ROOT.
@@ -34,24 +36,24 @@ fi
if [ ! -d /usr/local/lib/owncloud/ ] \
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
# Download and verify
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
# Clear out the existing ownCloud.
if [ ! -d /usr/local/lib/owncloud/ ]; then
echo installing ownCloud...
else
if [ -d /usr/local/lib/owncloud/ ]; then
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
fi
# Download and extract ownCloud.
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
# Extract ownCloud
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
rm -f /tmp/owncloud.zip
# The two apps we actually want are not in ownCloud core. Clone them from
# their github repositories.
mkdir -p /usr/local/lib/owncloud/apps
git_clone https://github.com/owncloud/contacts v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
git_clone https://github.com/owncloud/contacts 4ff855e7c2075309041bead09fbb9eb7df678244 '' /usr/local/lib/owncloud/apps/contacts
git_clone https://github.com/owncloud/calendar ec53139b144c0f842c33813305612e8006c42ea5 '' /usr/local/lib/owncloud/apps/calendar
# Fix weird permissions.
chmod 750 /usr/local/lib/owncloud/{apps,config}
@@ -65,8 +67,20 @@ if [ ! -d /usr/local/lib/owncloud/ ] \
# that error.
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
# If this isn't a new installation, immediately run the upgrade script.
# Then check for success (0=ok and 3=no upgrade needed, both are success).
if [ -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
# ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but
# that can be OK.
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
sudo -u www-data php /usr/local/lib/owncloud/occ maintenance:mode --off
echo "...which seemed to work."
fi
fi
fi
# ### Configuring ownCloud
@@ -94,12 +108,14 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
'overwritewebroot' => '/cloud',
'overwrite.cli.url' => '/cloud',
'user_backends' => array(
array(
'class'=>'OC_User_IMAP',
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
)
),
'memcache.local' => '\\OC\\Memcache\\Memcached',
"memcached_servers" => array (
array('localhost', 11211),
),
@@ -146,6 +162,22 @@ EOF
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
fi
# Update existing configuration files with changed settings that weren't included in
# previous versions of Mail-in-a-Box. Use PHP to read the settings file, modify it,
# and write out the new settings array.
CONFIG_TEMP=$(/bin/mktemp)
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
<?php
include("$STORAGE_ROOT/owncloud/config.php");
\$CONFIG['memcache.local'] = '\\OC\\Memcache\\Memcached';
\$CONFIG['overwrite.cli.url'] = '/cloud';
echo "<?php\n\\\$CONFIG = ";
var_export(\$CONFIG);
echo ";";
?>
EOF
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php
# Enable/disable apps. Note that this must be done after the ownCloud setup.
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
# user_external is what allows ownCloud to use IMAP for login. The contacts
@@ -155,6 +187,12 @@ hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
# When upgrading, run the upgrade script again now that apps are enabled. It seems like
# the first upgrade at the top won't work because apps may be disabled during upgrade?
# Check for success (0=ok, 3=no upgrade needed).
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
# Set PHP FPM values to support large file uploads
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \

View File

@@ -13,7 +13,7 @@ if [ -z "$NONINTERACTIVE" ]; then
fi
# email_validator is repeated in setup/management.sh
hide_output pip3 install "email_validator==0.1.0-rc5" || exit 1
hide_output pip3 install email_validator || exit 1
message_box "Mail-in-a-Box Installation" \
"Hello and thanks for deploying a Mail-in-a-Box!

View File

@@ -16,14 +16,26 @@ source setup/functions.sh # load our functions
# ----------------------------------------
# Install packages.
echo "Installing SpamAssassin..."
apt_install spampd razor pyzor dovecot-antispam
# Allow spamassassin to download new rules.
tools/editconf.py /etc/default/spamassassin \
CRON=1
# Configure pyzor.
hide_output pyzor discover
# Configure pyzor, which is a client to a live database of hashes of
# spam emails. Set the pyzor configuration directory to something sane.
# The default is ~/.pyzor. We used to use that, so we'll kill that old
# directory. Then write the public pyzor server to its servers file.
# That will prevent an automatic download on first use, and also means
# we can skip 'pyzor discover', both of which are currently broken by
# something happening on Sourceforge (#496).
rm -rf ~/.pyzor
tools/editconf.py /etc/spamassassin/local.cf -s \
pyzor_options="--homedir /etc/spamassassin/pyzor"
mkdir -p /etc/spamassassin/pyzor
echo "public.pyzor.org:24441" > /etc/spamassassin/pyzor/servers
# check with: pyzor --homedir /etc/mail/spamassassin/pyzor ping
# Configure spampd:
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't

View File

@@ -19,6 +19,7 @@
source setup/functions.sh # load our functions
source /etc/mailinabox.conf # load global vars
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
apt_install openssl
mkdir -p $STORAGE_ROOT/ssl

View File

@@ -52,9 +52,13 @@ chmod +x /usr/local/bin/mailinabox
source setup/questions.sh
# Run some network checks to make sure setup on this machine makes sense.
# Skip on existing installs since we don't want this to block the ability to
# upgrade, and these checks are also in the control panel status checks.
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
source setup/network-checks.sh
fi
fi
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a

View File

@@ -11,12 +11,21 @@ source setup/functions.sh # load our functions
# text search plugin for (and by) dovecot, which is not available in
# Ubuntu currently.
#
# Add that to the system's list of repositories:
# Add that to the system's list of repositories using add-apt-repository.
# But add-apt-repository may not be installed. If it's not available,
# then install it. But we have to run apt-get update before we try to
# install anything so the package index is up to date. After adding the
# PPA, we have to run apt-get update *again* to load the PPA's index,
# so this must precede the apt-get update line below.
if [ ! -f /usr/bin/add-apt-repository ]; then
echo "Installing add-apt-repository..."
hide_output apt-get update
apt_install software-properties-common
fi
hide_output add-apt-repository -y ppa:mail-in-a-box/ppa
# The apt-get update in the next step will pull in the PPA's index.
# ### Update Packages
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
@@ -39,9 +48,10 @@ apt_get_quiet upgrade
# * netcat-openbsd: `nc` command line networking tool
# * git: we install some things directly from github
# * sudo: allows privileged users to execute commands as root without being root
# * coreutils: includes `nproc` tool to report number of processors
# * coreutils: includes `nproc` tool to report number of processors, mktemp
# * bc: allows us to do math to compute sane defaults
echo Installing system packages...
apt_install python3 python3-dev python3-pip \
netcat-openbsd wget curl git sudo coreutils bc \
haveged unattended-upgrades cron ntp fail2ban

View File

@@ -17,6 +17,7 @@ fi
#
# Turn off nginx's default website.
echo "Installing Nginx (web server)..."
apt_install nginx php5-fpm
rm -f /etc/nginx/sites-enabled/default
@@ -36,6 +37,10 @@ tools/editconf.py /etc/nginx/nginx.conf -s \
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
expose_php=Off
# Set PHPs default charset to UTF-8, since we use it. See #367.
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
default_charset="UTF-8"
# Bump up PHP's max_children to support more concurrent connections
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
pm.max_children=8

View File

@@ -19,9 +19,10 @@ source /etc/mailinabox.conf # load global vars
# and then we'll manually install roundcube from source.
# These dependencies are from `apt-cache showpkg roundcube-core`.
echo "Installing Roundcube (webmail)..."
apt_install \
dbconfig-common \
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-crypt-gpg php5-gd php5-pspell \
php5 php5-sqlite php5-mcrypt php5-intl php5-json php5-common php-auth php-net-smtp php-net-socket php-net-sieve php-mail-mime php-mail-mimedecode php-crypt-gpg php5-gd php5-pspell \
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
@@ -47,9 +48,8 @@ elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
fi
if [ $needs_update == 1 ]; then
# install roundcube
echo installing Roundcube webmail $VERSION...
wget_verify \
http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz \
https://mailinabox.email/mirror/roundcubemail-$VERSION.tar.gz \
$HASH \
/tmp/roundcube.tgz
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz

View File

@@ -15,6 +15,7 @@ source /etc/mailinabox.conf # load global vars
# Prereqs.
echo "Installing Z-Push (Exchange/ActiveSync server)..."
apt_install \
php-soap php5-imap libawl-php php5-xsl
@@ -30,7 +31,6 @@ elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
needs_update=1 #NODOC
fi
if [ $needs_update == 1 ]; then
echo installing z-push \(fmbiete fork\)...
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/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

View File

@@ -14,6 +14,8 @@
#
# NAME VALUE
#
# If the -c option is given, then the supplied character becomes the comment character
#
# If the -w option is given, then setting lines continue onto following
# lines while the lines start with whitespace, e.g.:
#
@@ -24,7 +26,7 @@ import sys, re
# sanity check
if len(sys.argv) < 3:
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-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

View File

@@ -120,10 +120,10 @@ 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", { "source": sys.argv[3], "destination": sys.argv[4] }))
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", { "source": sys.argv[3] }))
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
else:
print("Invalid command-line arguments.")

View File

@@ -25,6 +25,7 @@ for fn in glob.glob("/var/log/nginx/access.log*"):
with f:
for line in f:
# Find lines that are GETs on /bootstrap.sh by either curl or wget.
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /bootstrap.sh HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
if m:
date, time = m.group("date").decode("ascii").split(":", 1)