mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
407 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86881c0107 | ||
|
|
e65c77588e | ||
|
|
3843f63416 | ||
|
|
703e6795e8 | ||
|
|
b3223136f4 | ||
|
|
aa1fdaddaf | ||
|
|
7fa9baf308 | ||
|
|
d881487d68 | ||
|
|
33d07b2b54 | ||
|
|
f9ca440ce8 | ||
|
|
d880f088be | ||
|
|
5cabfd591b | ||
|
|
af80849857 | ||
|
|
7a191e67b8 | ||
|
|
4b2e48f2c0 | ||
|
|
eb545d7941 | ||
|
|
a2e6e81697 | ||
|
|
1b24e2cbaf | ||
|
|
0843159fb4 | ||
|
|
b8e99c30a2 | ||
|
|
3d933c16d0 | ||
|
|
e785886447 | ||
|
|
23ecff04b8 | ||
|
|
a0bae5db5c | ||
|
|
86368ed165 | ||
|
|
5e4c0ed825 | ||
|
|
ffa9dc5d67 | ||
|
|
43cb6c4995 | ||
|
|
36cb2ef41d | ||
|
|
098e250cc4 | ||
|
|
3d5a35b184 | ||
|
|
87d3f2641d | ||
|
|
c6c75c5a17 | ||
|
|
1ba44b02d4 | ||
|
|
6fd4cd85ca | ||
|
|
6182347641 | ||
|
|
401b0526a3 | ||
|
|
2f24328608 | ||
|
|
8ea42847da | ||
|
|
4ed23f44e6 | ||
|
|
178527dab1 | ||
|
|
f5c376dca8 | ||
|
|
239eac662c | ||
|
|
4e18f66db6 | ||
|
|
77937df955 | ||
|
|
4db8efa0df | ||
|
|
66c80bd16a | ||
|
|
5895aeecd7 | ||
|
|
83ffc99b9c | ||
|
|
3615772b2d | ||
|
|
78729bd277 | ||
|
|
85a9a1608c | ||
|
|
2e693f7011 | ||
|
|
6f0220da4b | ||
|
|
09a45b4397 | ||
|
|
62b6117638 | ||
|
|
70111dafbc | ||
|
|
faaa74c3a7 | ||
|
|
2ad7d0830e | ||
|
|
5045e206c2 | ||
|
|
07f9228694 | ||
|
|
50b5b91216 | ||
|
|
72bfc0915c | ||
|
|
a7d7a9adbd | ||
|
|
413af1fe67 | ||
|
|
b09cbb0ca4 | ||
|
|
5b135738b4 | ||
|
|
36e5772a8e | ||
|
|
2b9fb9643d | ||
|
|
2882e63dd8 | ||
|
|
812ef024ef | ||
|
|
40cdc5aa30 | ||
|
|
b8d6226a9a | ||
|
|
bac15d3919 | ||
|
|
4b4f670adf | ||
|
|
b1b57f9bfd | ||
|
|
b6933a73fa | ||
|
|
5033042b8c | ||
|
|
89a46089ee | ||
|
|
e288d7730b | ||
|
|
5ae75e723c | ||
|
|
d6d5009d23 | ||
|
|
06a0e7f3fe | ||
|
|
a9cd72bbf9 | ||
|
|
f184a74fa0 | ||
|
|
682b1dea5e | ||
|
|
3fbbf56986 | ||
|
|
2835d9b468 | ||
|
|
afd401c3d4 | ||
|
|
362bc060f6 | ||
|
|
c8fef45362 | ||
|
|
8d19eade85 | ||
|
|
d53332b7cf | ||
|
|
392d33b902 | ||
|
|
4305a71916 | ||
|
|
a4d8e12fd7 | ||
|
|
3cb5e109a3 | ||
|
|
e4a4b47fac | ||
|
|
8a35905d2e | ||
|
|
dbf4729109 | ||
|
|
86b9ef496c | ||
|
|
6336cc6452 | ||
|
|
bc79319864 | ||
|
|
62e88cff54 | ||
|
|
6e6c993724 | ||
|
|
f8b4e3775d | ||
|
|
fad69f85fa | ||
|
|
0029811de2 | ||
|
|
fe9ed3f70d | ||
|
|
aedfe62bb0 | ||
|
|
c4f00626ef | ||
|
|
fdad83a1bb | ||
|
|
20e11bbab3 | ||
|
|
5bbe9f9a04 | ||
|
|
7a93d219ef | ||
|
|
808522d895 | ||
|
|
be9efe0273 | ||
|
|
766b98c4ad | ||
|
|
c422543fdd | ||
|
|
cf33be4596 | ||
|
|
161d096139 | ||
|
|
b32cb6229b | ||
|
|
3dd5fff110 | ||
|
|
398a66dd4a | ||
|
|
59f8aa1c31 | ||
|
|
cfba97e104 | ||
|
|
bbf78716fd | ||
|
|
b9820641aa | ||
|
|
8c00556bab | ||
|
|
16d148a8a9 | ||
|
|
e8264e9b6a | ||
|
|
4f2b223070 | ||
|
|
05e128cafb | ||
|
|
59e9952a61 | ||
|
|
2f9fd09b2f | ||
|
|
ec20d657ba | ||
|
|
34ba279b0a | ||
|
|
c28065cc56 | ||
|
|
04960d0b98 | ||
|
|
280de022cb | ||
|
|
fa1cad7fb2 | ||
|
|
1926bfa1c5 | ||
|
|
2b351208e0 | ||
|
|
96b02e68ee | ||
|
|
82f4f8b2eb | ||
|
|
d703b0a2a1 | ||
|
|
2e3796c4f7 | ||
|
|
ac238b9d28 | ||
|
|
25e6fa53c2 | ||
|
|
57b4c685df | ||
|
|
3fd1279e7d | ||
|
|
3bc38c89ab | ||
|
|
4995cebc38 | ||
|
|
720157e8a3 | ||
|
|
8d9eb022d1 | ||
|
|
5e2eb51879 | ||
|
|
d0062b7de4 | ||
|
|
6307503cda | ||
|
|
9b1e04b1e8 | ||
|
|
24f1dbc0bb | ||
|
|
5d158c524d | ||
|
|
fd9287a0fd | ||
|
|
90836eff5b | ||
|
|
914cf68651 | ||
|
|
4db82d3d09 | ||
|
|
5055ef060d | ||
|
|
35088a7cac | ||
|
|
f046031b26 | ||
|
|
3b91bc2c0a | ||
|
|
4c4babd9e7 | ||
|
|
53dc53bf8f | ||
|
|
274e5ca676 | ||
|
|
5e7b7835b7 | ||
|
|
1bdfdbee89 | ||
|
|
ebffaab16a | ||
|
|
d6d4085809 | ||
|
|
2a44b0cafb | ||
|
|
834c42bc50 | ||
|
|
6c8ee1862a | ||
|
|
787beab63f | ||
|
|
58349a9410 | ||
|
|
93c2258d23 | ||
|
|
bd7a4dedc1 | ||
|
|
ae3ae0b5ba | ||
|
|
42d657eb54 | ||
|
|
d60d73b7e0 | ||
|
|
6704da1446 | ||
|
|
d24a2f7cab | ||
|
|
ed31002cc6 | ||
|
|
f8ac896795 | ||
|
|
3e96de26dd | ||
|
|
4f6fa40dbd | ||
|
|
104b804059 | ||
|
|
c545e46ebe | ||
|
|
52a216fbcb | ||
|
|
2c29d59895 | ||
|
|
de34d0d337 | ||
|
|
2bb7a6fc27 | ||
|
|
1b84292c56 | ||
|
|
18efae9703 | ||
|
|
4b6d86ef89 | ||
|
|
75a75a6f84 | ||
|
|
2e99589336 | ||
|
|
188b21dd36 | ||
|
|
0cf56e0aad | ||
|
|
bd7728ac94 | ||
|
|
b6f7a10569 | ||
|
|
53a9fc0e48 | ||
|
|
b05af6eecb | ||
|
|
571171a0c6 | ||
|
|
c5082498ab | ||
|
|
d19c215bf1 | ||
|
|
42dd46e305 | ||
|
|
a6496949f8 | ||
|
|
ab59323813 | ||
|
|
a56a9dc6a1 | ||
|
|
bc790ea581 | ||
|
|
dbfd158388 | ||
|
|
2b1f7da654 | ||
|
|
0c9d431a3f | ||
|
|
1a525df8ad | ||
|
|
ef1779ba80 | ||
|
|
d4e9938e3f | ||
|
|
f26c0b71d2 | ||
|
|
b2dfdc386a | ||
|
|
732a6922de | ||
|
|
9501a2209e | ||
|
|
8c83171a28 | ||
|
|
289936db7a | ||
|
|
6c71abbb09 | ||
|
|
841181ed55 | ||
|
|
c3821e9165 | ||
|
|
d9a4085efa | ||
|
|
6b35d8402c | ||
|
|
3b6f7250ee | ||
|
|
c6bf061b26 | ||
|
|
a8074ae3e4 | ||
|
|
cfc4e6b48b | ||
|
|
73fbcd7fa3 | ||
|
|
79317ed67e | ||
|
|
1b26632bb4 | ||
|
|
5b415c6895 | ||
|
|
078f3bff70 | ||
|
|
d02f800d1b | ||
|
|
83d3643e8f | ||
|
|
6d35ae56d3 | ||
|
|
39270a8e35 | ||
|
|
8c08f957cd | ||
|
|
5924d0fe0d | ||
|
|
c23a34d475 | ||
|
|
1bd3a725ca | ||
|
|
848dea83ab | ||
|
|
bded529535 | ||
|
|
ec665a97db | ||
|
|
fc536b6ff3 | ||
|
|
f96bef43cc | ||
|
|
f4e8ee0af9 | ||
|
|
c2e5f5cb2b | ||
|
|
9ca116d545 | ||
|
|
cdd3a64638 | ||
|
|
99e51f8a52 | ||
|
|
3b4b57c081 | ||
|
|
c7f8ead496 | ||
|
|
3f15879578 | ||
|
|
1cdd205eb7 | ||
|
|
77099b3bce | ||
|
|
0d8a4099c1 | ||
|
|
606cf6a941 | ||
|
|
ba9065cada | ||
|
|
e693802091 | ||
|
|
fa0dd684da | ||
|
|
43fb7fe635 | ||
|
|
91e4ea6e2f | ||
|
|
1e3e34f15f | ||
|
|
96fb0f78f7 | ||
|
|
2e6c410336 | ||
|
|
0293e04311 | ||
|
|
8c066764d9 | ||
|
|
1900e512f2 | ||
|
|
d0ccde7b48 | ||
|
|
1e261e347a | ||
|
|
2cb4cdc645 | ||
|
|
123ac4fd33 | ||
|
|
423bb8e317 | ||
|
|
e6ff280984 | ||
|
|
3fdfad27cd | ||
|
|
d3bbc0ec95 | ||
|
|
e54608c282 | ||
|
|
9b9a40ddd7 | ||
|
|
1b00184c89 | ||
|
|
e11825392d | ||
|
|
1a995d9e26 | ||
|
|
53d4820d74 | ||
|
|
40a5fa46d1 | ||
|
|
05e33edb0d | ||
|
|
76dba1a521 | ||
|
|
f7298a45bd | ||
|
|
3f606feea3 | ||
|
|
541d9252f6 | ||
|
|
cbbbb117e0 | ||
|
|
415f95b792 | ||
|
|
5f17abc856 | ||
|
|
a91995f0a7 | ||
|
|
216acb0eeb | ||
|
|
5dd5fc4a1c | ||
|
|
09133c8f59 | ||
|
|
d08a3095a9 | ||
|
|
593fd242bf | ||
|
|
e591d9082f | ||
|
|
b6f26c0f1e | ||
|
|
34b7638342 | ||
|
|
acd91665b5 | ||
|
|
b503ea1cf7 | ||
|
|
091c2e45bf | ||
|
|
0a78d1d2fa | ||
|
|
ff4780d5fb | ||
|
|
07beef3db2 | ||
|
|
0924f8ca7a | ||
|
|
6302aa6c12 | ||
|
|
da4d9ff607 | ||
|
|
dd0bdef640 | ||
|
|
e57e08088a | ||
|
|
5e43c394d5 | ||
|
|
b2eaaeca4b | ||
|
|
e2d9a523c3 | ||
|
|
11df1e4680 | ||
|
|
53d5542402 | ||
|
|
bfda3f40b9 | ||
|
|
c0ddceb2bd | ||
|
|
42a506231b | ||
|
|
e3252f53da | ||
|
|
aa33428311 | ||
|
|
ca5d228be6 | ||
|
|
f89a98c78a | ||
|
|
a3087d8815 | ||
|
|
23d2df7a93 | ||
|
|
1cd97d46a2 | ||
|
|
53f84a8092 | ||
|
|
6441de63ba | ||
|
|
b2553aea33 | ||
|
|
5ef1cfbdc7 | ||
|
|
7527b4dc27 | ||
|
|
1367816b04 | ||
|
|
299a2315c1 | ||
|
|
9a6aea6940 | ||
|
|
98cd04cccf | ||
|
|
0cc20cbb97 | ||
|
|
ef6a17d4a6 | ||
|
|
17a149947a | ||
|
|
a2c50ae967 | ||
|
|
13958ba4df | ||
|
|
8eb71483f3 | ||
|
|
d8e30883fa | ||
|
|
47acbbf332 | ||
|
|
dece359c90 | ||
|
|
6a9eb4e367 | ||
|
|
fc03ce9b2f | ||
|
|
ce17c12ca2 | ||
|
|
5edaeb8c7b | ||
|
|
3a28d1b073 | ||
|
|
6f2226bfcd | ||
|
|
97cd4c64ad | ||
|
|
43d50d0667 | ||
|
|
6258a7f311 | ||
|
|
ab36cc8968 | ||
|
|
33b71c6b3c | ||
|
|
34e821c102 | ||
|
|
2af557139d | ||
|
|
9e0dcd8718 | ||
|
|
be2b5a62de | ||
|
|
0cbba71c72 | ||
|
|
d28563fb45 | ||
|
|
38632f0f90 | ||
|
|
0754ce01b1 | ||
|
|
1ef455d37d | ||
|
|
d152603abd | ||
|
|
9e125aec00 | ||
|
|
2c90c267bd | ||
|
|
47de93961e | ||
|
|
1990f32ca4 | ||
|
|
807939c0e4 | ||
|
|
a1c7bf0883 | ||
|
|
5008cc603e | ||
|
|
9857db96cd | ||
|
|
e9e6d94e3b | ||
|
|
462a79cf47 | ||
|
|
f792deeebd | ||
|
|
95173bb327 | ||
|
|
1d09e2406b | ||
|
|
c9add7a8bf | ||
|
|
e4caed9277 | ||
|
|
1760eaa601 | ||
|
|
b25ce67fe1 | ||
|
|
b23ba6f75e | ||
|
|
cf904a05cc | ||
|
|
cac6a251cc | ||
|
|
31d26a7bad | ||
|
|
a9ed9ae936 | ||
|
|
a9892efe38 | ||
|
|
f02e0a3ccb | ||
|
|
32f5632620 | ||
|
|
005cc08b40 | ||
|
|
6a659fe10d | ||
|
|
6941ca2f63 | ||
|
|
e4eba49c1b | ||
|
|
f289439d1d | ||
|
|
f78cff225b |
261
CHANGELOG.md
261
CHANGELOG.md
@@ -1,6 +1,267 @@
|
|||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
v0.17c (April 1, 2016)
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
This update addresses some minor security concerns and some installation issues.
|
||||||
|
|
||||||
|
ownCoud:
|
||||||
|
|
||||||
|
* Block web access to the configuration parameters (config.php). There is no immediate impact (see [#776](https://github.com/mail-in-a-box/mailinabox/pull/776)), although advanced users may want to take note.
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Roundcube html5_notifier plugin updated from version 0.6 to 0.6.2 to fix Roundcube getting stuck for some people.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* Prevent click-jacking of the management interface by adding HTTP headers.
|
||||||
|
* Failed login no longer reveals whether an account exists on the system.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* Setup dialogs did not appear correctly when connecting to SSH using Putty on Windows.
|
||||||
|
* We now install Roundcube from our own mirror because Sourceforge's downloads experience frequent intermittant unavailability.
|
||||||
|
|
||||||
|
v0.17b (March 1, 2016)
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
ownCloud moved their source code to a new location, breaking our installation script.
|
||||||
|
|
||||||
|
v0.17 (February 25, 2016)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Roundcube updated to version 1.1.4.
|
||||||
|
* When there's a problem delivering an outgoing message, a new 'warning' bounce will come after 3 hours and the box will stop trying after 2 days (instead of 5).
|
||||||
|
* On multi-homed machines, Postfix now binds to the right network interface when sending outbound mail so that SPF checks on the receiving end will pass.
|
||||||
|
* Mail sent from addresses on subdomains of other domains hosted by this box would not be DKIM-signed and so would fail DMARC checks by recipients, since version v0.15.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* TLS certificate provisioning would crash if DNS propagation was in progress and a challenge failed; might have shown the wrong error when provisioning fails.
|
||||||
|
* Backup times were displayed with the wrong time zone.
|
||||||
|
* Thresholds for displaying messages when the system is running low on memory have been reduced from 30% to 20% for a warning and from 15% to 10% for an error.
|
||||||
|
* Other minor fixes.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* Backups to some AWS S3 regions broke in version 0.15 because we reverted the version of boto. That's now fixed.
|
||||||
|
* On low-usage systems, don't hold backups for quite so long by taking a full backup more often.
|
||||||
|
* Nightly status checks might fail on systems not configured with a default Unicode locale.
|
||||||
|
* If domains need a TLS certificate and the user hasn't installed one yet using Let's Encrypt, the administrator would get a nightly email with weird interactive text asking them to agree to Let's Encrypt's ToS. Now just say that the provisioning can't be done automatically.
|
||||||
|
* Reduce the number of background processes used by the management daemon to lower memory consumption.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
* The first screen now warns users not to install on a machine used for other things.
|
||||||
|
|
||||||
|
v0.16 (January 30, 2016)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
This update primarily adds automatic SSL (now "TLS") certificate provisioning from Let's Encrypt (https://letsencrypt.org/).
|
||||||
|
* The Sieve port is now open so tools like the Thunderbird Sieve program can be used to edit mail filters.
|
||||||
|
|
||||||
|
Control Panel:
|
||||||
|
|
||||||
|
* The SSL certificates (now referred to as "TLS ccertificates") page now supports provisioning free certificates from Let's Encrypt.
|
||||||
|
* Report free memory usage.
|
||||||
|
* Fix a crash when the git directory is not checked out to a tag.
|
||||||
|
* When IPv6 is enabled, check that all domains (besides the system hostname) resolve over IPv6.
|
||||||
|
* When a domain doesn't resolve to the box, don't bother checking if the TLS certificate is valid.
|
||||||
|
* Remove rounded border on the menu bar.
|
||||||
|
|
||||||
|
Other:
|
||||||
|
|
||||||
|
* The Sieve port is now open so tools like the Thunderbird Sieve extension can be used to edit mail filters.
|
||||||
|
* .be domains now offer DNSSEC options supported by the TLD
|
||||||
|
* The daily backup will now email the administrator if there is a problem.
|
||||||
|
* Expiring TLS certificates are now automatically renewed via Let's Encrypt.
|
||||||
|
* File ownership for installed Roundcube files is fixed.
|
||||||
|
* Typos fixed.
|
||||||
|
|
||||||
|
v0.15a (January 9, 2016)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Sending mail through Exchange/ActiveSync (Z-Push) had been broken since v0.14 in some setups. This is now fixed.
|
||||||
|
|
||||||
|
v0.15 (January 1, 2016)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Updated Roundcube to version 1.1.3.
|
||||||
|
* Auto-create aliases for abuse@, as required by RFC2142.
|
||||||
|
* The DANE TLSA record is changed to use the certificate subject public key rather than the whole certificate, which means the record remains valid after certificate changes (so long as the private key remains the same, which it does for us).
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* When IPv6 is enabled, check that system services are accessible over IPv6 too, that the box's hostname resolves over IPv6, and that reverse DNS is setup correctly for IPv6.
|
||||||
|
* Explanatory text for setting up secondary nameserver is added/fixed.
|
||||||
|
* DNS checks now have a timeout in case a DNS server is not responding, so the checks don't stall indefinitely.
|
||||||
|
* Better messages if external DNS is used and, weirdly, custom secondary nameservers are set.
|
||||||
|
* Add POP to the mail client settings documentation.
|
||||||
|
* The box's IP address is added to the fail2ban whitelist so that the status checks don't trigger the machine banning itself, which results in the status checks showing services down even though they are running.
|
||||||
|
* For SSL certificates, rather than asking you what country you are in during setup, ask at the time a CSR is generated. The default system self-signed certificate now omits a country in the subject (it was never needed). The CSR_COUNTRY Mail-in-a-Box setting is dropped entirely.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* Nightly backups and system status checks are now moved to 3am in the system's timezone.
|
||||||
|
* fail2ban's recidive jail is now active, which guards against persistent brute force login attacks over long periods of time.
|
||||||
|
* Setup (first run only) now asks for your timezone to set the system time.
|
||||||
|
* The Exchange/ActiveSync server is now taken offline during nightly backups (along with SMTP and IMAP).
|
||||||
|
* The machine's random number generator (/dev/urandom) is now seeded with Ubuntu Pollinate and a blocking read on /dev/random.
|
||||||
|
* DNSSEC key generation during install now uses /dev/urandom (instead of /dev/random), which is faster.
|
||||||
|
* The $STORAGE_ROOT/ssl directory is flattened by a migration script and the system SSL certificate path is now a symlink to the actual certificate.
|
||||||
|
* If ownCloud sends out email, it will use the box's administrative address now (admin@yourboxname).
|
||||||
|
* Z-Push (Exchange/ActiveSync) logs now exclude warnings and are now rotated to save disk space.
|
||||||
|
* Fix pip command that might have not installed all necessary Python packages.
|
||||||
|
* The control panel and backup would not work on Google Compute Engine because GCE installs a conflicting boto package.
|
||||||
|
* Added a new command `management/backup.py --restore` to restore files from a backup to a target directory (command line arguments are passed to `duplicity restore`).
|
||||||
|
|
||||||
|
v0.14 (November 4, 2015)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Mail:
|
||||||
|
|
||||||
|
* Spamassassin's network-based tests (Pyzor, others) and DKIM tests are now enabled. (Pyzor had always been installed but was not active due to a misconfiguration.)
|
||||||
|
* Moving spam out of the Spam folder and into Trash would incorrectly train Spamassassin that those messages were not spam.
|
||||||
|
* Automatically create the Sent and Archive folders for new users.
|
||||||
|
* The HTML5_Notifier plugin for Roundcube is now included, which when turned on in Roundcube settings provides desktop notifications for new mail.
|
||||||
|
* The Exchange/ActiveSync backend Z-Push has been updated to fix a problem with CC'd emails not being sent to the CC recipients.
|
||||||
|
|
||||||
|
Calender/Contacts:
|
||||||
|
|
||||||
|
* CalDAV/CardDAV and Exchange/ActiveSync for calendar/contacts wasn't working in some network configurations.
|
||||||
|
|
||||||
|
Web:
|
||||||
|
|
||||||
|
* When a new domain is added to the box, rather than applying a new self-signed certificate for that domain, the SSL certificate for the box's primary hostname will be used instead.
|
||||||
|
* If a custom DNS record is set on a domain or 'www'+domain, web would not be served for that domain. If the custom DNS record is just the box's IP address, that's a configuration mistake, but allow it and let web continue to be served.
|
||||||
|
* Accommodate really long domain names by increasing an nginx setting.
|
||||||
|
|
||||||
|
Control panel:
|
||||||
|
|
||||||
|
* Added an option to check for new Mail-in-a-Box versions within status checks. It is off by default so that boxes don't "phone home" without permission.
|
||||||
|
* Added a random password generator on the users page to simplify creating new accounts.
|
||||||
|
* When S3 backup credentials are set, the credentials are now no longer ever sent back from the box to the client, for better security.
|
||||||
|
* Fixed the jumpiness when a modal is displayed.
|
||||||
|
* Focus is put into the login form fields when the login form is displayed.
|
||||||
|
* Status checks now include a warning if a custom DNS record has been set on a domain that would normally serve web and as a result that domain no longer is serving web.
|
||||||
|
* Status checks now check that secondary nameservers, if specified, are actually serving the domains.
|
||||||
|
* Some errors in the control panel when there is invalid data in the database or an improperly named archived user account have been suppressed.
|
||||||
|
* Added subresource integrity attributes to all remotely-sourced resources (i.e. via CDNs) to guard against CDNs being used as an attack vector.
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
* Tweaks to fail2ban settings.
|
||||||
|
* Fixed a spurrious warning while installing munin.
|
||||||
|
|
||||||
|
v0.13b (August 30, 2015)
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Another ownCloud 8.1.1 issue was found. New installations left ownCloud improperly setup ("You are accessing the server from an untrusted domain."). Upgrading to this version will fix that.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
v0.10 (June 1, 2015)
|
v0.10 (June 1, 2015)
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -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.
|
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:
|
The components installed are:
|
||||||
|
|
||||||
@@ -32,35 +32,55 @@ The components installed are:
|
|||||||
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
|
* 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/))
|
* 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
|
* 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))
|
* 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/))
|
||||||
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and system monitoring.
|
|
||||||
|
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 (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).
|
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.
|
Clone this repository:
|
||||||
$ curl -s https://keybase.io/joshdata/key.asc | gpg --import
|
|
||||||
gpg: key C10BDD81: public key "Joshua Tauberer <jt@occams.info>" imported
|
|
||||||
|
|
||||||
# Clone this repository.
|
|
||||||
$ git clone https://github.com/mail-in-a-box/mailinabox
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
$ cd mailinabox
|
$ cd mailinabox
|
||||||
|
|
||||||
# Verify the tag.
|
_Optional:_ Download my PGP key and then verify that the sources were signed
|
||||||
$ git verify-tag v0.10
|
by me:
|
||||||
|
|
||||||
|
$ 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.17c
|
||||||
gpg: Signature made ..... using RSA key ID C10BDD81
|
gpg: Signature made ..... using RSA key ID C10BDD81
|
||||||
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
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
|
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/).
|
You'll get a lot of warnings, but that's OK. Check that the primary key fingerprint matches the
|
||||||
|
fingerprint in the key details at [https://keybase.io/joshdata](https://keybase.io/joshdata)
|
||||||
|
and on my [personal homepage](https://razor.occams.info/). (Of course, if this repository has been compromised you can't trust these instructions.)
|
||||||
|
|
||||||
|
Checkout the tag corresponding to the most recent release:
|
||||||
|
|
||||||
|
$ git checkout v0.17c
|
||||||
|
|
||||||
|
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
|
The Acknowledgements
|
||||||
--------------------
|
--------------------
|
||||||
@@ -76,3 +96,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.
|
* 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 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.
|
* 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.
|
||||||
|
|||||||
1
Vagrantfile
vendored
1
Vagrantfile
vendored
@@ -22,7 +22,6 @@ Vagrant.configure("2") do |config|
|
|||||||
export PUBLIC_IP=auto
|
export PUBLIC_IP=auto
|
||||||
export PUBLIC_IPV6=auto
|
export PUBLIC_IPV6=auto
|
||||||
export PRIMARY_HOSTNAME=auto-easy
|
export PRIMARY_HOSTNAME=auto-easy
|
||||||
export CSR_COUNTRY=US
|
|
||||||
#export SKIP_NETWORK_CHECKS=1
|
#export SKIP_NETWORK_CHECKS=1
|
||||||
|
|
||||||
# Start the setup script.
|
# Start the setup script.
|
||||||
|
|||||||
63
conf/dovecot-mailboxes.conf
Normal file
63
conf/dovecot-mailboxes.conf
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
## NOTE: This file is automatically generated by Mail-in-a-Box.
|
||||||
|
## Do not edit this file. It is continually updated by
|
||||||
|
## Mail-in-a-Box and your changes will be lost.
|
||||||
|
##
|
||||||
|
## Mail-in-a-Box machines are not meant to be modified.
|
||||||
|
## If you modify any system configuration you are on
|
||||||
|
## your own --- please do not ask for help from us.
|
||||||
|
|
||||||
|
namespace inbox {
|
||||||
|
# Automatically create & subscribe some folders.
|
||||||
|
# * Create and subscribe the INBOX folder.
|
||||||
|
# * Our sieve rule for spam expects that the Spam folder exists.
|
||||||
|
# * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580).
|
||||||
|
|
||||||
|
# MUA notes:
|
||||||
|
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
|
||||||
|
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist.
|
||||||
|
# * Apple's OS X Mail app will create 'Sent Messages' if it doesn't see a folder with the \Sent flag (#571, #573) and won't be able to archive messages unless 'Archive' exists (#581).
|
||||||
|
# * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581).
|
||||||
|
|
||||||
|
# auto:
|
||||||
|
# 'create' will automatically create this mailbox.
|
||||||
|
# 'subscribe' will both create and subscribe to the mailbox.
|
||||||
|
|
||||||
|
# special_use is a space separated list of IMAP SPECIAL-USE
|
||||||
|
# attributes as specified by RFC 6154:
|
||||||
|
# \All \Archive \Drafts \Flagged \Junk \Sent \Trash
|
||||||
|
|
||||||
|
mailbox INBOX {
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Spam {
|
||||||
|
special_use = \Junk
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Drafts {
|
||||||
|
special_use = \Drafts
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Sent {
|
||||||
|
special_use = \Sent
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Trash {
|
||||||
|
special_use = \Trash
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
mailbox Archive {
|
||||||
|
special_use = \Archive
|
||||||
|
auto = subscribe
|
||||||
|
}
|
||||||
|
|
||||||
|
# dovevot's standard mailboxes configuration file marks two sent folders
|
||||||
|
# with the \Sent attribute, just in case clients don't agree about which
|
||||||
|
# they're using. We'll keep that, plus add Junk as an alterative for Spam.
|
||||||
|
# These are not auto-created.
|
||||||
|
mailbox "Sent Messages" {
|
||||||
|
special_use = \Sent
|
||||||
|
}
|
||||||
|
mailbox Junk {
|
||||||
|
special_use = \Junk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
# Fail2Ban configuration file.
|
# Fail2Ban configuration file for Mail-in-a-Box
|
||||||
# For Mail-in-a-Box
|
|
||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
|
# Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks
|
||||||
|
# ping services over the public interface so we should whitelist that address of
|
||||||
|
# ours too. The string is substituted during installation.
|
||||||
|
ignoreip = 127.0.0.1/8 PUBLIC_IP
|
||||||
|
|
||||||
# bantime in seconds
|
|
||||||
bantime = 60
|
|
||||||
|
|
||||||
# This should ban dumb brute-force attacks, not oblivious users.
|
|
||||||
findtime = 30
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
#
|
|
||||||
# JAILS
|
# JAILS
|
||||||
#
|
|
||||||
|
|
||||||
[ssh]
|
[ssh]
|
||||||
|
maxretry = 7
|
||||||
enabled = true
|
bantime = 3600
|
||||||
logpath = /var/log/auth.log
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
[ssh-ddos]
|
[ssh-ddos]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
[sasl]
|
[sasl]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
[dovecot]
|
[dovecot]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
filter = dovecotimap
|
filter = dovecotimap
|
||||||
|
findtime = 30
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
[recidive]
|
||||||
|
enabled = true
|
||||||
|
maxretry = 10
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ EXEC_AS_USER=root
|
|||||||
|
|
||||||
# Ensure Python reads/writes files in UTF-8. If the machine
|
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||||
# triggers some other locale in Python, like ASCII encoding,
|
# triggers some other locale in Python, like ASCII encoding,
|
||||||
# Python may not be able to read/write files. Here and in
|
# Python may not be able to read/write files. Set also
|
||||||
# setup/start.sh (where the locale is also installed if not
|
# setup/start.sh (where the locale is also installed if not
|
||||||
# already present).
|
# already present) and management/daily_tasks.sh.
|
||||||
export LANGUAGE=en_US.UTF-8
|
export LANGUAGE=en_US.UTF-8
|
||||||
export LC_ALL=en_US.UTF-8
|
export LC_ALL=en_US.UTF-8
|
||||||
export LANG=en_US.UTF-8
|
export LANG=en_US.UTF-8
|
||||||
|
|||||||
79
conf/nginx-alldomains.conf
Normal file
79
conf/nginx-alldomains.conf
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Expose this directory as static files.
|
||||||
|
root $ROOT;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location = /robots.txt {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /favicon.ico {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /mailinabox.mobileconfig {
|
||||||
|
alias /var/lib/mailinabox/mobileconfig.xml;
|
||||||
|
}
|
||||||
|
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
||||||
|
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roundcube Webmail configuration.
|
||||||
|
rewrite ^/mail$ /mail/ redirect;
|
||||||
|
rewrite ^/mail/$ /mail/index.php;
|
||||||
|
location /mail/ {
|
||||||
|
index index.php;
|
||||||
|
alias /usr/local/lib/roundcubemail/;
|
||||||
|
}
|
||||||
|
location ~ /mail/config/.* {
|
||||||
|
# A ~-style location is needed to give this precedence over the next block.
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
location ~ /mail/.*\.php {
|
||||||
|
# note: ~ has precendence over a regular location block
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_split_path_info ^/mail(/.*)()$;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
|
||||||
|
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||||
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
|
client_max_body_size 128M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Z-Push (Microsoft Exchange ActiveSync)
|
||||||
|
location /Microsoft-Server-ActiveSync {
|
||||||
|
include /etc/nginx/fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
|
||||||
|
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||||
|
fastcgi_read_timeout 630;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
|
||||||
|
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||||
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
|
client_max_body_size 128M;
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ADDITIONAL DIRECTIVES HERE
|
||||||
|
|
||||||
|
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
|
||||||
|
# This block is placed at the end. Nginx's precedence rules means this block
|
||||||
|
# takes precedence over all non-regex matches and only regex matches that
|
||||||
|
# come after it (i.e. none of those, since this is the last one.) That means
|
||||||
|
# we're blocking dotfiles in the static hosted sites but not the FastCGI-
|
||||||
|
# handled locations for ownCloud (which serves user-uploaded files that might
|
||||||
|
# have this pattern, see #414) or some of the other services.
|
||||||
|
location ~ /\.(ht|svn|git|hg|bzr) {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@
|
|||||||
# Proxy /admin to our Python based control panel daemon. It is
|
# Proxy /admin to our Python based control panel daemon. It is
|
||||||
# listening on IPv4 only so use an IP address and not 'localhost'.
|
# listening on IPv4 only so use an IP address and not 'localhost'.
|
||||||
rewrite ^/admin$ /admin/;
|
rewrite ^/admin$ /admin/;
|
||||||
|
rewrite ^/admin/munin$ /admin/munin/ redirect;
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
proxy_pass http://127.0.0.1:10222/;
|
proxy_pass http://127.0.0.1:10222/;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
add_header X-Frame-Options "DENY";
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'none';";
|
||||||
}
|
}
|
||||||
|
|
||||||
# ownCloud configuration.
|
# ownCloud configuration.
|
||||||
@@ -14,7 +18,10 @@
|
|||||||
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
||||||
location /cloud/ {
|
location /cloud/ {
|
||||||
alias /usr/local/lib/owncloud/;
|
alias /usr/local/lib/owncloud/;
|
||||||
location ~ ^/(data|config|\.ht|db_structure\.xml|README) {
|
location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,8 +36,8 @@
|
|||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
||||||
fastcgi_param SCRIPT_NAME $1$2;
|
fastcgi_param SCRIPT_NAME $1$2;
|
||||||
fastcgi_param PATH_INFO $3;
|
fastcgi_param PATH_INFO $3;
|
||||||
# TODO: see the dispreferred "method 2" for xaccel at https://doc.owncloud.org/server/8.1/admin_manual/configuration_files/serving_static_files_configuration.html
|
|
||||||
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
||||||
|
fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel;
|
||||||
fastcgi_read_timeout 630;
|
fastcgi_read_timeout 630;
|
||||||
fastcgi_pass php-fpm;
|
fastcgi_pass php-fpm;
|
||||||
error_page 403 /cloud/core/templates/403.php;
|
error_page 403 /cloud/core/templates/403.php;
|
||||||
@@ -38,23 +45,22 @@
|
|||||||
client_max_body_size 1G;
|
client_max_body_size 1G;
|
||||||
fastcgi_buffers 64 4K;
|
fastcgi_buffers 64 4K;
|
||||||
}
|
}
|
||||||
location ^~ $STORAGE_ROOT/owncloud {
|
location ^~ /owncloud-xaccel/ {
|
||||||
# This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. It works a little weird.
|
# This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. ownCloud sends the full file
|
||||||
# The full path on disk of the file is passed as the URL path. ownCloud 8 totally
|
# path on disk as a subdirectory under this virtual path.
|
||||||
# busted the sane way this worked in ownCloud 7. There's a pending change using
|
# We must only allow 'internal' redirects within nginx so that the filesystem
|
||||||
# a new parameter to make this make more sense.
|
|
||||||
# We need to only allow 'internal' redirects within nginx so that the filesystem
|
|
||||||
# is not exposed to the world.
|
# is not exposed to the world.
|
||||||
internal;
|
internal;
|
||||||
alias $STORAGE_ROOT/owncloud;
|
alias /;
|
||||||
}
|
}
|
||||||
location ~ ^/((caldav|carddav|webdav).*)$ {
|
location ~ ^/((caldav|carddav|webdav).*)$ {
|
||||||
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
||||||
# Properly proxying like this seems to work fine.
|
# Properly proxying like this seems to work fine.
|
||||||
proxy_pass https://$HOSTNAME/cloud/remote.php/$1;
|
proxy_pass https://127.0.0.1/cloud/remote.php/$1;
|
||||||
}
|
}
|
||||||
rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
|
rewrite ^/.well-known/host-meta /cloud/public.php?service=host-meta last;
|
||||||
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
|
rewrite ^/.well-known/host-meta.json /cloud/public.php?service=host-meta-json last;
|
||||||
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
|
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
|
||||||
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
|
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
|
||||||
|
|
||||||
|
# ADDITIONAL DIRECTIVES HERE
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
#ssl_certificate_key /path/to/my-private-decrypted.key;
|
#ssl_certificate_key /path/to/my-private-decrypted.key;
|
||||||
|
|
||||||
# Tell browsers to require SSL (warning: difficult to change your mind)
|
# Tell browsers to require SSL (warning: difficult to change your mind)
|
||||||
add_header Strict-Transport-Security max-age=31536000;
|
# Handled by the management daemon because we can toggle this version or a
|
||||||
|
# preload version.
|
||||||
|
#add_header Strict-Transport-Security max-age=31536000;
|
||||||
|
|
||||||
# Prefer certain ciphersuites, to enforce Forward Secrecy and avoid known vulnerabilities.
|
# Prefer certain ciphersuites, to enforce Forward Secrecy and avoid known vulnerabilities.
|
||||||
#
|
#
|
||||||
@@ -36,7 +38,7 @@ ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
|
|||||||
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
# as recommended by http://nginx.org/en/docs/http/configuring_https_servers.html
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
keepalive_timeout 70;
|
#keepalive_timeout 70; # in Ubuntu 14.04/nginx 1.4.6 the default is 65, so plenty good
|
||||||
|
|
||||||
# Buffer size of 1400 bytes fits in one MTU.
|
# Buffer size of 1400 bytes fits in one MTU.
|
||||||
# nginx 1.5.9+ ONLY
|
# nginx 1.5.9+ ONLY
|
||||||
@@ -70,6 +72,5 @@ ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
|||||||
# nginx will use them to talk to the CA.
|
# nginx will use them to talk to the CA.
|
||||||
ssl_stapling on;
|
ssl_stapling on;
|
||||||
ssl_stapling_verify on;
|
ssl_stapling_verify on;
|
||||||
resolver 8.8.8.8 8.8.4.4 valid=86400;
|
resolver 127.0.0.1 valid=86400;
|
||||||
resolver_timeout 10;
|
resolver_timeout 10;
|
||||||
#ssl_trusted_certificate /path/to/all-certs-in-chain.crt;
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## $HOSTNAME
|
## $HOSTNAME
|
||||||
|
|
||||||
# Redirect all HTTP to HTTPS.
|
# Redirect all HTTP to HTTPS *except* the ACME challenges (Let's Encrypt TLS certificate
|
||||||
|
# domain validation challenges) path, which must be served over HTTP per the ACME spec
|
||||||
|
# (due to some Apache vulnerability).
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
@@ -12,10 +14,19 @@ server {
|
|||||||
# error pages and in the "Server" HTTP-Header.
|
# error pages and in the "Server" HTTP-Header.
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
|
location / {
|
||||||
# Redirect using the 'return' directive and the built-in
|
# Redirect using the 'return' directive and the built-in
|
||||||
# variable '$request_uri' to avoid any capturing, matching
|
# variable '$request_uri' to avoid any capturing, matching
|
||||||
# or evaluation of regular expressions.
|
# or evaluation of regular expressions.
|
||||||
return 301 https://$HOSTNAME$request_uri;
|
return 301 https://$HOSTNAME$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
# This path must be served over HTTP for ACME domain validation.
|
||||||
|
# We map this to a special path where our TLS cert provisioning
|
||||||
|
# tool knows to store challenge response files.
|
||||||
|
alias $STORAGE_ROOT/ssl/lets_encrypt/acme_challenges/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# The secure HTTPS server.
|
# The secure HTTPS server.
|
||||||
@@ -31,86 +42,6 @@ server {
|
|||||||
|
|
||||||
ssl_certificate $SSL_CERTIFICATE;
|
ssl_certificate $SSL_CERTIFICATE;
|
||||||
ssl_certificate_key $SSL_KEY;
|
ssl_certificate_key $SSL_KEY;
|
||||||
include /etc/nginx/nginx-ssl.conf;
|
|
||||||
|
|
||||||
# Expose this directory as static files.
|
|
||||||
root $ROOT;
|
|
||||||
index index.html index.htm;
|
|
||||||
|
|
||||||
location = /robots.txt {
|
|
||||||
log_not_found off;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /favicon.ico {
|
|
||||||
log_not_found off;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /mailinabox.mobileconfig {
|
|
||||||
alias /var/lib/mailinabox/mobileconfig.xml;
|
|
||||||
}
|
|
||||||
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
|
||||||
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Roundcube Webmail configuration.
|
|
||||||
rewrite ^/mail$ /mail/ redirect;
|
|
||||||
rewrite ^/mail/$ /mail/index.php;
|
|
||||||
location /mail/ {
|
|
||||||
index index.php;
|
|
||||||
alias /usr/local/lib/roundcubemail/;
|
|
||||||
}
|
|
||||||
location ~ /mail/config/.* {
|
|
||||||
# A ~-style location is needed to give this precedence over the next block.
|
|
||||||
return 403;
|
|
||||||
}
|
|
||||||
location ~ /mail/.*\.php {
|
|
||||||
# note: ~ has precendence over a regular location block
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_split_path_info ^/mail(/.*)()$;
|
|
||||||
fastcgi_index index.php;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
|
||||||
fastcgi_pass php-fpm;
|
|
||||||
|
|
||||||
# Outgoing mail also goes through this endpoint, so increase the maximum
|
|
||||||
# file upload limit to match the corresponding Postfix limit.
|
|
||||||
client_max_body_size 128M;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Z-Push (Microsoft Exchange ActiveSync)
|
|
||||||
location /Microsoft-Server-ActiveSync {
|
|
||||||
include /etc/nginx/fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
|
|
||||||
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
|
||||||
fastcgi_read_timeout 630;
|
|
||||||
fastcgi_pass php-fpm;
|
|
||||||
|
|
||||||
# Outgoing mail also goes through this endpoint, so increase the maximum
|
|
||||||
# file upload limit to match the corresponding Postfix limit.
|
|
||||||
client_max_body_size 128M;
|
|
||||||
}
|
|
||||||
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";
|
|
||||||
fastcgi_pass php-fpm;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ADDITIONAL DIRECTIVES HERE
|
# ADDITIONAL DIRECTIVES HERE
|
||||||
|
|
||||||
# Disable viewing dotfiles (.htaccess, .svn, .git, etc.)
|
|
||||||
# This block is placed at the end. Nginx's precedence rules means this block
|
|
||||||
# takes precedence over all non-regex matches and only regex matches that
|
|
||||||
# come after it (i.e. none of those, since this is the last one.) That means
|
|
||||||
# we're blocking dotfiles in the static hosted sites but not the FastCGI-
|
|
||||||
# handled locations for ownCloud (which serves user-uploaded files that might
|
|
||||||
# have this pattern, see #414) or some of the other services.
|
|
||||||
location ~ /\.(ht|svn|git|hg|bzr) {
|
|
||||||
log_not_found off;
|
|
||||||
access_log off;
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
|
# 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
|
# 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.
|
# 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.
|
# Remove other typically private information.
|
||||||
/^\s*User-Agent:/ IGNORE
|
/^\s*User-Agent:/ IGNORE
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
* Descr : CalDAV backend configuration file
|
* Descr : CalDAV backend configuration file
|
||||||
************************************************/
|
************************************************/
|
||||||
|
|
||||||
define('CALDAV_SERVER', 'https://localhost');
|
define('CALDAV_PROTOCOL', 'https');
|
||||||
|
define('CALDAV_SERVER', 'localhost');
|
||||||
define('CALDAV_PORT', '443');
|
define('CALDAV_PORT', '443');
|
||||||
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
define('CALDAV_PATH', '/caldav/calendars/%u/');
|
||||||
define('CALDAV_PERSONAL', '');
|
define('CALDAV_PERSONAL', 'PRINCIPAL');
|
||||||
define('CALDAV_SUPPORTS_SYNC', true);
|
define('CALDAV_SUPPORTS_SYNC', false);
|
||||||
|
define('CALDAV_MAX_SYNC_PERIOD', 2147483647);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ define('IMAP_PORT', 993);
|
|||||||
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
||||||
define('IMAP_DEFAULTFROM', '');
|
define('IMAP_DEFAULTFROM', '');
|
||||||
|
|
||||||
|
define('SYSTEM_MIME_TYPES_MAPPING', '/etc/mime.types');
|
||||||
|
define('IMAP_AUTOSEEN_ON_DELETE', false);
|
||||||
|
define('IMAP_FOLDER_CONFIGURED', true);
|
||||||
|
define('IMAP_FOLDER_PREFIX', '');
|
||||||
|
define('IMAP_FOLDER_PREFIX_IN_INBOX', false);
|
||||||
|
// see our conf/dovecot-mailboxes.conf file for IMAP special flags settings
|
||||||
|
define('IMAP_FOLDER_INBOX', 'INBOX');
|
||||||
|
define('IMAP_FOLDER_SENT', 'SENT');
|
||||||
|
define('IMAP_FOLDER_DRAFT', 'DRAFTS');
|
||||||
|
define('IMAP_FOLDER_TRASH', 'TRASH');
|
||||||
|
define('IMAP_FOLDER_SPAM', 'SPAM');
|
||||||
|
define('IMAP_FOLDER_ARCHIVE', 'ARCHIVE');
|
||||||
|
|
||||||
|
|
||||||
// not used
|
// not used
|
||||||
define('IMAP_FROM_SQL_DSN', '');
|
define('IMAP_FROM_SQL_DSN', '');
|
||||||
define('IMAP_FROM_SQL_USER', '');
|
define('IMAP_FROM_SQL_USER', '');
|
||||||
@@ -27,11 +41,6 @@ define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)');
|
|||||||
define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));
|
define('IMAP_FROM_LDAP_FIELDS', serialize(array('givenname', 'sn', 'mail')));
|
||||||
define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
|
define('IMAP_FROM_LDAP_FROM', '#givenname #sn <#mail>');
|
||||||
|
|
||||||
|
|
||||||
// copy outgoing mail to this folder. If not set z-push will try the default folders
|
|
||||||
define('IMAP_SENTFOLDER', '');
|
|
||||||
define('IMAP_INLINE_FORWARD', true);
|
|
||||||
define('IMAP_EXCLUDED_FOLDERS', '');
|
|
||||||
define('IMAP_SMTP_METHOD', 'sendmail');
|
define('IMAP_SMTP_METHOD', 'sendmail');
|
||||||
|
|
||||||
global $imap_smtp_params;
|
global $imap_smtp_params;
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ class KeyAuthService:
|
|||||||
if email == "" or pw == "":
|
if email == "" or pw == "":
|
||||||
raise ValueError("Enter an email address and password.")
|
raise ValueError("Enter an email address and password.")
|
||||||
|
|
||||||
# The password might be a user-specific API key.
|
# The password might be a user-specific API key. create_user_key raises
|
||||||
if hmac.compare_digest(self.create_user_key(email), pw):
|
# a ValueError if the user does not exist.
|
||||||
|
if hmac.compare_digest(self.create_user_key(email, env), pw):
|
||||||
# OK.
|
# OK.
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -111,18 +112,26 @@ class KeyAuthService:
|
|||||||
# Login failed.
|
# Login failed.
|
||||||
raise ValueError("Invalid password.")
|
raise ValueError("Invalid password.")
|
||||||
|
|
||||||
# Get privileges for authorization. This call should never fail on a valid user,
|
# Get privileges for authorization. This call should never fail because by this
|
||||||
# but if the caller passed a user-specific API key then the user may no longer
|
# point we know the email address is a valid user. But on error the call will
|
||||||
# exist --- in that case, get_mail_user_privileges will return a tuple of an
|
# return a tuple of an error message and an HTTP status code.
|
||||||
# error message and an HTTP status code.
|
|
||||||
privs = get_mail_user_privileges(email, env)
|
privs = get_mail_user_privileges(email, env)
|
||||||
if isinstance(privs, tuple): raise ValueError(privs[0])
|
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||||
|
|
||||||
# Return a list of privileges.
|
# Return a list of privileges.
|
||||||
return privs
|
return privs
|
||||||
|
|
||||||
def create_user_key(self, email):
|
def create_user_key(self, email, env):
|
||||||
return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest()
|
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
|
||||||
|
# email address & hashed password and the key will be the master API key. The user of
|
||||||
|
# course has their own email address and password. We assume they do not have the master
|
||||||
|
# API key (unless they are trusted anyway). The HMAC proves that they authenticated
|
||||||
|
# with us in some other way to get the HMAC. Including the password means that when
|
||||||
|
# a user's password is reset, the HMAC changes and they will correctly need to log
|
||||||
|
# in to the control panel again. This method raises a ValueError if the user does
|
||||||
|
# not exist, due to get_mail_password.
|
||||||
|
msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
|
||||||
|
return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest()
|
||||||
|
|
||||||
def _generate_key(self):
|
def _generate_key(self):
|
||||||
raw_key = os.urandom(32)
|
raw_key = os.urandom(32)
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
# This script performs a backup of all user data:
|
# This script performs a backup of all user data:
|
||||||
# 1) System services are stopped while a copy of user data is made.
|
# 1) System services are stopped.
|
||||||
# 2) An incremental encrypted backup is made using duplicity into the
|
# 2) An incremental encrypted backup is made using duplicity.
|
||||||
# directory STORAGE_ROOT/backup/encrypted. The password used for
|
|
||||||
# encryption is stored in backup/secret_key.txt.
|
|
||||||
# 3) The stopped services are restarted.
|
# 3) The stopped services are restarted.
|
||||||
# 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
# 4) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
||||||
|
|
||||||
import os, os.path, shutil, glob, re, datetime
|
import os, os.path, shutil, glob, re, datetime, sys
|
||||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||||
|
import rtyaml
|
||||||
|
|
||||||
from utils import exclusive_process, load_environment, shell, wait_for_service
|
from utils import exclusive_process, load_environment, shell, wait_for_service, fix_boto
|
||||||
|
|
||||||
# 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):
|
def backup_status(env):
|
||||||
# What is the current status of backups?
|
# Root folder
|
||||||
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
# get a list of all of the backups taken and sum up file sizes to
|
|
||||||
# see how large the storage is.
|
|
||||||
|
|
||||||
|
# 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())
|
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):
|
def reldate(date, ref, clip):
|
||||||
if ref < date: return clip
|
if ref < date: return clip
|
||||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||||
@@ -34,78 +39,108 @@ def backup_status(env):
|
|||||||
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||||
|
|
||||||
backups = { }
|
# Get duplicity collection status and parse for a list of backups.
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
def parse_line(line):
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
keys = line.strip().split()
|
||||||
os.makedirs(backup_dir, exist_ok=True) # os.listdir fails if directory does not exist
|
date = dateutil.parser.parse(keys[1]).astimezone(dateutil.tz.tzlocal())
|
||||||
for fn in os.listdir(backup_dir):
|
return {
|
||||||
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)
|
"date": keys[1],
|
||||||
if not m: raise ValueError(fn)
|
"date_str": date.strftime("%x %X") + " " + now.tzname(),
|
||||||
|
|
||||||
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?"),
|
"date_delta": reldate(date, now, "the future?"),
|
||||||
"full": m.group("incbase") is None,
|
"full": keys[0] == "full",
|
||||||
"previous": m.group("incbase"),
|
"size": 0, # collection-status doesn't give us the size
|
||||||
"size": 0,
|
"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 or the last backup job terminated unexpectedly.
|
||||||
|
raise Exception("Something is wrong with the backup: " + collection_status)
|
||||||
|
for line in collection_status.split('\n'):
|
||||||
|
if line.startswith(" full") or line.startswith(" inc"):
|
||||||
|
backup = parse_line(line)
|
||||||
|
backups[backup["date"]] = backup
|
||||||
|
|
||||||
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
|
# 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: continue # not a part of a current backup chain
|
||||||
|
key = m.group("date")
|
||||||
|
backups[key]["size"] += size
|
||||||
|
|
||||||
# Ensure the rows are sorted reverse chronologically.
|
# Ensure the rows are sorted reverse chronologically.
|
||||||
# This is relied on by should_force_full() and the next step.
|
# This is relied on by should_force_full() and the next step.
|
||||||
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
||||||
|
|
||||||
# Get the average size of incremental backups and the size of the
|
# Get the average size of incremental backups, the size of the
|
||||||
# most recent full backup.
|
# most recent full backup, and the date of the most recent
|
||||||
|
# backup and the most recent full backup.
|
||||||
incremental_count = 0
|
incremental_count = 0
|
||||||
incremental_size = 0
|
incremental_size = 0
|
||||||
|
first_date = None
|
||||||
first_full_size = None
|
first_full_size = None
|
||||||
|
first_full_date = None
|
||||||
for bak in backups:
|
for bak in backups:
|
||||||
|
if first_date is None:
|
||||||
|
first_date = dateutil.parser.parse(bak["date"])
|
||||||
if bak["full"]:
|
if bak["full"]:
|
||||||
first_full_size = bak["size"]
|
first_full_size = bak["size"]
|
||||||
|
first_full_date = dateutil.parser.parse(bak["date"])
|
||||||
break
|
break
|
||||||
incremental_count += 1
|
incremental_count += 1
|
||||||
incremental_size += bak["size"]
|
incremental_size += bak["size"]
|
||||||
|
|
||||||
# Predict how many more increments until the next full backup,
|
# When will the most recent backup be deleted? It won't be deleted if the next
|
||||||
# and add to that the time we hold onto backups, to predict
|
# backup is incremental, because the increments rely on all past increments.
|
||||||
# how long the most recent full backup+increments will be held
|
# So first guess how many more incremental backups will occur until the next
|
||||||
# onto. Round up since the backup occurs on the night following
|
# full backup. That full backup frees up this one to be deleted. But, the backup
|
||||||
# when the threshold is met.
|
# must also be at least min_age_in_days old too.
|
||||||
deleted_in = None
|
deleted_in = None
|
||||||
if incremental_count > 0 and first_full_size is not 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)
|
# How many days until the next incremental backup? First, the part of
|
||||||
|
# the algorithm based on increment sizes:
|
||||||
|
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count)
|
||||||
|
est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full)
|
||||||
|
|
||||||
# When will a backup be deleted?
|
# ...And then the part of the algorithm based on full backup age:
|
||||||
|
est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1))
|
||||||
|
|
||||||
|
# It still can't be deleted until it's old enough.
|
||||||
|
est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"]))
|
||||||
|
|
||||||
|
deleted_in = "approx. %d days" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5)
|
||||||
|
|
||||||
|
# When will a backup be deleted? Set the deleted_in field of each backup.
|
||||||
saw_full = False
|
saw_full = False
|
||||||
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
|
||||||
for bak in backups:
|
for bak in backups:
|
||||||
if deleted_in:
|
if deleted_in:
|
||||||
# Subsequent backups are deleted when the most recent increment
|
# The most recent increment in a chain and all of the previous backups
|
||||||
# in the chain would be deleted.
|
# it relies on are deleted at the same time.
|
||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
if bak["full"]:
|
if bak["full"]:
|
||||||
# Reset when we get to a full backup. A new chain start next.
|
# Reset when we get to a full backup. A new chain start *next*.
|
||||||
saw_full = True
|
saw_full = True
|
||||||
deleted_in = None
|
deleted_in = None
|
||||||
elif saw_full and not deleted_in:
|
elif saw_full and not deleted_in:
|
||||||
# Mark deleted_in only on the first increment after a full backup.
|
# We're now on backups prior to the most recent full backup. These are
|
||||||
deleted_in = reldate(days_ago, dateutil.parser.parse(bak["date"]), "on next daily backup")
|
# free to be deleted as soon as they are min_age_in_days old.
|
||||||
|
deleted_in = reldate(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup")
|
||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"directory": backup_dir,
|
|
||||||
"encpwfile": os.path.join(backup_root, 'secret_key.txt'),
|
|
||||||
"tz": now.tzname(),
|
|
||||||
"backups": backups,
|
"backups": backups,
|
||||||
}
|
}
|
||||||
|
|
||||||
def should_force_full(env):
|
def should_force_full(config, env):
|
||||||
# Force a full backup when the total size of the increments
|
# Force a full backup when the total size of the increments
|
||||||
# since the last full backup is greater than half the size
|
# since the last full backup is greater than half the size
|
||||||
# of that full backup.
|
# of that full backup.
|
||||||
@@ -117,22 +152,61 @@ def should_force_full(env):
|
|||||||
inc_size += bak["size"]
|
inc_size += bak["size"]
|
||||||
else:
|
else:
|
||||||
# ...until we reach the most recent full backup.
|
# ...until we reach the most recent full backup.
|
||||||
# Return if we should to a full backup.
|
# Return if we should to a full backup, which is based
|
||||||
return inc_size > .5*bak["size"]
|
# on the size of the increments relative to the full
|
||||||
|
# backup, as well as the age of the full backup.
|
||||||
|
if inc_size > .5*bak["size"]:
|
||||||
|
return True
|
||||||
|
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
# If we got here there are no (full) backups, so make one.
|
# If we got here there are no (full) backups, so make one.
|
||||||
# (I love for/else blocks. Here it's just to show off.)
|
# (I love for/else blocks. Here it's just to show off.)
|
||||||
return True
|
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):
|
def perform_backup(full_backup):
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
|
|
||||||
exclusive_process("backup")
|
exclusive_process("backup")
|
||||||
|
config = get_backup_config(env)
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
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
|
# In an older version of this script, duplicity was called
|
||||||
# such that it did not encrypt the backups it created (in
|
# such that it did not encrypt the backups it created (in
|
||||||
# backup/duplicity), and instead openssl was called separately
|
# backup/duplicity), and instead openssl was called separately
|
||||||
@@ -163,22 +237,26 @@ def perform_backup(full_backup):
|
|||||||
# will fail. Otherwise do a full backup when the size of
|
# will fail. Otherwise do a full backup when the size of
|
||||||
# the increments since the most recent full backup are
|
# the increments since the most recent full backup are
|
||||||
# large.
|
# large.
|
||||||
full_backup = full_backup or should_force_full(env)
|
try:
|
||||||
|
full_backup = full_backup or should_force_full(config, env)
|
||||||
|
except Exception as e:
|
||||||
|
# This was the first call to duplicity, and there might
|
||||||
|
# be an error already.
|
||||||
|
print(e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Stop services.
|
# Stop services.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
def service_command(service, command, quit=None):
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
# Execute silently, but if there is an error then display the output & exit.
|
||||||
|
code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
|
||||||
|
if code != 0:
|
||||||
|
print(ret)
|
||||||
|
if quit:
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
service_command("php5-fpm", "stop", quit=True)
|
||||||
# bits base64-encoded and with line breaks every 65 characters.
|
service_command("postfix", "stop", quit=True)
|
||||||
# gpg will only take the first line of text, so sanity check that
|
service_command("dovecot", "stop", quit=True)
|
||||||
# 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!).
|
# Run a backup of STORAGE_ROOT (but excluding the backups themselves!).
|
||||||
# --allow-source-mismatch is needed in case the box's hostname is changed
|
# --allow-source-mismatch is needed in case the box's hostname is changed
|
||||||
@@ -187,19 +265,21 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"full" if full_backup else "incr",
|
"full" if full_backup else "incr",
|
||||||
|
"--verbosity", "warning", "--no-print-statistics",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
"--volsize", "250",
|
"--volsize", "250",
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
service_command("dovecot", "start", quit=False)
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
service_command("postfix", "start", quit=False)
|
||||||
|
service_command("php5-fpm", "start", quit=False)
|
||||||
|
|
||||||
# Once the migrated backup is included in a new backup, it can be deleted.
|
# Once the migrated backup is included in a new backup, it can be deleted.
|
||||||
if os.path.isdir(migrated_unencrypted_backup_dir):
|
if os.path.isdir(migrated_unencrypted_backup_dir):
|
||||||
@@ -210,12 +290,13 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"remove-older-than",
|
"remove-older-than",
|
||||||
"%dD" % keep_backups_for_days,
|
"%dD" % config["min_age_in_days"],
|
||||||
|
"--verbosity", "error",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
"file://" + backup_dir
|
config["target"]
|
||||||
],
|
],
|
||||||
env_with_passphrase)
|
get_env(env))
|
||||||
|
|
||||||
# From duplicity's manual:
|
# From duplicity's manual:
|
||||||
# "This should only be necessary after a duplicity session fails or is
|
# "This should only be necessary after a duplicity session fails or is
|
||||||
@@ -225,14 +306,16 @@ def perform_backup(full_backup):
|
|||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"cleanup",
|
"cleanup",
|
||||||
|
"--verbosity", "error",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--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
|
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||||
# script can access them.
|
# script can access them.
|
||||||
|
if get_target_type(config) == 'file':
|
||||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# Execute a post-backup script that does the copying to a remote server.
|
||||||
@@ -241,7 +324,7 @@ def perform_backup(full_backup):
|
|||||||
post_script = os.path.join(backup_root, 'after-backup')
|
post_script = os.path.join(backup_root, 'after-backup')
|
||||||
if os.path.exists(post_script):
|
if os.path.exists(post_script):
|
||||||
shell('check_call',
|
shell('check_call',
|
||||||
['su', env['STORAGE_USER'], '-c', post_script],
|
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
|
||||||
env=env)
|
env=env)
|
||||||
|
|
||||||
# Our nightly cron job executes system status checks immediately after this
|
# Our nightly cron job executes system status checks immediately after this
|
||||||
@@ -254,9 +337,9 @@ def perform_backup(full_backup):
|
|||||||
def run_duplicity_verification():
|
def run_duplicity_verification():
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
config = get_backup_config(env)
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
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', [
|
shell('check_call', [
|
||||||
"/usr/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"--verbosity", "info",
|
"--verbosity", "info",
|
||||||
@@ -264,9 +347,138 @@ def run_duplicity_verification():
|
|||||||
"--compare-data",
|
"--compare-data",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
"file://" + backup_dir,
|
config["target"],
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
], env_with_passphrase)
|
], get_env(env))
|
||||||
|
|
||||||
|
def run_duplicity_restore(args):
|
||||||
|
env = load_environment()
|
||||||
|
config = get_backup_config(env)
|
||||||
|
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
|
||||||
|
shell('check_call', [
|
||||||
|
"/usr/bin/duplicity",
|
||||||
|
"restore",
|
||||||
|
"--archive-dir", backup_cache_dir,
|
||||||
|
config["target"],
|
||||||
|
] + args,
|
||||||
|
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
|
||||||
|
fix_boto() # must call prior to importing boto
|
||||||
|
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 "OK"
|
||||||
|
|
||||||
|
def get_backup_config(env, for_save=False, for_ui=False):
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
|
||||||
|
# Defaults.
|
||||||
|
config = {
|
||||||
|
"min_age_in_days": 3,
|
||||||
|
"target": "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# When passing this back to the admin to show the current settings, do not include
|
||||||
|
# authentication details. The user will have to re-enter it.
|
||||||
|
if for_ui:
|
||||||
|
for field in ("target_user", "target_pass"):
|
||||||
|
if field in config:
|
||||||
|
del config[field]
|
||||||
|
|
||||||
|
# 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__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
@@ -274,6 +486,17 @@ if __name__ == "__main__":
|
|||||||
# Run duplicity's verification command to check a) the backup files
|
# Run duplicity's verification command to check a) the backup files
|
||||||
# are readable, and b) report if they are up to date.
|
# are readable, and b) report if they are up to date.
|
||||||
run_duplicity_verification()
|
run_duplicity_verification()
|
||||||
|
|
||||||
|
elif sys.argv[-1] == "--status":
|
||||||
|
# Show backup status.
|
||||||
|
ret = backup_status(load_environment())
|
||||||
|
print(rtyaml.dump(ret["backups"]))
|
||||||
|
|
||||||
|
elif len(sys.argv) >= 2 and sys.argv[1] == "--restore":
|
||||||
|
# Run duplicity restore. Rest of command line passed as arguments
|
||||||
|
# to duplicity. The restore path should be specified.
|
||||||
|
run_duplicity_restore(sys.argv[2:])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Perform a backup. Add --full to force a full backup rather than
|
# Perform a backup. Add --full to force a full backup rather than
|
||||||
# possibly performing an incremental backup.
|
# possibly performing an incremental backup.
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
# This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
|
# This list is derived from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2.
|
||||||
# The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
|
# The columns are ISO_3166-1_alpha-2 code, display name, Wikipedia page name.
|
||||||
# The top 20 countries by number of Internet users are grouped first, see
|
# The top 21 countries by number of Internet users are grouped first, see
|
||||||
# https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
|
# https://en.wikipedia.org/wiki/List_of_countries_by_number_of_Internet_users.
|
||||||
BR Brazil
|
|
||||||
CA Canada
|
|
||||||
CN China
|
CN China
|
||||||
EG Egypt
|
|
||||||
FR France
|
|
||||||
DE Germany
|
|
||||||
IN India
|
IN India
|
||||||
ID Indonesia
|
|
||||||
IT Italy
|
|
||||||
JP Japan
|
|
||||||
MX Mexico
|
|
||||||
NG Nigeria
|
|
||||||
PH Philippines
|
|
||||||
RU Russian Federation Russia
|
|
||||||
ES Spain
|
|
||||||
KR South Korea
|
|
||||||
TR Turkey
|
|
||||||
GB United Kingdom
|
|
||||||
US United States
|
US United States
|
||||||
|
JP Japan
|
||||||
|
BR Brazil
|
||||||
|
RU Russian Federation Russia
|
||||||
|
DE Germany
|
||||||
|
NG Nigeria
|
||||||
|
GB United Kingdom
|
||||||
|
FR France
|
||||||
|
MX Mexico
|
||||||
|
EG Egypt
|
||||||
|
KR South Korea
|
||||||
VN Vietnam
|
VN Vietnam
|
||||||
|
ID Indonesia
|
||||||
|
PH Philippines
|
||||||
|
TR Turkey
|
||||||
|
IT Italy
|
||||||
|
PK Pakistan
|
||||||
|
ES Spain
|
||||||
|
CA Canada
|
||||||
AD Andorra
|
AD Andorra
|
||||||
AE United Arab Emirates
|
AE United Arab Emirates
|
||||||
AF Afghanistan
|
AF Afghanistan
|
||||||
@@ -183,7 +184,6 @@ PA Panama
|
|||||||
PE Peru
|
PE Peru
|
||||||
PF French Polynesia
|
PF French Polynesia
|
||||||
PG Papua New Guinea
|
PG Papua New Guinea
|
||||||
PK Pakistan
|
|
||||||
PL Poland
|
PL Poland
|
||||||
PM Saint Pierre and Miquelon
|
PM Saint Pierre and Miquelon
|
||||||
PN Pitcairn Pitcairn Islands
|
PN Pitcairn Pitcairn Islands
|
||||||
|
@@ -4,7 +4,7 @@ import os, os.path, re, json
|
|||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, request, render_template, abort, Response
|
from flask import Flask, request, render_template, abort, Response, send_from_directory
|
||||||
|
|
||||||
import auth, utils
|
import auth, utils
|
||||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user
|
||||||
@@ -15,7 +15,7 @@ from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains,
|
|||||||
# live across http requests so we don't baloon the system with
|
# live across http requests so we don't baloon the system with
|
||||||
# processes.
|
# processes.
|
||||||
import multiprocessing.pool
|
import multiprocessing.pool
|
||||||
pool = multiprocessing.pool.Pool(processes=10)
|
pool = multiprocessing.pool.Pool(processes=5)
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
@@ -28,6 +28,14 @@ try:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# for generating CSRs we need a list of country codes
|
||||||
|
csr_country_codes = []
|
||||||
|
with open(os.path.join(os.path.dirname(me), "csr_country_codes.tsv")) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip() == "" or line.startswith("#"): continue
|
||||||
|
code, name = line.strip().split("\t")[0:2]
|
||||||
|
csr_country_codes.append((code, name))
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
||||||
|
|
||||||
# Decorator to protect views that require a user with 'admin' privileges.
|
# Decorator to protect views that require a user with 'admin' privileges.
|
||||||
@@ -41,7 +49,7 @@ def authorized_personnel_only(viewfunc):
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Authentication failed.
|
# Authentication failed.
|
||||||
privs = []
|
privs = []
|
||||||
error = str(e)
|
error = "Incorrect username or password"
|
||||||
|
|
||||||
# Authorized to access an API view?
|
# Authorized to access an API view?
|
||||||
if "admin" in privs:
|
if "admin" in privs:
|
||||||
@@ -90,13 +98,23 @@ def json_response(data):
|
|||||||
def index():
|
def index():
|
||||||
# Render the control panel. This route does not require user authentication
|
# Render the control panel. This route does not require user authentication
|
||||||
# so it must be safe!
|
# so it must be safe!
|
||||||
|
|
||||||
no_users_exist = (len(get_mail_users(env)) == 0)
|
no_users_exist = (len(get_mail_users(env)) == 0)
|
||||||
no_admins_exist = (len(get_admins(env)) == 0)
|
no_admins_exist = (len(get_admins(env)) == 0)
|
||||||
|
|
||||||
|
utils.fix_boto() # must call prior to importing boto
|
||||||
|
import boto.s3
|
||||||
|
backup_s3_hosts = [(r.name, r.endpoint) for r in boto.s3.regions()]
|
||||||
|
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
hostname=env['PRIMARY_HOSTNAME'],
|
hostname=env['PRIMARY_HOSTNAME'],
|
||||||
storage_root=env['STORAGE_ROOT'],
|
storage_root=env['STORAGE_ROOT'],
|
||||||
|
|
||||||
no_users_exist=no_users_exist,
|
no_users_exist=no_users_exist,
|
||||||
no_admins_exist=no_admins_exist,
|
no_admins_exist=no_admins_exist,
|
||||||
|
|
||||||
|
backup_s3_hosts=backup_s3_hosts,
|
||||||
|
csr_country_codes=csr_country_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
@@ -107,7 +125,7 @@ def me():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json_response({
|
return json_response({
|
||||||
"status": "invalid",
|
"status": "invalid",
|
||||||
"reason": str(e),
|
"reason": "Incorrect username or password",
|
||||||
})
|
})
|
||||||
|
|
||||||
resp = {
|
resp = {
|
||||||
@@ -118,7 +136,7 @@ def me():
|
|||||||
|
|
||||||
# Is authorized as admin? Return an API key for future use.
|
# Is authorized as admin? Return an API key for future use.
|
||||||
if "admin" in privs:
|
if "admin" in privs:
|
||||||
resp["api_key"] = auth_service.create_user_key(email)
|
resp["api_key"] = auth_service.create_user_key(email, env)
|
||||||
|
|
||||||
# Return.
|
# Return.
|
||||||
return json_response(resp)
|
return json_response(resp)
|
||||||
@@ -179,14 +197,15 @@ def mail_aliases():
|
|||||||
if request.args.get("format", "") == "json":
|
if request.args.get("format", "") == "json":
|
||||||
return json_response(get_mail_aliases_ex(env))
|
return json_response(get_mail_aliases_ex(env))
|
||||||
else:
|
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'])
|
@app.route('/mail/aliases/add', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_aliases_add():
|
def mail_aliases_add():
|
||||||
return add_mail_alias(
|
return add_mail_alias(
|
||||||
request.form.get('source', ''),
|
request.form.get('address', ''),
|
||||||
request.form.get('destination', ''),
|
request.form.get('forwards_to', ''),
|
||||||
|
request.form.get('permitted_senders', ''),
|
||||||
env,
|
env,
|
||||||
update_if_exists=(request.form.get('update_if_exists', '') == '1')
|
update_if_exists=(request.form.get('update_if_exists', '') == '1')
|
||||||
)
|
)
|
||||||
@@ -194,7 +213,7 @@ def mail_aliases_add():
|
|||||||
@app.route('/mail/aliases/remove', methods=['POST'])
|
@app.route('/mail/aliases/remove', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_aliases_remove():
|
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')
|
@app.route('/mail/domains')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
@@ -222,14 +241,14 @@ def dns_update():
|
|||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_get_secondary_nameserver():
|
def dns_get_secondary_nameserver():
|
||||||
from dns_update import get_custom_dns_config, get_secondary_dns
|
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'])
|
@app.route('/dns/secondary-nameserver', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_set_secondary_nameserver():
|
def dns_set_secondary_nameserver():
|
||||||
from dns_update import set_secondary_dns
|
from dns_update import set_secondary_dns
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
return (str(e), 400)
|
return (str(e), 400)
|
||||||
|
|
||||||
@@ -308,22 +327,63 @@ def dns_get_dump():
|
|||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
|
|
||||||
|
@app.route('/ssl/status')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def ssl_get_status():
|
||||||
|
from ssl_certificates import get_certificates_to_provision
|
||||||
|
from web_update import get_web_domains_info, get_web_domains
|
||||||
|
|
||||||
|
# What domains can we provision certificates for? What unexpected problems do we have?
|
||||||
|
provision, cant_provision = get_certificates_to_provision(env, show_extended_problems=False)
|
||||||
|
|
||||||
|
# What's the current status of TLS certificates on all of the domain?
|
||||||
|
domains_status = get_web_domains_info(env)
|
||||||
|
domains_status = [{ "domain": d["domain"], "status": d["ssl_certificate"][0], "text": d["ssl_certificate"][1] } for d in domains_status ]
|
||||||
|
|
||||||
|
# Warn the user about domain names not hosted here because of other settings.
|
||||||
|
for domain in set(get_web_domains(env, exclude_dns_elsewhere=False)) - set(get_web_domains(env)):
|
||||||
|
domains_status.append({
|
||||||
|
"domain": domain,
|
||||||
|
"status": "not-applicable",
|
||||||
|
"text": "The domain's website is hosted elsewhere.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return json_response({
|
||||||
|
"can_provision": utils.sort_domains(provision, env),
|
||||||
|
"cant_provision": [{ "domain": domain, "problem": cant_provision[domain] } for domain in utils.sort_domains(cant_provision, env) ],
|
||||||
|
"status": domains_status,
|
||||||
|
})
|
||||||
|
|
||||||
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
@app.route('/ssl/csr/<domain>', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def ssl_get_csr(domain):
|
def ssl_get_csr(domain):
|
||||||
from web_update import get_domain_ssl_files, create_csr
|
from ssl_certificates import create_csr
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
return create_csr(domain, ssl_key, env)
|
return create_csr(domain, ssl_private_key, request.form.get('countrycode', ''), env)
|
||||||
|
|
||||||
@app.route('/ssl/install', methods=['POST'])
|
@app.route('/ssl/install', methods=['POST'])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def ssl_install_cert():
|
def ssl_install_cert():
|
||||||
from web_update import install_cert
|
from web_update import get_web_domains
|
||||||
|
from ssl_certificates import install_cert
|
||||||
domain = request.form.get('domain')
|
domain = request.form.get('domain')
|
||||||
ssl_cert = request.form.get('cert')
|
ssl_cert = request.form.get('cert')
|
||||||
ssl_chain = request.form.get('chain')
|
ssl_chain = request.form.get('chain')
|
||||||
|
if domain not in get_web_domains(env):
|
||||||
|
return "Invalid domain name."
|
||||||
return install_cert(domain, ssl_cert, ssl_chain, env)
|
return install_cert(domain, ssl_cert, ssl_chain, env)
|
||||||
|
|
||||||
|
@app.route('/ssl/provision', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def ssl_provision_certs():
|
||||||
|
from ssl_certificates import provision_certificates
|
||||||
|
agree_to_tos_url = request.form.get('agree_to_tos_url')
|
||||||
|
status = provision_certificates(env,
|
||||||
|
agree_to_tos_url=agree_to_tos_url,
|
||||||
|
jsonable=True)
|
||||||
|
return json_response(status)
|
||||||
|
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
|
|
||||||
@app.route('/web/domains')
|
@app.route('/web/domains')
|
||||||
@@ -340,6 +400,24 @@ def web_update():
|
|||||||
|
|
||||||
# System
|
# 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"])
|
@app.route('/system/status', methods=["POST"])
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def system_status():
|
def system_status():
|
||||||
@@ -382,7 +460,52 @@ def do_updates():
|
|||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def backup_status():
|
def backup_status():
|
||||||
from backup import backup_status
|
from backup import backup_status
|
||||||
|
try:
|
||||||
return json_response(backup_status(env))
|
return json_response(backup_status(env))
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({ "error": str(e) })
|
||||||
|
|
||||||
|
@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, for_ui=True))
|
||||||
|
|
||||||
|
@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', '')
|
||||||
|
))
|
||||||
|
|
||||||
|
@app.route('/system/privacy', methods=["GET"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def privacy_status_get():
|
||||||
|
config = utils.load_settings(env)
|
||||||
|
return json_response(config.get("privacy", True))
|
||||||
|
|
||||||
|
@app.route('/system/privacy', methods=["POST"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def privacy_status_set():
|
||||||
|
config = utils.load_settings(env)
|
||||||
|
config["privacy"] = (request.form.get('value') == "private")
|
||||||
|
utils.write_settings(config, env)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
# MUNIN
|
||||||
|
|
||||||
|
@app.route('/munin/')
|
||||||
|
@app.route('/munin/<path:filename>')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def munin(filename=""):
|
||||||
|
# Checks administrative access (@authorized_personnel_only) and then just proxies
|
||||||
|
# the request to static files.
|
||||||
|
if filename == "": filename = "index.html"
|
||||||
|
return send_from_directory("/var/cache/munin/www", filename)
|
||||||
|
|
||||||
# APP
|
# APP
|
||||||
|
|
||||||
@@ -403,4 +526,3 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
# Start the application server. Listens on 127.0.0.1 (IPv4 only).
|
||||||
app.run(port=10222)
|
app.run(port=10222)
|
||||||
|
|
||||||
|
|||||||
19
management/daily_tasks.sh
Executable file
19
management/daily_tasks.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# This script is run daily (at 3am each night).
|
||||||
|
|
||||||
|
# Set character encoding flags to ensure that any non-ASCII
|
||||||
|
# characters don't cause problems. See setup/start.sh and
|
||||||
|
# the management daemon startup script.
|
||||||
|
export LANGUAGE=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
|
# Take a backup.
|
||||||
|
management/backup.py | management/email_administrator.py "Backup Status"
|
||||||
|
|
||||||
|
# Provision any new certificates for new domains or domains with expiring certificates.
|
||||||
|
management/ssl_certificates.py --headless | management/email_administrator.py "Error Provisioning TLS Certificate"
|
||||||
|
|
||||||
|
# Run status checks and email the administrator if anything changed.
|
||||||
|
management/status_checks.py --show-changes | management/email_administrator.py "Status Checks Change Notice"
|
||||||
@@ -51,19 +51,13 @@ def get_dns_zones(env):
|
|||||||
return zonefiles
|
return zonefiles
|
||||||
|
|
||||||
def do_dns_update(env, force=False):
|
def do_dns_update(env, force=False):
|
||||||
# What domains (and their zone filenames) should we build?
|
|
||||||
domains = get_dns_domains(env)
|
|
||||||
zonefiles = get_dns_zones(env)
|
|
||||||
|
|
||||||
# Custom records to add to zones.
|
|
||||||
additional_records = list(get_custom_dns_config(env))
|
|
||||||
|
|
||||||
# Write zone files.
|
# Write zone files.
|
||||||
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
||||||
|
zonefiles = []
|
||||||
updated_domains = []
|
updated_domains = []
|
||||||
for i, (domain, zonefile) in enumerate(zonefiles):
|
for (domain, zonefile, records) in build_zones(env):
|
||||||
# Build the records to put in the zone.
|
# The final set of files will be signed.
|
||||||
records = build_zone(domain, domains, additional_records, env)
|
zonefiles.append((domain, zonefile + ".signed"))
|
||||||
|
|
||||||
# See if the zone has changed, and if so update the serial number
|
# See if the zone has changed, and if so update the serial number
|
||||||
# and write the zone file.
|
# and write the zone file.
|
||||||
@@ -71,14 +65,6 @@ def do_dns_update(env, force=False):
|
|||||||
# Zone was not updated. There were no changes.
|
# Zone was not updated. There were no changes.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If this is a .justtesting.email domain, then post the update.
|
|
||||||
try:
|
|
||||||
justtestingdotemail(domain, records)
|
|
||||||
except:
|
|
||||||
# Hmm. Might be a network issue. If we stop now, will we end
|
|
||||||
# up in an inconsistent state? Let's just continue.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Mark that we just updated this domain.
|
# Mark that we just updated this domain.
|
||||||
updated_domains.append(domain)
|
updated_domains.append(domain)
|
||||||
|
|
||||||
@@ -93,14 +79,8 @@ def do_dns_update(env, force=False):
|
|||||||
# and return True so we get a chance to re-sign it.
|
# and return True so we get a chance to re-sign it.
|
||||||
sign_zone(domain, zonefile, env)
|
sign_zone(domain, zonefile, env)
|
||||||
|
|
||||||
# Now that all zones are signed (some might not have changed and so didn't
|
|
||||||
# just get signed now, but were before) update the zone filename so nsd.conf
|
|
||||||
# uses the signed file.
|
|
||||||
for i in range(len(zonefiles)):
|
|
||||||
zonefiles[i][1] += ".signed"
|
|
||||||
|
|
||||||
# Write the main nsd.conf file.
|
# Write the main nsd.conf file.
|
||||||
if write_nsd_conf(zonefiles, additional_records, env):
|
if write_nsd_conf(zonefiles, list(get_custom_dns_config(env)), env):
|
||||||
# Make sure updated_domains contains *something* if we wrote an updated
|
# Make sure updated_domains contains *something* if we wrote an updated
|
||||||
# nsd.conf so that we know to restart nsd.
|
# nsd.conf so that we know to restart nsd.
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
@@ -110,14 +90,18 @@ def do_dns_update(env, force=False):
|
|||||||
if len(updated_domains) > 0:
|
if len(updated_domains) > 0:
|
||||||
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
||||||
|
|
||||||
# Write the OpenDKIM configuration tables.
|
# Write the OpenDKIM configuration tables for all of the domains.
|
||||||
if write_opendkim_tables(domains, env):
|
if write_opendkim_tables(get_mail_domains(env), env):
|
||||||
# Settings changed. Kick opendkim.
|
# Settings changed. Kick opendkim.
|
||||||
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
shell('check_call', ["/usr/sbin/service", "opendkim", "restart"])
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
# If this is the only thing that changed?
|
# If this is the only thing that changed?
|
||||||
updated_domains.append("OpenDKIM configuration")
|
updated_domains.append("OpenDKIM configuration")
|
||||||
|
|
||||||
|
# Clear bind9's DNS cache so our own DNS resolver is up to date.
|
||||||
|
# (ignore errors with trap=True)
|
||||||
|
shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True)
|
||||||
|
|
||||||
if len(updated_domains) == 0:
|
if len(updated_domains) == 0:
|
||||||
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
|
# if nothing was updated (except maybe OpenDKIM's files), don't show any output
|
||||||
return ""
|
return ""
|
||||||
@@ -126,7 +110,23 @@ def do_dns_update(env, force=False):
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
def build_zones(env):
|
||||||
|
# What domains (and their zone filenames) should we build?
|
||||||
|
domains = get_dns_domains(env)
|
||||||
|
zonefiles = get_dns_zones(env)
|
||||||
|
|
||||||
|
# Custom records to add to zones.
|
||||||
|
additional_records = list(get_custom_dns_config(env))
|
||||||
|
from web_update import get_web_domains
|
||||||
|
www_redirect_domains = set(get_web_domains(env)) - set(get_web_domains(env, include_www_redirects=False))
|
||||||
|
|
||||||
|
# Build DNS records for each zone.
|
||||||
|
for domain, zonefile in zonefiles:
|
||||||
|
# Build the records to put in the zone.
|
||||||
|
records = build_zone(domain, domains, additional_records, www_redirect_domains, env)
|
||||||
|
yield (domain, zonefile, records)
|
||||||
|
|
||||||
|
def build_zone(domain, all_domains, additional_records, www_redirect_domains, env, is_zone=True):
|
||||||
records = []
|
records = []
|
||||||
|
|
||||||
# For top-level zones, define the authoritative name servers.
|
# For top-level zones, define the authoritative name servers.
|
||||||
@@ -142,7 +142,10 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False))
|
||||||
|
|
||||||
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
# Define ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
||||||
secondary_ns = get_secondary_dns(additional_records) or ("ns2." + env["PRIMARY_HOSTNAME"])
|
# User may provide one or more additional nameservers
|
||||||
|
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
|
||||||
|
or ["ns2." + env["PRIMARY_HOSTNAME"]]
|
||||||
|
for secondary_ns in secondary_ns_list:
|
||||||
records.append((None, "NS", secondary_ns+'.', False))
|
records.append((None, "NS", secondary_ns+'.', False))
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +168,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
# Add a DANE TLSA record for SMTP.
|
# 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."))
|
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.
|
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
|
||||||
for value in build_sshfp_records():
|
for value in build_sshfp_records():
|
||||||
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh."))
|
records.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."))
|
||||||
@@ -177,7 +183,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
||||||
for subdomain in subdomains:
|
for subdomain in subdomains:
|
||||||
subdomain_qname = subdomain[0:-len("." + domain)]
|
subdomain_qname = subdomain[0:-len("." + domain)]
|
||||||
subzone = build_zone(subdomain, [], additional_records, env, is_zone=False)
|
subzone = build_zone(subdomain, [], additional_records, www_redirect_domains, env, is_zone=False)
|
||||||
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
||||||
if child_qname == None:
|
if child_qname == None:
|
||||||
child_qname = subdomain_qname
|
child_qname = subdomain_qname
|
||||||
@@ -211,13 +217,19 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
records.append((qname, rtype, value, "(Set by user.)"))
|
records.append((qname, rtype, value, "(Set by user.)"))
|
||||||
|
|
||||||
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
|
# Add defaults if not overridden by the user's custom settings (and not otherwise configured).
|
||||||
# Any "CNAME" record on the qname overrides A and AAAA.
|
# Any CNAME or A record on the qname overrides A and AAAA. But when we set the default A record,
|
||||||
has_rec_base = records
|
# we should not cause the default AAAA record to be skipped because it thinks a custom A record
|
||||||
|
# was set. So set has_rec_base to a clone of the current set of DNS settings, and don't update
|
||||||
|
# during this process.
|
||||||
|
has_rec_base = list(records)
|
||||||
defaults = [
|
defaults = [
|
||||||
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
(None, "A", env["PUBLIC_IP"], "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain),
|
||||||
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain),
|
|
||||||
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain),
|
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain),
|
||||||
("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain),
|
]
|
||||||
|
if "www." + domain in www_redirect_domains:
|
||||||
|
defaults += [
|
||||||
|
("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to so that the box can provide a redirect to the parent domain." % domain),
|
||||||
|
("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to so that the box can provide a redirect to the parent domain." % domain),
|
||||||
]
|
]
|
||||||
for qname, rtype, value, explanation in defaults:
|
for qname, rtype, value, explanation in defaults:
|
||||||
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||||
@@ -229,6 +241,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
|
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
|
||||||
records.append((qname, rtype, value, explanation))
|
records.append((qname, rtype, value, explanation))
|
||||||
|
|
||||||
|
# Don't pin the list of records that has_rec checks against anymore.
|
||||||
|
has_rec_base = records
|
||||||
|
|
||||||
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
||||||
# the domain, and no one else.
|
# the domain, and no one else.
|
||||||
# Skip if the user has set a custom SPF record.
|
# Skip if the user has set a custom SPF record.
|
||||||
@@ -239,8 +254,8 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
# Skip if the user has set a DKIM record already.
|
# Skip if the user has set a DKIM record already.
|
||||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||||
with open(opendkim_record_file) as orf:
|
with open(opendkim_record_file) as orf:
|
||||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
|
||||||
val = m.group(2) + m.group(3)
|
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
|
||||||
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
|
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))
|
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||||
|
|
||||||
@@ -268,26 +283,40 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
|
|
||||||
def build_tlsa_record(env):
|
def build_tlsa_record(env):
|
||||||
# A DANE TLSA record in DNS specifies that connections on a port
|
# A DANE TLSA record in DNS specifies that connections on a port
|
||||||
# must use TLS and the certificate must match a particular certificate.
|
# must use TLS and the certificate must match a particular criteria.
|
||||||
#
|
#
|
||||||
# Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html
|
# Thanks to http://blog.huque.com/2012/10/dnssec-and-certificates.html
|
||||||
# for explaining all of this!
|
# and https://community.letsencrypt.org/t/please-avoid-3-0-1-and-3-0-2-dane-tlsa-records-with-le-certificates/7022
|
||||||
|
# for explaining all of this! Also see https://tools.ietf.org/html/rfc6698#section-2.1
|
||||||
|
# and https://github.com/mail-in-a-box/mailinabox/issues/268#issuecomment-167160243.
|
||||||
|
#
|
||||||
|
# There are several criteria. We used to use "3 0 1" criteria, which
|
||||||
|
# meant to pin a leaf (3) certificate (0) with SHA256 hash (1). But
|
||||||
|
# certificates change, and especially as we move to short-lived certs
|
||||||
|
# they change often. The TLSA record handily supports the criteria of
|
||||||
|
# a leaf certificate (3)'s subject public key (1) with SHA256 hash (1).
|
||||||
|
# The subject public key is the public key portion of the private key
|
||||||
|
# that generated the CSR that generated the certificate. Since we
|
||||||
|
# generate a private key once the first time Mail-in-a-Box is set up
|
||||||
|
# and reuse it for all subsequent certificates, the TLSA record will
|
||||||
|
# remain valid indefinitely.
|
||||||
|
|
||||||
# Get the hex SHA256 of the DER-encoded server certificate:
|
from ssl_certificates import load_cert_chain, load_pem
|
||||||
certder = shell("check_output", [
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
"/usr/bin/openssl",
|
|
||||||
"x509",
|
fn = os.path.join(env["STORAGE_ROOT"], "ssl", "ssl_certificate.pem")
|
||||||
"-in", os.path.join(env["STORAGE_ROOT"], "ssl", "ssl_certificate.pem"),
|
cert = load_pem(load_cert_chain(fn)[0])
|
||||||
"-outform", "DER"
|
|
||||||
],
|
subject_public_key = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
||||||
return_bytes=True)
|
# We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...)
|
||||||
certhash = hashlib.sha256(certder).hexdigest()
|
|
||||||
|
pk_hash = hashlib.sha256(subject_public_key).hexdigest()
|
||||||
|
|
||||||
# Specify the TLSA parameters:
|
# Specify the TLSA parameters:
|
||||||
# 3: This is the certificate that the client should trust. No CA is needed.
|
# 3: Match the (leaf) certificate. (No CA, no trust path needed.)
|
||||||
# 0: The whole certificate is matched.
|
# 1: Match its subject public key.
|
||||||
# 1: The certificate is SHA256'd here.
|
# 1: Use SHA256.
|
||||||
return "3 0 1 " + certhash
|
return "3 1 1 " + pk_hash
|
||||||
|
|
||||||
def build_sshfp_records():
|
def build_sshfp_records():
|
||||||
# The SSHFP record is a way for us to embed this server's SSH public
|
# The SSHFP record is a way for us to embed this server's SSH public
|
||||||
@@ -362,9 +391,16 @@ $TTL 1800 ; default time to live
|
|||||||
zone += subdomain
|
zone += subdomain
|
||||||
zone += "\tIN\t" + querytype + "\t"
|
zone += "\tIN\t" + querytype + "\t"
|
||||||
if querytype == "TXT":
|
if querytype == "TXT":
|
||||||
value = value.replace('\\', '\\\\') # escape backslashes
|
# Divide into 255-byte max substrings.
|
||||||
value = value.replace('"', '\\"') # escape quotes
|
v2 = ""
|
||||||
value = '"' + value + '"' # wrap in quotes
|
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"
|
zone += value + "\n"
|
||||||
|
|
||||||
# DNSSEC requires re-signing a zone periodically. That requires
|
# DNSSEC requires re-signing a zone periodically. That requires
|
||||||
@@ -444,17 +480,10 @@ zone:
|
|||||||
zonefile: %s
|
zonefile: %s
|
||||||
""" % (domain, zonefile)
|
""" % (domain, zonefile)
|
||||||
|
|
||||||
# If a custom secondary nameserver has been set, allow zone transfers
|
# If custom secondary nameservers have been set, allow zone transfers
|
||||||
# and notifies to that nameserver.
|
# and notifies to them.
|
||||||
if get_secondary_dns(additional_records):
|
for ipaddr in get_secondary_dns(additional_records, mode="xfr"):
|
||||||
# Get the IP address of the nameserver by resolving it.
|
nsdconf += "\n\tnotify: %s NOKEY\n\tprovide-xfr: %s NOKEY\n" % (ipaddr, ipaddr)
|
||||||
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)
|
|
||||||
|
|
||||||
# Check if the file is changing. If it isn't changing,
|
# Check if the file is changing. If it isn't changing,
|
||||||
# return False to flag that no change was made.
|
# return False to flag that no change was made.
|
||||||
@@ -473,11 +502,12 @@ zone:
|
|||||||
|
|
||||||
def dnssec_choose_algo(domain, env):
|
def dnssec_choose_algo(domain, env):
|
||||||
if '.' in domain and domain.rsplit('.')[-1] in \
|
if '.' in domain and domain.rsplit('.')[-1] in \
|
||||||
("email", "guide", "fund"):
|
("email", "guide", "fund", "be"):
|
||||||
# At GoDaddy, RSASHA256 is the only algorithm supported
|
# At GoDaddy, RSASHA256 is the only algorithm supported
|
||||||
# for .email and .guide.
|
# for .email and .guide.
|
||||||
# A variety of algorithms are supported for .fund. This
|
# A variety of algorithms are supported for .fund. This
|
||||||
# is preferred.
|
# is preferred.
|
||||||
|
# Gandi tells me that .be does not support RSASHA1-NSEC3-SHA1
|
||||||
return "RSASHA256"
|
return "RSASHA256"
|
||||||
|
|
||||||
# For any domain we were able to sign before, don't change the algorithm
|
# For any domain we were able to sign before, don't change the algorithm
|
||||||
@@ -767,89 +797,82 @@ def set_custom_dns_record(qname, rtype, value, action, env):
|
|||||||
if made_change:
|
if made_change:
|
||||||
# serialize & save
|
# serialize & save
|
||||||
write_custom_dns_config(newconfig, env)
|
write_custom_dns_config(newconfig, env)
|
||||||
|
|
||||||
return made_change
|
return made_change
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def get_secondary_dns(custom_dns):
|
def get_secondary_dns(custom_dns, mode=None):
|
||||||
for qname, rtype, value in custom_dns:
|
|
||||||
if qname == "_secondary_nameserver":
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set_secondary_dns(hostname, env):
|
|
||||||
|
|
||||||
if hostname in (None, ""):
|
|
||||||
# Clear.
|
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
|
||||||
else:
|
|
||||||
# Validate.
|
|
||||||
hostname = hostname.strip().lower()
|
|
||||||
resolver = dns.resolver.get_default_resolver()
|
resolver = dns.resolver.get_default_resolver()
|
||||||
|
resolver.timeout = 10
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for qname, rtype, value in custom_dns:
|
||||||
|
if qname != '_secondary_nameserver': continue
|
||||||
|
for hostname in value.split(" "):
|
||||||
|
hostname = hostname.strip()
|
||||||
|
if mode == None:
|
||||||
|
# Just return the setting.
|
||||||
|
values.append(hostname)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
resolver.timeout = 5
|
||||||
|
for item in hostnames:
|
||||||
|
if not item.startswith("xfr:"):
|
||||||
|
# Resolve hostname.
|
||||||
try:
|
try:
|
||||||
response = dns.resolver.query(hostname, "A")
|
response = resolver.query(item, "A")
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
raise ValueError("Could not resolve the IP address of %s." % hostname)
|
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.
|
||||||
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.
|
# Apply.
|
||||||
return do_dns_update(env)
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
|
||||||
########################################################################
|
def get_custom_dns_record(custom_dns, qname, rtype):
|
||||||
|
for qname1, rtype1, value in custom_dns:
|
||||||
def justtestingdotemail(domain, records):
|
if qname1 == qname and rtype1 == rtype:
|
||||||
# If the domain is a subdomain of justtesting.email, which we own,
|
return value
|
||||||
# automatically populate the zone where it is set up on dns4e.com.
|
return None
|
||||||
# Ideally if dns4e.com supported NS records we would just have it
|
|
||||||
# delegate DNS to us, but instead we will populate the whole zone.
|
|
||||||
|
|
||||||
import subprocess, json, urllib.parse
|
|
||||||
|
|
||||||
if not domain.endswith(".justtesting.email"):
|
|
||||||
return
|
|
||||||
|
|
||||||
for subdomain, querytype, value, explanation in records:
|
|
||||||
if querytype in ("NS",): continue
|
|
||||||
if subdomain in ("www", "ns1", "ns2"): continue # don't do unnecessary things
|
|
||||||
|
|
||||||
if subdomain == None:
|
|
||||||
subdomain = domain
|
|
||||||
else:
|
|
||||||
subdomain = subdomain + "." + domain
|
|
||||||
|
|
||||||
if querytype == "TXT":
|
|
||||||
# nsd requires parentheses around txt records with multiple parts,
|
|
||||||
# but DNS4E requires there be no parentheses; also it goes into
|
|
||||||
# nsd with a newline and a tab, which we replace with a space here
|
|
||||||
value = re.sub("^\s*\(\s*([\w\W]*)\)", r"\1", value)
|
|
||||||
value = re.sub("\s+", " ", value)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print("Updating DNS for %s/%s..." % (subdomain, querytype))
|
|
||||||
resp = json.loads(subprocess.check_output([
|
|
||||||
"curl",
|
|
||||||
"-s",
|
|
||||||
"https://api.dns4e.com/v7/%s/%s" % (urllib.parse.quote(subdomain), querytype.lower()),
|
|
||||||
"--user", "2ddbd8e88ed1495fa0ec:A97TDJV26CVUJS6hqAs0CKnhj4HvjTM7MwAAg8xb",
|
|
||||||
"--data", "record=%s" % urllib.parse.quote(value),
|
|
||||||
]).decode("utf8"))
|
|
||||||
print("\t...", resp.get("message", "?"))
|
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def build_recommended_dns(env):
|
def build_recommended_dns(env):
|
||||||
ret = []
|
ret = []
|
||||||
domains = get_dns_domains(env)
|
for (domain, zonefile, records) in build_zones(env):
|
||||||
zonefiles = get_dns_zones(env)
|
|
||||||
additional_records = list(get_custom_dns_config(env))
|
|
||||||
for domain, zonefile in zonefiles:
|
|
||||||
records = build_zone(domain, domains, additional_records, env)
|
|
||||||
|
|
||||||
# remove records that we don't dislay
|
# remove records that we don't dislay
|
||||||
records = [r for r in records if r[3] is not False]
|
records = [r for r in records if r[3] is not False]
|
||||||
|
|
||||||
|
|||||||
42
management/email_administrator.py
Executable file
42
management/email_administrator.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
# Reads in STDIN. If the stream is not empty, mail it to the system administrator.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from email.message import Message
|
||||||
|
|
||||||
|
from utils import load_environment
|
||||||
|
|
||||||
|
# Load system environment info.
|
||||||
|
env = load_environment()
|
||||||
|
|
||||||
|
# Process command line args.
|
||||||
|
subject = sys.argv[1]
|
||||||
|
|
||||||
|
# Administrator's email address.
|
||||||
|
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
|
# Read in STDIN.
|
||||||
|
content = sys.stdin.read().strip()
|
||||||
|
|
||||||
|
# If there's nothing coming in, just exit.
|
||||||
|
if content == "":
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# create MIME message
|
||||||
|
msg = Message()
|
||||||
|
msg['From'] = "\"%s\" <%s>" % (env['PRIMARY_HOSTNAME'], admin_addr)
|
||||||
|
msg['To'] = admin_addr
|
||||||
|
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
||||||
|
msg.set_payload(content, "UTF-8")
|
||||||
|
|
||||||
|
# send
|
||||||
|
smtpclient = smtplib.SMTP('localhost', 25)
|
||||||
|
smtpclient.ehlo()
|
||||||
|
smtpclient.sendmail(
|
||||||
|
admin_addr, # MAIL FROM
|
||||||
|
admin_addr, # RCPT TO
|
||||||
|
msg.as_string())
|
||||||
|
smtpclient.quit()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
import re, os.path
|
import re, os.path
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ def scan_mail_log(logger, env):
|
|||||||
"imap-logins": { },
|
"imap-logins": { },
|
||||||
"postgrey": { },
|
"postgrey": { },
|
||||||
"rejected-mail": { },
|
"rejected-mail": { },
|
||||||
|
"activity-by-hour": { "imap-logins": defaultdict(int), "smtp-sends": defaultdict(int) },
|
||||||
}
|
}
|
||||||
|
|
||||||
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
||||||
@@ -45,6 +47,10 @@ def scan_mail_log(logger, env):
|
|||||||
for date, sender, message in collector["rejected-mail"][k]:
|
for date, sender, message in collector["rejected-mail"][k]:
|
||||||
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
||||||
|
|
||||||
|
logger.add_heading("Activity by Hour")
|
||||||
|
for h in range(24):
|
||||||
|
logger.print_line("%d\t%d\t%d" % (h, collector["activity-by-hour"]["imap-logins"][h], collector["activity-by-hour"]["smtp-sends"][h] ))
|
||||||
|
|
||||||
if len(collector["other-services"]) > 0:
|
if len(collector["other-services"]) > 0:
|
||||||
logger.add_heading("Other")
|
logger.add_heading("Other")
|
||||||
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
||||||
@@ -65,6 +71,9 @@ def scan_mail_log_line(line, collector):
|
|||||||
elif service == "postfix/smtpd":
|
elif service == "postfix/smtpd":
|
||||||
scan_postfix_smtpd_line(date, log, collector)
|
scan_postfix_smtpd_line(date, log, collector)
|
||||||
|
|
||||||
|
elif service == "postfix/submission/smtpd":
|
||||||
|
scan_postfix_submission_line(date, log, collector)
|
||||||
|
|
||||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
||||||
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
||||||
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
||||||
@@ -80,6 +89,7 @@ def scan_dovecot_line(date, log, collector):
|
|||||||
login, ip = m.group(1), m.group(2)
|
login, ip = m.group(1), m.group(2)
|
||||||
if ip != "127.0.0.1": # local login from webmail/zpush
|
if ip != "127.0.0.1": # local login from webmail/zpush
|
||||||
collector["imap-logins"].setdefault(login, {})[ip] = date
|
collector["imap-logins"].setdefault(login, {})[ip] = date
|
||||||
|
collector["activity-by-hour"]["imap-logins"][date.hour] += 1
|
||||||
|
|
||||||
def scan_postgrey_line(date, log, collector):
|
def scan_postgrey_line(date, log, collector):
|
||||||
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||||
@@ -114,6 +124,11 @@ def scan_postfix_smtpd_line(date, log, collector):
|
|||||||
|
|
||||||
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
||||||
|
|
||||||
|
def scan_postfix_submission_line(date, log, collector):
|
||||||
|
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=PLAIN, sasl_username=(\S+)", log)
|
||||||
|
if m:
|
||||||
|
procid, client, user = m.groups()
|
||||||
|
collector["activity-by-hour"]["smtp-sends"][date.hour] += 1
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from status_checks import ConsoleOutput
|
from status_checks import ConsoleOutput
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import subprocess, shutil, os, sqlite3, re
|
import subprocess, shutil, os, sqlite3, re
|
||||||
import utils
|
import utils
|
||||||
from email_validator import validate_email as validate_email_, EmailNotValidError
|
from email_validator import validate_email as validate_email_, EmailNotValidError
|
||||||
|
import idna
|
||||||
|
|
||||||
def validate_email(email, mode=None):
|
def validate_email(email, mode=None):
|
||||||
# Checks that an email address is syntactically valid. Returns True/False.
|
# 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.
|
# to the underlying protocols.
|
||||||
try:
|
try:
|
||||||
localpart, domainpart = email.split("@")
|
localpart, domainpart = email.split("@")
|
||||||
domainpart = domainpart.encode("idna").decode('ascii')
|
domainpart = idna.encode(domainpart).decode('ascii')
|
||||||
return localpart + "@" + domainpart
|
return localpart + "@" + domainpart
|
||||||
except:
|
except (ValueError, idna.IDNAError):
|
||||||
# Domain part is not IDNA-valid, so leave unchanged. If there
|
# ValueError: String does not have a single @-sign, so it is not
|
||||||
# are non-ASCII characters it will be filtered out by
|
# 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.
|
# validate_email.
|
||||||
return email
|
return email
|
||||||
|
|
||||||
@@ -65,15 +68,16 @@ def prettify_idn_email_address(email):
|
|||||||
# names in IDNA in the database, but we want to show Unicode to the user.
|
# names in IDNA in the database, but we want to show Unicode to the user.
|
||||||
try:
|
try:
|
||||||
localpart, domainpart = email.split("@")
|
localpart, domainpart = email.split("@")
|
||||||
domainpart = domainpart.encode("ascii").decode('idna')
|
domainpart = idna.decode(domainpart.encode("ascii"))
|
||||||
return localpart + "@" + domainpart
|
return localpart + "@" + domainpart
|
||||||
except:
|
except (ValueError, UnicodeError, idna.IDNAError):
|
||||||
# Failed to decode IDNA. Should never happen.
|
# Failed to decode IDNA, or the email address does not have a
|
||||||
|
# single @-sign. Should never happen.
|
||||||
return email
|
return email
|
||||||
|
|
||||||
def is_dcv_address(email):
|
def is_dcv_address(email):
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster"):
|
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"):
|
||||||
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
|
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -177,13 +181,13 @@ def get_admins(env):
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
def get_mail_aliases(env):
|
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 = open_database(env)
|
||||||
c.execute('SELECT source, destination FROM aliases')
|
c.execute('SELECT source, destination, permitted_senders FROM aliases')
|
||||||
aliases = { row[0]: row[1] for row in c.fetchall() } # make dict
|
aliases = { row[0]: row for row in c.fetchall() } # make dict
|
||||||
|
|
||||||
# put in a canonical order: sort by domain, then by email address lexicographically
|
# 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
|
return aliases
|
||||||
|
|
||||||
def get_mail_aliases_ex(env):
|
def get_mail_aliases_ex(env):
|
||||||
@@ -195,9 +199,10 @@ def get_mail_aliases_ex(env):
|
|||||||
# domain: "domain.tld",
|
# domain: "domain.tld",
|
||||||
# alias: [
|
# alias: [
|
||||||
# {
|
# {
|
||||||
# source: "name@domain.tld", # IDNA-encoded
|
# address: "name@domain.tld", # IDNA-encoded
|
||||||
# source_display: "name@domain.tld", # full Unicode
|
# address_display: "name@domain.tld", # full Unicode
|
||||||
# destination: ["target1@domain.com", "target2@domain.com", ...],
|
# forwards_to: ["user1@domain.com", "receiver-only1@domain.com", ...],
|
||||||
|
# permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null,
|
||||||
# required: True|False
|
# required: True|False
|
||||||
# },
|
# },
|
||||||
# ...
|
# ...
|
||||||
@@ -208,10 +213,10 @@ def get_mail_aliases_ex(env):
|
|||||||
|
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
domains = {}
|
domains = {}
|
||||||
for source, destination in get_mail_aliases(env):
|
for address, forwards_to, permitted_senders in get_mail_aliases(env):
|
||||||
# get alias info
|
# get alias info
|
||||||
domain = get_domain(source)
|
domain = get_domain(address)
|
||||||
required = ((source in required_aliases) or (source == get_system_administrator(env)))
|
required = (address in required_aliases)
|
||||||
|
|
||||||
# add to list
|
# add to list
|
||||||
if not domain in domains:
|
if not domain in domains:
|
||||||
@@ -220,33 +225,40 @@ def get_mail_aliases_ex(env):
|
|||||||
"aliases": [],
|
"aliases": [],
|
||||||
}
|
}
|
||||||
domains[domain]["aliases"].append({
|
domains[domain]["aliases"].append({
|
||||||
"source": source,
|
"address": address,
|
||||||
"source_display": prettify_idn_email_address(source),
|
"address_display": prettify_idn_email_address(address),
|
||||||
"destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")],
|
"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,
|
"required": required,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort domains.
|
# Sort domains.
|
||||||
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
|
|
||||||
# Sort aliases within each domain first by required-ness then lexicographically by source address.
|
# Sort aliases within each domain first by required-ness then lexicographically by address.
|
||||||
for domain in domains:
|
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
|
return domains
|
||||||
|
|
||||||
def get_domain(emailaddr, as_unicode=True):
|
def get_domain(emailaddr, as_unicode=True):
|
||||||
# Gets the domain part of an email address. Turns IDNA
|
# Gets the domain part of an email address. Turns IDNA
|
||||||
# back to Unicode for display.
|
# back to Unicode for display.
|
||||||
ret = emailaddr.split('@', 1)[1]
|
ret = emailaddr.split('@', 1)[1]
|
||||||
if as_unicode: ret = ret.encode('ascii').decode('idna')
|
if as_unicode:
|
||||||
|
try:
|
||||||
|
ret = idna.decode(ret.encode('ascii'))
|
||||||
|
except (ValueError, UnicodeError, idna.IDNAError):
|
||||||
|
# Looks like we have an invalid email address in
|
||||||
|
# the database. Now is not the time to complain.
|
||||||
|
pass
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
def get_mail_domains(env, filter_aliases=lambda alias : True):
|
||||||
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
||||||
# configured on the system.
|
# configured on the system.
|
||||||
return set(
|
return set(
|
||||||
[get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
|
[get_domain(login, as_unicode=False) for login 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(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ]
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
@@ -291,25 +303,6 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
# write databasebefore next step
|
# write databasebefore next step
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Create & subscribe the user's INBOX, Trash, Spam, and Drafts folders.
|
|
||||||
# * Our sieve rule for spam expects that the Spam folder exists.
|
|
||||||
# * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
|
|
||||||
# * K-9 mail will poll every 90 seconds if a Drafts folder does not exist, so create it
|
|
||||||
# to avoid unnecessary polling.
|
|
||||||
|
|
||||||
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
|
||||||
# been deleted, the mailboxes will still exist because they are still on disk.
|
|
||||||
try:
|
|
||||||
existing_mboxes = utils.shell('check_output', ["doveadm", "mailbox", "list", "-u", email, "-8"], capture_stderr=True).split("\n")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
c.execute("DELETE FROM users WHERE email=?", (email,))
|
|
||||||
conn.commit()
|
|
||||||
return ("Failed to initialize the user: " + e.output.decode("utf8"), 400)
|
|
||||||
|
|
||||||
for folder in ("INBOX", "Trash", "Spam", "Drafts"):
|
|
||||||
if folder not in existing_mboxes:
|
|
||||||
utils.shell('check_call', ["doveadm", "mailbox", "create", "-u", email, "-s", folder])
|
|
||||||
|
|
||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, "mail user added")
|
return kick(env, "mail user added")
|
||||||
|
|
||||||
@@ -402,67 +395,91 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
|||||||
|
|
||||||
return "OK"
|
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
|
# 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
|
# Our database is case sensitive (oops), which affects mail delivery
|
||||||
# (Postfix always queries in lowercase?), so force lowercase.
|
# (Postfix always queries in lowercase?), so force lowercase.
|
||||||
source = source.lower()
|
address = address.lower()
|
||||||
|
|
||||||
# validate source
|
# validate address
|
||||||
source = source.strip()
|
address = address.strip()
|
||||||
if source == "":
|
if address == "":
|
||||||
return ("No incoming email address provided.", 400)
|
return ("No email address provided.", 400)
|
||||||
if not validate_email(source, mode='alias'):
|
if not validate_email(address, mode='alias'):
|
||||||
return ("Invalid incoming email address (%s)." % source, 400)
|
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
|
# extra checks for email addresses used in domain control validation
|
||||||
is_dcv_source = is_dcv_address(source)
|
is_dcv_source = is_dcv_address(address)
|
||||||
|
|
||||||
# validate destination
|
|
||||||
dests = []
|
|
||||||
destination = destination.strip()
|
|
||||||
|
|
||||||
# Postfix allows a single @domain.tld as the destination, which means
|
# Postfix allows a single @domain.tld as the destination, which means
|
||||||
# the local part on the address is preserved in the rewrite. We must
|
# 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
|
# try to convert Unicode to IDNA first before validating that it's a
|
||||||
# legitimate alias address. Don't allow this sort of rewriting for
|
# legitimate alias address. Don't allow this sort of rewriting for
|
||||||
# DCV source addresses.
|
# DCV source addresses.
|
||||||
d1 = sanitize_idn_email_address(destination)
|
r1 = sanitize_idn_email_address(forwards_to)
|
||||||
if validate_email(d1, mode='alias') and not is_dcv_source:
|
if validate_email(r1, mode='alias') and not is_dcv_source:
|
||||||
dests.append(d1)
|
validated_forwards_to.append(r1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Parse comma and \n-separated destination emails & validate. In this
|
# Parse comma and \n-separated destination emails & validate. In this
|
||||||
# case, the recipients must be complete email addresses.
|
# case, the forwards_to must be complete email addresses.
|
||||||
for line in destination.split("\n"):
|
for line in forwards_to.split("\n"):
|
||||||
for email in line.split(","):
|
for email in line.split(","):
|
||||||
email = email.strip()
|
email = email.strip()
|
||||||
if email == "": continue
|
if email == "": continue
|
||||||
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||||
if not validate_email(email):
|
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):
|
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
|
# Make domain control validation hijacking a little harder to mess up by
|
||||||
# requiring aliases for email addresses typically used in DCV to forward
|
# requiring aliases for email addresses typically used in DCV to forward
|
||||||
# only to accounts that are administrators on this system.
|
# only to accounts that are administrators on this system.
|
||||||
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
||||||
dests.append(email)
|
validated_forwards_to.append(email)
|
||||||
if len(destination) == 0:
|
|
||||||
return ("No destination email address(es) provided.", 400)
|
# validate permitted_senders
|
||||||
destination = ",".join(dests)
|
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
|
# 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)
|
conn, c = open_database(env, with_connection=True)
|
||||||
try:
|
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"
|
return_status = "alias added"
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
if not update_if_exists:
|
if not update_if_exists:
|
||||||
return ("Alias already exists (%s)." % source, 400)
|
return ("Alias already exists (%s)." % address, 400)
|
||||||
else:
|
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"
|
return_status = "alias updated"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -471,15 +488,15 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
|
|||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, return_status)
|
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
|
# convert Unicode domain to IDNA
|
||||||
source = sanitize_idn_email_address(source)
|
address = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
# remove
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
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:
|
if c.rowcount != 1:
|
||||||
return ("That's not an alias (%s)." % source, 400)
|
return ("That's not an alias (%s)." % address, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
if do_kick:
|
if do_kick:
|
||||||
@@ -493,25 +510,31 @@ def get_required_aliases(env):
|
|||||||
# These are the aliases that must exist.
|
# These are the aliases that must exist.
|
||||||
aliases = set()
|
aliases = set()
|
||||||
|
|
||||||
|
# The system administrator alias is required.
|
||||||
|
aliases.add(get_system_administrator(env))
|
||||||
|
|
||||||
# The hostmaster alias is exposed in the DNS SOA for each zone.
|
# The hostmaster alias is exposed in the DNS SOA for each zone.
|
||||||
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME'])
|
||||||
|
|
||||||
# Get a list of domains we serve mail for, except ones for which the only
|
# Get a list of domains we serve mail for, except ones for which the only
|
||||||
# email on that domain is a postmaster/admin alias to the administrator
|
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
||||||
# or a wildcard alias (since it will forward postmaster/admin).
|
|
||||||
real_mail_domains = get_mail_domains(env,
|
real_mail_domains = get_mail_domains(env,
|
||||||
filter_aliases = lambda alias :
|
filter_aliases = lambda alias :
|
||||||
((not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@")) or alias[1] != get_system_administrator(env))
|
not alias.startswith("postmaster@")
|
||||||
and not alias[0].startswith("@")
|
and not alias.startswith("admin@")
|
||||||
|
and not alias.startswith("abuse@")
|
||||||
|
and not alias.startswith("@")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create postmaster@ and admin@ for all domains we serve mail on.
|
# Create postmaster@, admin@ and abuse@ for all domains we serve
|
||||||
# postmaster@ is assumed to exist by our Postfix configuration. admin@
|
# mail on. postmaster@ is assumed to exist by our Postfix configuration.
|
||||||
# isn't anything, but it might save the user some trouble e.g. when
|
# admin@isn't anything, but it might save the user some trouble e.g. when
|
||||||
# buying an SSL certificate.
|
# buying an SSL certificate.
|
||||||
|
# abuse@ is part of RFC2142: https://www.ietf.org/rfc/rfc2142.txt
|
||||||
for domain in real_mail_domains:
|
for domain in real_mail_domains:
|
||||||
aliases.add("postmaster@" + domain)
|
aliases.add("postmaster@" + domain)
|
||||||
aliases.add("admin@" + domain)
|
aliases.add("admin@" + domain)
|
||||||
|
aliases.add("abuse@" + domain)
|
||||||
|
|
||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
@@ -526,36 +549,38 @@ def kick(env, mail_result=None):
|
|||||||
# Ensure every required alias exists.
|
# Ensure every required alias exists.
|
||||||
|
|
||||||
existing_users = get_mail_users(env)
|
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)
|
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 a user account exists with that address, we're good.
|
||||||
if source in existing_users:
|
if address in existing_users:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Does this alias exists?
|
# If the alias already exists, we're good.
|
||||||
for s, t in existing_aliases:
|
if address in existing_aliases:
|
||||||
if s == source:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Doesn't exist.
|
# Doesn't exist.
|
||||||
administrator = get_system_administrator(env)
|
administrator = get_system_administrator(env)
|
||||||
add_mail_alias(source, administrator, env, do_kick=False)
|
if address == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually
|
||||||
results.append("added alias %s (=> %s)\n" % (source, administrator))
|
add_mail_alias(address, administrator, "", env, do_kick=False)
|
||||||
|
if administrator not in existing_aliases: return # don't report the alias in output if the administrator alias isn't in yet -- this is a hack to supress confusing output on initial setup
|
||||||
|
results.append("added alias %s (=> %s)\n" % (address, administrator))
|
||||||
|
|
||||||
|
for address in required_aliases:
|
||||||
for alias in required_aliases:
|
ensure_admin_alias_exists(address)
|
||||||
ensure_admin_alias_exists(alias)
|
|
||||||
|
|
||||||
# Remove auto-generated postmaster/admin on domains we no
|
# Remove auto-generated postmaster/admin on domains we no
|
||||||
# longer have any other email addresses for.
|
# longer have any other email addresses for.
|
||||||
for source, target in existing_aliases:
|
for address, forwards_to, *_ in existing_alias_records:
|
||||||
user, domain = source.split("@")
|
user, domain = address.split("@")
|
||||||
if user in ("postmaster", "admin") \
|
if user in ("postmaster", "admin", "abuse") \
|
||||||
and source not in required_aliases \
|
and address not in required_aliases \
|
||||||
and target == get_system_administrator(env):
|
and forwards_to == get_system_administrator(env):
|
||||||
remove_mail_alias(source, env, do_kick=False)
|
remove_mail_alias(address, env, do_kick=False)
|
||||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
|
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.
|
# Update DNS and nginx in case any domains are added/removed.
|
||||||
|
|
||||||
|
|||||||
793
management/ssl_certificates.py
Executable file
793
management/ssl_certificates.py
Executable file
@@ -0,0 +1,793 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# Utilities for installing and selecting SSL certificates.
|
||||||
|
|
||||||
|
import os, os.path, re, shutil
|
||||||
|
|
||||||
|
from utils import shell, safe_domain_name, sort_domains
|
||||||
|
|
||||||
|
import idna
|
||||||
|
|
||||||
|
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
||||||
|
|
||||||
|
def get_ssl_certificates(env):
|
||||||
|
# Scan all of the installed SSL certificates and map every domain
|
||||||
|
# that the certificates are good for to the best certificate for
|
||||||
|
# the domain.
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
|
from cryptography.x509 import Certificate
|
||||||
|
|
||||||
|
# The certificates are all stored here:
|
||||||
|
ssl_root = os.path.join(env["STORAGE_ROOT"], 'ssl')
|
||||||
|
|
||||||
|
# List all of the files in the SSL directory and one level deep.
|
||||||
|
def get_file_list():
|
||||||
|
if not os.path.exists(ssl_root):
|
||||||
|
return
|
||||||
|
for fn in os.listdir(ssl_root):
|
||||||
|
fn = os.path.join(ssl_root, fn)
|
||||||
|
if os.path.isfile(fn):
|
||||||
|
yield fn
|
||||||
|
elif os.path.isdir(fn):
|
||||||
|
for fn1 in os.listdir(fn):
|
||||||
|
fn1 = os.path.join(fn, fn1)
|
||||||
|
if os.path.isfile(fn1):
|
||||||
|
yield fn1
|
||||||
|
|
||||||
|
# Remember stuff.
|
||||||
|
private_keys = { }
|
||||||
|
certificates = [ ]
|
||||||
|
|
||||||
|
# Scan each of the files to find private keys and certificates.
|
||||||
|
# We must load all of the private keys first before processing
|
||||||
|
# certificates so that we can check that we have a private key
|
||||||
|
# available before using a certificate.
|
||||||
|
for fn in get_file_list():
|
||||||
|
try:
|
||||||
|
pem = load_pem(load_cert_chain(fn)[0])
|
||||||
|
except ValueError:
|
||||||
|
# Not a valid PEM format for a PEM type we care about.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remember where we got this object.
|
||||||
|
pem._filename = fn
|
||||||
|
|
||||||
|
# Is it a private key?
|
||||||
|
if isinstance(pem, RSAPrivateKey):
|
||||||
|
private_keys[pem.public_key().public_numbers()] = pem
|
||||||
|
|
||||||
|
# Is it a certificate?
|
||||||
|
if isinstance(pem, Certificate):
|
||||||
|
certificates.append(pem)
|
||||||
|
|
||||||
|
# Process the certificates.
|
||||||
|
domains = { }
|
||||||
|
for cert in certificates:
|
||||||
|
# What domains is this certificate good for?
|
||||||
|
cert_domains, primary_domain = get_certificate_domains(cert)
|
||||||
|
cert._primary_domain = primary_domain
|
||||||
|
|
||||||
|
# Is there a private key file for this certificate?
|
||||||
|
private_key = private_keys.get(cert.public_key().public_numbers())
|
||||||
|
if not private_key:
|
||||||
|
continue
|
||||||
|
cert._private_key = private_key
|
||||||
|
|
||||||
|
# Add this cert to the list of certs usable for the domains.
|
||||||
|
for domain in cert_domains:
|
||||||
|
domains.setdefault(domain, []).append(cert)
|
||||||
|
|
||||||
|
# Sort the certificates to prefer good ones.
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
ret = { }
|
||||||
|
for domain, cert_list in domains.items():
|
||||||
|
cert_list.sort(key = lambda cert : (
|
||||||
|
# must be valid NOW
|
||||||
|
cert.not_valid_before <= now <= cert.not_valid_after,
|
||||||
|
|
||||||
|
# prefer one that is not self-signed
|
||||||
|
cert.issuer != cert.subject,
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
# The above lines ensure that valid certificates are chosen
|
||||||
|
# over invalid certificates. The lines below choose between
|
||||||
|
# multiple valid certificates available for this domain.
|
||||||
|
###########################################################
|
||||||
|
|
||||||
|
# prefer one with the expiration furthest into the future so
|
||||||
|
# that we can easily rotate to new certs as we get them
|
||||||
|
cert.not_valid_after,
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
# We always choose the certificate that is good for the
|
||||||
|
# longest period of time. This is important for how we
|
||||||
|
# provision certificates for Let's Encrypt. To ensure that
|
||||||
|
# we don't re-provision every night, we have to ensure that
|
||||||
|
# if we choose to provison a certificate that it will
|
||||||
|
# *actually* be used so the provisioning logic knows it
|
||||||
|
# doesn't still need to provision a certificate for the
|
||||||
|
# domain.
|
||||||
|
###########################################################
|
||||||
|
|
||||||
|
# in case a certificate is installed in multiple paths,
|
||||||
|
# prefer the... lexicographically last one?
|
||||||
|
cert._filename,
|
||||||
|
|
||||||
|
), reverse=True)
|
||||||
|
cert = cert_list.pop(0)
|
||||||
|
ret[domain] = {
|
||||||
|
"private-key": cert._private_key._filename,
|
||||||
|
"certificate": cert._filename,
|
||||||
|
"primary-domain": cert._primary_domain,
|
||||||
|
"certificate_object": cert,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False, raw=False):
|
||||||
|
# Get the system certificate info.
|
||||||
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
|
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
|
system_certificate = {
|
||||||
|
"private-key": ssl_private_key,
|
||||||
|
"certificate": ssl_certificate,
|
||||||
|
"primary-domain": env['PRIMARY_HOSTNAME'],
|
||||||
|
"certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
# The primary domain must use the server certificate because
|
||||||
|
# it is hard-coded in some service configuration files.
|
||||||
|
return system_certificate
|
||||||
|
|
||||||
|
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||||
|
if domain in ssl_certificates:
|
||||||
|
return ssl_certificates[domain]
|
||||||
|
elif wildcard_domain in ssl_certificates:
|
||||||
|
return ssl_certificates[wildcard_domain]
|
||||||
|
elif not allow_missing_cert:
|
||||||
|
# No valid certificate is available for this domain! Return default files.
|
||||||
|
return system_certificate
|
||||||
|
else:
|
||||||
|
# No valid certificate is available for this domain.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
|
||||||
|
|
||||||
|
def get_certificates_to_provision(env, show_extended_problems=True, force_domains=None):
|
||||||
|
# Get a set of domain names that we should now provision certificates
|
||||||
|
# for. Provision if a domain name has no valid certificate or if any
|
||||||
|
# certificate is expiring in 14 days. If provisioning anything, also
|
||||||
|
# provision certificates expiring within 30 days. The period between
|
||||||
|
# 14 and 30 days allows us to consolidate domains into multi-domain
|
||||||
|
# certificates for domains expiring around the same time.
|
||||||
|
|
||||||
|
from web_update import get_web_domains
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
|
||||||
|
# Get domains with missing & expiring certificates.
|
||||||
|
certs = get_ssl_certificates(env)
|
||||||
|
domains = set()
|
||||||
|
domains_if_any = set()
|
||||||
|
problems = { }
|
||||||
|
for domain in get_web_domains(env):
|
||||||
|
# If the user really wants a cert for certain domains, include it.
|
||||||
|
if force_domains:
|
||||||
|
if force_domains == "ALL" or (isinstance(force_domains, list) and domain in force_domains):
|
||||||
|
domains.add(domain)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Include this domain if its certificate is missing, self-signed, or expiring soon.
|
||||||
|
try:
|
||||||
|
cert = get_domain_ssl_files(domain, certs, env, allow_missing_cert=True)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# system certificate is not present
|
||||||
|
problems[domain] = "Error: " + str(e)
|
||||||
|
continue
|
||||||
|
if cert is None:
|
||||||
|
# No valid certificate available.
|
||||||
|
domains.add(domain)
|
||||||
|
else:
|
||||||
|
cert = cert["certificate_object"]
|
||||||
|
if cert.issuer == cert.subject:
|
||||||
|
# This is self-signed. Get a real one.
|
||||||
|
domains.add(domain)
|
||||||
|
|
||||||
|
# Valid certificate today, but is it expiring soon?
|
||||||
|
elif cert.not_valid_after-now < datetime.timedelta(days=14):
|
||||||
|
domains.add(domain)
|
||||||
|
elif cert.not_valid_after-now < datetime.timedelta(days=30):
|
||||||
|
domains_if_any.add(domain)
|
||||||
|
|
||||||
|
# It's valid. Should we report its validness?
|
||||||
|
elif show_extended_problems:
|
||||||
|
problems[domain] = "The certificate is valid for at least another 30 days --- no need to replace."
|
||||||
|
|
||||||
|
# Warn the user about domains hosted elsewhere.
|
||||||
|
if not force_domains and show_extended_problems:
|
||||||
|
for domain in set(get_web_domains(env, exclude_dns_elsewhere=False)) - set(get_web_domains(env)):
|
||||||
|
problems[domain] = "The domain's DNS is pointed elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here)."
|
||||||
|
|
||||||
|
# Filter out domains that we can't provision a certificate for.
|
||||||
|
def can_provision_for_domain(domain):
|
||||||
|
# Let's Encrypt doesn't yet support IDNA domains.
|
||||||
|
# We store domains in IDNA (ASCII). To see if this domain is IDNA,
|
||||||
|
# we'll see if its IDNA-decoded form is different.
|
||||||
|
if idna.decode(domain.encode("ascii")) != domain:
|
||||||
|
problems[domain] = "Let's Encrypt does not yet support provisioning certificates for internationalized domains."
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Does the domain resolve to this machine in public DNS? If not,
|
||||||
|
# we can't do domain control validation. For IPv6 is configured,
|
||||||
|
# make sure both IPv4 and IPv6 are correct because we don't know
|
||||||
|
# how Let's Encrypt will connect.
|
||||||
|
import dns.resolver
|
||||||
|
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
|
||||||
|
if not value: continue # IPv6 is not configured
|
||||||
|
try:
|
||||||
|
# Must make the qname absolute to prevent a fall-back lookup with a
|
||||||
|
# search domain appended, by adding a period to the end.
|
||||||
|
response = dns.resolver.query(domain + ".", rtype)
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
|
||||||
|
problems[domain] = "DNS isn't configured properly for this domain: DNS resolution failed (%s: %s)." % (rtype, str(e) or repr(e)) # NoAnswer's str is empty
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
problems[domain] = "DNS isn't configured properly for this domain: DNS lookup had an error: %s." % str(e)
|
||||||
|
return False
|
||||||
|
if len(response) != 1 or str(response[0]) != value:
|
||||||
|
problems[domain] = "Domain control validation cannot be performed for this domain because DNS points the domain to another machine (%s %s)." % (rtype, ", ".join(str(r) for r in response))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
domains = set(filter(can_provision_for_domain, domains))
|
||||||
|
|
||||||
|
# If there are any domains we definitely will provision for, add in
|
||||||
|
# additional domains to do at this time.
|
||||||
|
if len(domains) > 0:
|
||||||
|
domains |= set(filter(can_provision_for_domain, domains_if_any))
|
||||||
|
|
||||||
|
return (domains, problems)
|
||||||
|
|
||||||
|
def provision_certificates(env, agree_to_tos_url=None, logger=None, show_extended_problems=True, force_domains=None, jsonable=False):
|
||||||
|
import requests.exceptions
|
||||||
|
import acme.messages
|
||||||
|
|
||||||
|
from free_tls_certificates import client
|
||||||
|
|
||||||
|
# What domains should we provision certificates for? And what
|
||||||
|
# errors prevent provisioning for other domains.
|
||||||
|
domains, problems = get_certificates_to_provision(env, force_domains=force_domains, show_extended_problems=show_extended_problems)
|
||||||
|
|
||||||
|
# Exit fast if there is nothing to do.
|
||||||
|
if len(domains) == 0:
|
||||||
|
return {
|
||||||
|
"requests": [],
|
||||||
|
"problems": problems,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Break into groups of up to 100 certificates at a time, which is Let's Encrypt's
|
||||||
|
# limit for a single certificate. We'll sort to put related domains together.
|
||||||
|
domains = sort_domains(domains, env)
|
||||||
|
certs = []
|
||||||
|
while len(domains) > 0:
|
||||||
|
certs.append( domains[0:100] )
|
||||||
|
domains = domains[100:]
|
||||||
|
|
||||||
|
# Prepare to provision.
|
||||||
|
|
||||||
|
# Where should we put our Let's Encrypt account info and state cache.
|
||||||
|
account_path = os.path.join(env['STORAGE_ROOT'], 'ssl/lets_encrypt')
|
||||||
|
if not os.path.exists(account_path):
|
||||||
|
os.mkdir(account_path)
|
||||||
|
|
||||||
|
# Where should we put ACME challenge files. This is mapped to /.well-known/acme_challenge
|
||||||
|
# by the nginx configuration.
|
||||||
|
challenges_path = os.path.join(account_path, 'acme_challenges')
|
||||||
|
if not os.path.exists(challenges_path):
|
||||||
|
os.mkdir(challenges_path)
|
||||||
|
|
||||||
|
# Read in the private key that we use for all TLS certificates. We'll need that
|
||||||
|
# to generate a CSR (done by free_tls_certificates).
|
||||||
|
with open(os.path.join(env['STORAGE_ROOT'], 'ssl/ssl_private_key.pem'), 'rb') as f:
|
||||||
|
private_key = f.read()
|
||||||
|
|
||||||
|
# Provision certificates.
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
for domain_list in certs:
|
||||||
|
# For return.
|
||||||
|
ret_item = {
|
||||||
|
"domains": domain_list,
|
||||||
|
"log": [],
|
||||||
|
}
|
||||||
|
ret.append(ret_item)
|
||||||
|
|
||||||
|
# Logging for free_tls_certificates.
|
||||||
|
def my_logger(message):
|
||||||
|
if logger: logger(message)
|
||||||
|
ret_item["log"].append(message)
|
||||||
|
|
||||||
|
# Attempt to provision a certificate.
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
cert = client.issue_certificate(
|
||||||
|
domain_list,
|
||||||
|
account_path,
|
||||||
|
agree_to_tos_url=agree_to_tos_url,
|
||||||
|
private_key=private_key,
|
||||||
|
logger=my_logger)
|
||||||
|
|
||||||
|
except client.NeedToTakeAction as e:
|
||||||
|
# Write out the ACME challenge files.
|
||||||
|
for action in e.actions:
|
||||||
|
if isinstance(action, client.NeedToInstallFile):
|
||||||
|
fn = os.path.join(challenges_path, action.file_name)
|
||||||
|
with open(fn, 'w') as f:
|
||||||
|
f.write(action.contents)
|
||||||
|
else:
|
||||||
|
raise ValueError(str(action))
|
||||||
|
|
||||||
|
# Try to provision now that the challenge files are installed.
|
||||||
|
|
||||||
|
cert = client.issue_certificate(
|
||||||
|
domain_list,
|
||||||
|
account_path,
|
||||||
|
private_key=private_key,
|
||||||
|
logger=my_logger)
|
||||||
|
|
||||||
|
except client.NeedToAgreeToTOS as e:
|
||||||
|
# The user must agree to the Let's Encrypt terms of service agreement
|
||||||
|
# before any further action can be taken.
|
||||||
|
ret_item.update({
|
||||||
|
"result": "agree-to-tos",
|
||||||
|
"url": e.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
except client.WaitABit as e:
|
||||||
|
# We need to hold on for a bit before querying again to see if we can
|
||||||
|
# acquire a provisioned certificate.
|
||||||
|
import time, datetime
|
||||||
|
ret_item.update({
|
||||||
|
"result": "wait",
|
||||||
|
"until": e.until_when if not jsonable else e.until_when.isoformat(),
|
||||||
|
"seconds": (e.until_when - datetime.datetime.now()).total_seconds()
|
||||||
|
})
|
||||||
|
|
||||||
|
except client.AccountDataIsCorrupt as e:
|
||||||
|
# This is an extremely rare condition.
|
||||||
|
ret_item.update({
|
||||||
|
"result": "error",
|
||||||
|
"message": "Something unexpected went wrong. It looks like your local Let's Encrypt account data is corrupted. There was a problem with the file " + e.account_file_path + ".",
|
||||||
|
})
|
||||||
|
|
||||||
|
except (client.InvalidDomainName, client.NeedToTakeAction, client.ChallengeFailed, acme.messages.Error, requests.exceptions.RequestException) as e:
|
||||||
|
ret_item.update({
|
||||||
|
"result": "error",
|
||||||
|
"message": "Something unexpected went wrong: " + str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
# A certificate was issued.
|
||||||
|
|
||||||
|
install_status = install_cert(domain_list[0], cert['cert'].decode("ascii"), b"\n".join(cert['chain']).decode("ascii"), env, raw=True)
|
||||||
|
|
||||||
|
# str indicates the certificate was not installed.
|
||||||
|
if isinstance(install_status, str):
|
||||||
|
ret_item.update({
|
||||||
|
"result": "error",
|
||||||
|
"message": "Something unexpected was wrong with the provisioned certificate: " + install_status,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# A list indicates success and what happened next.
|
||||||
|
ret_item["log"].extend(install_status)
|
||||||
|
ret_item.update({
|
||||||
|
"result": "installed",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Return what happened with each certificate request.
|
||||||
|
return {
|
||||||
|
"requests": ret,
|
||||||
|
"problems": problems,
|
||||||
|
}
|
||||||
|
|
||||||
|
def provision_certificates_cmdline():
|
||||||
|
import sys
|
||||||
|
from utils import load_environment, exclusive_process
|
||||||
|
|
||||||
|
exclusive_process("update_tls_certificates")
|
||||||
|
env = load_environment()
|
||||||
|
|
||||||
|
verbose = False
|
||||||
|
headless = False
|
||||||
|
force_domains = None
|
||||||
|
show_extended_problems = True
|
||||||
|
|
||||||
|
args = list(sys.argv)
|
||||||
|
args.pop(0) # program name
|
||||||
|
if args and args[0] == "-v":
|
||||||
|
verbose = True
|
||||||
|
args.pop(0)
|
||||||
|
if args and args[0] == "q":
|
||||||
|
show_extended_problems = False
|
||||||
|
args.pop(0)
|
||||||
|
if args and args[0] == "--headless":
|
||||||
|
headless = True
|
||||||
|
args.pop(0)
|
||||||
|
if args and args[0] == "--force":
|
||||||
|
force_domains = "ALL"
|
||||||
|
args.pop(0)
|
||||||
|
else:
|
||||||
|
force_domains = args
|
||||||
|
|
||||||
|
agree_to_tos_url = None
|
||||||
|
while True:
|
||||||
|
# Run the provisioning script. This installs certificates. If there are
|
||||||
|
# a very large number of domains on this box, it issues separate
|
||||||
|
# certificates for groups of domains. We have to check the result for
|
||||||
|
# each group.
|
||||||
|
def my_logger(message):
|
||||||
|
if verbose:
|
||||||
|
print(">", message)
|
||||||
|
status = provision_certificates(env, agree_to_tos_url=agree_to_tos_url, logger=my_logger, force_domains=force_domains, show_extended_problems=show_extended_problems)
|
||||||
|
agree_to_tos_url = None # reset to prevent infinite looping
|
||||||
|
|
||||||
|
if not status["requests"]:
|
||||||
|
# No domains need certificates.
|
||||||
|
if not headless or verbose:
|
||||||
|
if len(status["problems"]) == 0:
|
||||||
|
print("No domains hosted on this box need a new TLS certificate at this time.")
|
||||||
|
elif len(status["problems"]) > 0:
|
||||||
|
print("No TLS certificates could be provisoned at this time:")
|
||||||
|
print()
|
||||||
|
for domain in sort_domains(status["problems"], env):
|
||||||
|
print("%s: %s" % (domain, status["problems"][domain]))
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# What happened?
|
||||||
|
wait_until = None
|
||||||
|
wait_domains = []
|
||||||
|
for request in status["requests"]:
|
||||||
|
if request["result"] == "agree-to-tos":
|
||||||
|
# We may have asked already in a previous iteration.
|
||||||
|
if agree_to_tos_url is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Can't ask the user a question in this mode. Warn the user that something
|
||||||
|
# needs to be done.
|
||||||
|
if headless:
|
||||||
|
print(", ".join(request["domains"]) + " need a new or renewed TLS certificate.")
|
||||||
|
print()
|
||||||
|
print("This box can't do that automatically for you until you agree to Let's Encrypt's")
|
||||||
|
print("Terms of Service agreement. Use the Mail-in-a-Box control panel to provision")
|
||||||
|
print("certificates for these domains.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("""
|
||||||
|
I'm going to provision a TLS certificate (formerly called a SSL certificate)
|
||||||
|
for you from Let's Encrypt (letsencrypt.org).
|
||||||
|
|
||||||
|
TLS certificates are cryptographic keys that ensure communication between
|
||||||
|
you and this box are secure when getting and sending mail and visiting
|
||||||
|
websites hosted on this box. Let's Encrypt is a free provider of TLS
|
||||||
|
certificates.
|
||||||
|
|
||||||
|
Please open this document in your web browser:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
It is Let's Encrypt's terms of service agreement. If you agree, I can
|
||||||
|
provision that TLS certificate. If you don't agree, you will have an
|
||||||
|
opportunity to install your own TLS certificate from the Mail-in-a-Box
|
||||||
|
control panel.
|
||||||
|
|
||||||
|
Do you agree to the agreement? Type Y or N and press <ENTER>: """
|
||||||
|
% request["url"], end='', flush=True)
|
||||||
|
|
||||||
|
if sys.stdin.readline().strip().upper() != "Y":
|
||||||
|
print("\nYou didn't agree. Quitting.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Okay, indicate agreement on next iteration.
|
||||||
|
agree_to_tos_url = request["url"]
|
||||||
|
|
||||||
|
if request["result"] == "wait":
|
||||||
|
# Must wait. We'll record until when. The wait occurs below.
|
||||||
|
if wait_until is None:
|
||||||
|
wait_until = request["until"]
|
||||||
|
else:
|
||||||
|
wait_until = max(wait_until, request["until"])
|
||||||
|
wait_domains += request["domains"]
|
||||||
|
|
||||||
|
if request["result"] == "error":
|
||||||
|
print(", ".join(request["domains"]) + ":")
|
||||||
|
print(request["message"])
|
||||||
|
|
||||||
|
if request["result"] == "installed":
|
||||||
|
print("A TLS certificate was successfully installed for " + ", ".join(request["domains"]) + ".")
|
||||||
|
|
||||||
|
if wait_until:
|
||||||
|
# Wait, then loop.
|
||||||
|
import time, datetime
|
||||||
|
print()
|
||||||
|
print("A TLS certificate was requested for: " + ", ".join(wait_domains) + ".")
|
||||||
|
first = True
|
||||||
|
while wait_until > datetime.datetime.now():
|
||||||
|
if not headless or first:
|
||||||
|
print ("We have to wait", int(round((wait_until - datetime.datetime.now()).total_seconds())), "seconds for the certificate to be issued...")
|
||||||
|
time.sleep(10)
|
||||||
|
first = False
|
||||||
|
|
||||||
|
continue # Loop!
|
||||||
|
|
||||||
|
if agree_to_tos_url:
|
||||||
|
# The user agrees to the TOS. Loop to try again by agreeing.
|
||||||
|
continue # Loop!
|
||||||
|
|
||||||
|
# Unless we were instructed to wait, or we just agreed to the TOS,
|
||||||
|
# we're done for now.
|
||||||
|
break
|
||||||
|
|
||||||
|
# And finally show the domains with problems.
|
||||||
|
if len(status["problems"]) > 0:
|
||||||
|
print("TLS certificates could not be provisoned for:")
|
||||||
|
for domain in sort_domains(status["problems"], env):
|
||||||
|
print("%s: %s" % (domain, status["problems"][domain]))
|
||||||
|
|
||||||
|
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
|
||||||
|
|
||||||
|
def create_csr(domain, ssl_key, country_code, env):
|
||||||
|
return shell("check_output", [
|
||||||
|
"openssl", "req", "-new",
|
||||||
|
"-key", ssl_key,
|
||||||
|
"-sha256",
|
||||||
|
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (country_code, domain)])
|
||||||
|
|
||||||
|
def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
||||||
|
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||||
|
# The certificate always goes above the chain.
|
||||||
|
import tempfile
|
||||||
|
fd, fn = tempfile.mkstemp('.pem')
|
||||||
|
os.write(fd, (ssl_cert + '\n' + ssl_chain).encode("ascii"))
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
# Do validation on the certificate before installing it.
|
||||||
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
|
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)
|
||||||
|
if cert_status != "OK":
|
||||||
|
if cert_status == "SELF-SIGNED":
|
||||||
|
cert_status = "This is a self-signed certificate. I can't install that."
|
||||||
|
os.unlink(fn)
|
||||||
|
if cert_status_details is not None:
|
||||||
|
cert_status += " " + cert_status_details
|
||||||
|
return cert_status
|
||||||
|
|
||||||
|
# Where to put it?
|
||||||
|
# Make a unique path for the certificate.
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from binascii import hexlify
|
||||||
|
cert = load_pem(load_cert_chain(fn)[0])
|
||||||
|
all_domains, cn = get_certificate_domains(cert)
|
||||||
|
path = "%s-%s-%s.pem" % (
|
||||||
|
safe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
|
||||||
|
cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date
|
||||||
|
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
||||||
|
)
|
||||||
|
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
||||||
|
|
||||||
|
# Install the certificate.
|
||||||
|
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||||
|
shutil.move(fn, ssl_certificate)
|
||||||
|
|
||||||
|
ret = ["OK"]
|
||||||
|
|
||||||
|
# When updating the cert for PRIMARY_HOSTNAME, symlink it from the system
|
||||||
|
# certificate path, which is hard-coded for various purposes, and then
|
||||||
|
# restart postfix and dovecot.
|
||||||
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
# Update symlink.
|
||||||
|
system_ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
|
os.unlink(system_ssl_certificate)
|
||||||
|
os.symlink(ssl_certificate, system_ssl_certificate)
|
||||||
|
|
||||||
|
# Restart postfix and dovecot so they pick up the new file.
|
||||||
|
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
|
||||||
|
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
|
||||||
|
ret.append("mail services restarted")
|
||||||
|
|
||||||
|
# The DANE TLSA record will remain valid so long as the private key
|
||||||
|
# hasn't changed. We don't ever change the private key automatically.
|
||||||
|
# If the user does it, they must manually update DNS.
|
||||||
|
|
||||||
|
# Update the web configuration so nginx picks up the new certificate file.
|
||||||
|
from web_update import do_web_update
|
||||||
|
ret.append( do_web_update(env) )
|
||||||
|
if raw: return ret
|
||||||
|
return "\n".join(ret)
|
||||||
|
|
||||||
|
# VALIDATION OF CERTIFICATES
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
|
from cryptography.x509 import Certificate
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# First check that the domain name is one of the names allowed by
|
||||||
|
# the certificate.
|
||||||
|
if domain is not None:
|
||||||
|
certificate_names, cert_primary_name = get_certificate_domains(cert)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
if ssl_private_key is not None:
|
||||||
|
try:
|
||||||
|
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
||||||
|
except ValueError as e:
|
||||||
|
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
||||||
|
|
||||||
|
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.
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.utcnow()
|
||||||
|
if not(cert.not_valid_before <= now <= cert.not_valid_after):
|
||||||
|
return ("The certificate has expired or is not yet valid. It is valid from %s to %s." % (cert.not_valid_before, cert.not_valid_after), None)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# The certificate chain has to be passed separately and is given via STDIN.
|
||||||
|
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||||
|
retcode, verifyoutput = shell('check_output', [
|
||||||
|
"openssl",
|
||||||
|
"verify", "-verbose",
|
||||||
|
"-purpose", "sslserver", "-policy_check",]
|
||||||
|
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
||||||
|
+ [ssl_certificate],
|
||||||
|
input=b"\n\n".join(ssl_cert_chain[1:]),
|
||||||
|
trap=True)
|
||||||
|
|
||||||
|
if "self signed" in verifyoutput:
|
||||||
|
# Certificate is self-signed. Probably we detected this above.
|
||||||
|
return ("SELF-SIGNED", None)
|
||||||
|
|
||||||
|
elif retcode != 0:
|
||||||
|
if "unable to get local issuer certificate" in verifyoutput:
|
||||||
|
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
||||||
|
|
||||||
|
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||||
|
return ("There is a problem with the certificate.", verifyoutput.strip())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# `openssl verify` returned a zero exit status so the cert is currently
|
||||||
|
# good.
|
||||||
|
|
||||||
|
# But is it expiring soon?
|
||||||
|
cert_expiration_date = cert.not_valid_after
|
||||||
|
ndays = (cert_expiration_date-now).days
|
||||||
|
if not rounded_time or ndays <= 10:
|
||||||
|
# Yikes better renew soon!
|
||||||
|
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x"))
|
||||||
|
else:
|
||||||
|
# We'll renew it with Lets Encrypt.
|
||||||
|
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
|
||||||
|
|
||||||
|
if ndays <= 10 and warn_if_expiring_soon:
|
||||||
|
# Warn on day 10 to give 4 days for us to automatically renew the
|
||||||
|
# certificate, which occurs on day 14.
|
||||||
|
return ("The certificate is expiring soon: " + expiry_info, None)
|
||||||
|
|
||||||
|
# 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"))
|
||||||
|
|
||||||
|
def get_certificate_domains(cert):
|
||||||
|
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
|
||||||
|
import idna
|
||||||
|
|
||||||
|
names = set()
|
||||||
|
cn = 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.
|
||||||
|
try:
|
||||||
|
cn = cert.subject.get_attributes_for_oid(OID_COMMON_NAME)[0].value
|
||||||
|
names.add(cn)
|
||||||
|
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
|
||||||
|
|
||||||
|
# ... 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')
|
||||||
|
|
||||||
|
try:
|
||||||
|
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
||||||
|
for san in sans:
|
||||||
|
names.add(idna_decode_dns_name(san))
|
||||||
|
except ExtensionNotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return names, cn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Provision certificates.
|
||||||
|
provision_certificates_cmdline()
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
#
|
#
|
||||||
# Checks that the upstream DNS has been set correctly and that
|
# Checks that the upstream DNS has been set correctly and that
|
||||||
# SSL certificates have been signed, etc., and if not tells the user
|
# TLS certificates have been signed, etc., and if not tells the user
|
||||||
# what to do next.
|
# what to do next.
|
||||||
|
|
||||||
__ALL__ = ['check_certificate']
|
|
||||||
|
|
||||||
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
import sys, os, os.path, re, subprocess, datetime, multiprocessing.pool
|
||||||
|
|
||||||
import dns.reversename, dns.resolver
|
import dns.reversename, dns.resolver
|
||||||
import dateutil.parser, dateutil.tz
|
import dateutil.parser, dateutil.tz
|
||||||
|
import idna
|
||||||
|
import psutil
|
||||||
|
|
||||||
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns
|
from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, get_secondary_dns, get_custom_dns_record
|
||||||
from web_update import get_web_domains, get_domain_ssl_files
|
from web_update import get_web_domains, get_domains_with_a_records
|
||||||
|
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
|
||||||
from mailconfig import get_mail_domains, get_mail_aliases
|
from mailconfig import get_mail_domains, get_mail_aliases
|
||||||
|
|
||||||
from utils import shell, sort_domains, load_env_vars_from_file
|
from utils import shell, sort_domains, load_env_vars_from_file, load_settings
|
||||||
|
|
||||||
def run_checks(rounded_values, env, output, pool):
|
def run_checks(rounded_values, env, output, pool):
|
||||||
# run systems checks
|
# run systems checks
|
||||||
@@ -42,15 +43,22 @@ def run_checks(rounded_values, env, output, pool):
|
|||||||
|
|
||||||
def get_ssh_port():
|
def get_ssh_port():
|
||||||
# Returns ssh port
|
# Returns ssh port
|
||||||
|
try:
|
||||||
output = shell('check_output', ['sshd', '-T'])
|
output = shell('check_output', ['sshd', '-T'])
|
||||||
returnNext = False
|
except FileNotFoundError:
|
||||||
|
# sshd is not installed. That's ok.
|
||||||
|
return None
|
||||||
|
|
||||||
|
returnNext = False
|
||||||
for e in output.split():
|
for e in output.split():
|
||||||
if returnNext:
|
if returnNext:
|
||||||
return int(e)
|
return int(e)
|
||||||
if e == "port":
|
if e == "port":
|
||||||
returnNext = True
|
returnNext = True
|
||||||
|
|
||||||
|
# Did not find port!
|
||||||
|
return None
|
||||||
|
|
||||||
def run_services_checks(env, output, pool):
|
def run_services_checks(env, output, pool):
|
||||||
# Check that system services are running.
|
# Check that system services are running.
|
||||||
|
|
||||||
@@ -64,7 +72,6 @@ def run_services_checks(env, output, pool):
|
|||||||
{ "name": "OpenDKIM", "port": 8891, "public": False, },
|
{ "name": "OpenDKIM", "port": 8891, "public": False, },
|
||||||
{ "name": "OpenDMARC", "port": 8893, "public": False, },
|
{ "name": "OpenDMARC", "port": 8893, "public": False, },
|
||||||
{ "name": "Memcached", "port": 11211, "public": False, },
|
{ "name": "Memcached", "port": 11211, "public": False, },
|
||||||
{ "name": "Sieve (dovecot)", "port": 4190, "public": False, },
|
|
||||||
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
|
{ "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, },
|
||||||
|
|
||||||
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
|
{ "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, },
|
||||||
@@ -73,6 +80,7 @@ def run_services_checks(env, output, pool):
|
|||||||
{ "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, },
|
{ "name": "Outgoing Mail (SMTP 587/postfix)", "port": 587, "public": True, },
|
||||||
#{ "name": "Postfix/master", "port": 10587, "public": True, },
|
#{ "name": "Postfix/master", "port": 10587, "public": True, },
|
||||||
{ "name": "IMAPS (dovecot)", "port": 993, "public": True, },
|
{ "name": "IMAPS (dovecot)", "port": 993, "public": True, },
|
||||||
|
{ "name": "Mail Filters (Sieve/dovecot)", "port": 4190, "public": True, },
|
||||||
{ "name": "HTTP Web (nginx)", "port": 80, "public": True, },
|
{ "name": "HTTP Web (nginx)", "port": 80, "public": True, },
|
||||||
{ "name": "HTTPS Web (nginx)", "port": 443, "public": True, },
|
{ "name": "HTTPS Web (nginx)", "port": 443, "public": True, },
|
||||||
]
|
]
|
||||||
@@ -81,6 +89,7 @@ def run_services_checks(env, output, pool):
|
|||||||
fatal = False
|
fatal = False
|
||||||
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
|
ret = pool.starmap(check_service, ((i, service, env) for i, service in enumerate(services)), chunksize=1)
|
||||||
for i, running, fatal2, output2 in sorted(ret):
|
for i, running, fatal2, output2 in sorted(ret):
|
||||||
|
if output2 is None: continue # skip check (e.g. no port was set, e.g. no sshd)
|
||||||
all_running = all_running and running
|
all_running = all_running and running
|
||||||
fatal = fatal or fatal2
|
fatal = fatal or fatal2
|
||||||
output2.playback(output)
|
output2.playback(output)
|
||||||
@@ -91,53 +100,74 @@ def run_services_checks(env, output, pool):
|
|||||||
return not fatal
|
return not fatal
|
||||||
|
|
||||||
def check_service(i, service, env):
|
def check_service(i, service, env):
|
||||||
import socket
|
if not service["port"]:
|
||||||
|
# Skip check (no port, e.g. no sshd).
|
||||||
|
return (i, None, None, None)
|
||||||
|
|
||||||
output = BufferedOutput()
|
output = BufferedOutput()
|
||||||
running = False
|
running = False
|
||||||
fatal = False
|
fatal = False
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
|
# Helper function to make a connection to the service, since we try
|
||||||
|
# up to three ways (localhost, IPv4 address, IPv6 address).
|
||||||
|
def try_connect(ip):
|
||||||
|
# Connect to the given IP address on the service's port with a one-second timeout.
|
||||||
|
import socket
|
||||||
|
s = socket.socket(socket.AF_INET if ":" not in ip else socket.AF_INET6, socket.SOCK_STREAM)
|
||||||
s.settimeout(1)
|
s.settimeout(1)
|
||||||
try:
|
try:
|
||||||
try:
|
s.connect((ip, service["port"]))
|
||||||
s.connect((
|
return True
|
||||||
"127.0.0.1" if not service["public"] else env['PUBLIC_IP'],
|
|
||||||
service["port"]))
|
|
||||||
running = True
|
|
||||||
except OSError as e1:
|
|
||||||
if service["public"] and service["port"] != 53:
|
|
||||||
# For public services (except DNS), try the private IP as a fallback.
|
|
||||||
s1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s1.settimeout(1)
|
|
||||||
try:
|
|
||||||
s1.connect(("127.0.0.1", service["port"]))
|
|
||||||
output.print_error("%s is running but is not publicly accessible at %s:%d (%s)." % (service['name'], env['PUBLIC_IP'], service['port'], str(e1)))
|
|
||||||
except:
|
|
||||||
raise e1
|
|
||||||
finally:
|
|
||||||
s1.close()
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
output.print_error("%s is not running (%s; port %d)." % (service['name'], str(e), service['port']))
|
# timed out or some other odd error
|
||||||
|
return False
|
||||||
# Why is nginx not running?
|
|
||||||
if service["port"] in (80, 443):
|
|
||||||
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
|
|
||||||
|
|
||||||
# Flag if local DNS is not running.
|
|
||||||
if service["port"] == 53 and service["public"] == False:
|
|
||||||
fatal = True
|
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
if service["public"]:
|
||||||
|
# Service should be publicly accessible.
|
||||||
|
if try_connect(env["PUBLIC_IP"]):
|
||||||
|
# IPv4 ok.
|
||||||
|
if not env.get("PUBLIC_IPV6") or service.get("ipv6") is False or try_connect(env["PUBLIC_IPV6"]):
|
||||||
|
# No IPv6, or service isn't meant to run on IPv6, or IPv6 is good.
|
||||||
|
running = True
|
||||||
|
|
||||||
|
# IPv4 ok but IPv6 failed. Try the PRIVATE_IPV6 address to see if the service is bound to the interface.
|
||||||
|
elif service["port"] != 53 and try_connect(env["PRIVATE_IPV6"]):
|
||||||
|
output.print_error("%s is running (and available over IPv4 and the local IPv6 address), but it is not publicly accessible at %s:%d." % (service['name'], env['PUBLIC_IP'], service['port']))
|
||||||
|
else:
|
||||||
|
output.print_error("%s is running and available over IPv4 but is not accessible over IPv6 at %s port %d." % (service['name'], env['PUBLIC_IPV6'], service['port']))
|
||||||
|
|
||||||
|
# IPv4 failed. Try the private IP to see if the service is running but not accessible (except DNS because a different service runs on the private IP).
|
||||||
|
elif service["port"] != 53 and try_connect("127.0.0.1"):
|
||||||
|
output.print_error("%s is running but is not publicly accessible at %s:%d." % (service['name'], env['PUBLIC_IP'], service['port']))
|
||||||
|
else:
|
||||||
|
output.print_error("%s is not running (port %d)." % (service['name'], service['port']))
|
||||||
|
|
||||||
|
# Why is nginx not running?
|
||||||
|
if not running and service["port"] in (80, 443):
|
||||||
|
output.print_line(shell('check_output', ['nginx', '-t'], capture_stderr=True, trap=True)[1].strip())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Service should be running locally.
|
||||||
|
if try_connect("127.0.0.1"):
|
||||||
|
running = True
|
||||||
|
else:
|
||||||
|
output.print_error("%s is not running (port %d)." % (service['name'], service['port']))
|
||||||
|
|
||||||
|
# Flag if local DNS is not running.
|
||||||
|
if not running and service["port"] == 53 and service["public"] == False:
|
||||||
|
fatal = True
|
||||||
|
|
||||||
return (i, running, fatal, output)
|
return (i, running, fatal, output)
|
||||||
|
|
||||||
def run_system_checks(rounded_values, env, output):
|
def run_system_checks(rounded_values, env, output):
|
||||||
check_ssh_password(env, output)
|
check_ssh_password(env, output)
|
||||||
check_software_updates(env, output)
|
check_software_updates(env, output)
|
||||||
|
check_miab_version(env, output)
|
||||||
check_system_aliases(env, output)
|
check_system_aliases(env, output)
|
||||||
check_free_disk_space(rounded_values, env, output)
|
check_free_disk_space(rounded_values, env, output)
|
||||||
|
check_free_memory(rounded_values, env, output)
|
||||||
|
|
||||||
def check_ssh_password(env, output):
|
def check_ssh_password(env, output):
|
||||||
# Check that SSH login with password is disabled. The openssh-server
|
# Check that SSH login with password is disabled. The openssh-server
|
||||||
@@ -188,6 +218,20 @@ def check_free_disk_space(rounded_values, env, output):
|
|||||||
else:
|
else:
|
||||||
output.print_error(disk_msg)
|
output.print_error(disk_msg)
|
||||||
|
|
||||||
|
def check_free_memory(rounded_values, env, output):
|
||||||
|
# Check free memory.
|
||||||
|
percent_free = 100 - psutil.virtual_memory().percent
|
||||||
|
memory_msg = "System memory is %s%% free." % str(round(percent_free))
|
||||||
|
if percent_free >= 20:
|
||||||
|
if rounded_values: memory_msg = "System free memory is at least 20%."
|
||||||
|
output.print_ok(memory_msg)
|
||||||
|
elif percent_free >= 10:
|
||||||
|
if rounded_values: memory_msg = "System free memory is below 20%."
|
||||||
|
output.print_warning(memory_msg)
|
||||||
|
else:
|
||||||
|
if rounded_values: memory_msg = "System free memory is below 10%."
|
||||||
|
output.print_error(memory_msg)
|
||||||
|
|
||||||
def run_network_checks(env, output):
|
def run_network_checks(env, output):
|
||||||
# Also see setup/network-checks.sh.
|
# Also see setup/network-checks.sh.
|
||||||
|
|
||||||
@@ -231,23 +275,35 @@ def run_domain_checks(rounded_time, env, output, pool):
|
|||||||
|
|
||||||
domains_to_check = mail_domains | dns_domains | web_domains
|
domains_to_check = mail_domains | dns_domains | web_domains
|
||||||
|
|
||||||
|
# Get the list of domains that we don't serve web for because of a custom CNAME/A record.
|
||||||
|
domains_with_a_records = get_domains_with_a_records(env)
|
||||||
|
|
||||||
# Serial version:
|
# Serial version:
|
||||||
#for domain in sort_domains(domains_to_check, env):
|
#for domain in sort_domains(domains_to_check, env):
|
||||||
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
# run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
||||||
|
|
||||||
# Parallelize the checks across a worker pool.
|
# Parallelize the checks across a worker pool.
|
||||||
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains)
|
args = ((domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records)
|
||||||
for domain in domains_to_check)
|
for domain in domains_to_check)
|
||||||
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
ret = pool.starmap(run_domain_checks_on_domain, args, chunksize=1)
|
||||||
ret = dict(ret) # (domain, output) => { domain: output }
|
ret = dict(ret) # (domain, output) => { domain: output }
|
||||||
for domain in sort_domains(ret, env):
|
for domain in sort_domains(ret, env):
|
||||||
ret[domain].playback(output)
|
ret[domain].playback(output)
|
||||||
|
|
||||||
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains):
|
def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zonefiles, mail_domains, web_domains, domains_with_a_records):
|
||||||
output = BufferedOutput()
|
output = BufferedOutput()
|
||||||
|
|
||||||
# The domain is IDNA-encoded, but for display use Unicode.
|
# we'd move this up, but this returns non-pickleable values
|
||||||
output.add_heading(domain.encode('ascii').decode('idna'))
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
|
# The domain is IDNA-encoded in the database, but for display use Unicode.
|
||||||
|
try:
|
||||||
|
domain_display = idna.decode(domain.encode('ascii'))
|
||||||
|
output.add_heading(domain_display)
|
||||||
|
except (ValueError, UnicodeError, idna.IDNAError) as e:
|
||||||
|
# Looks like we have some invalid data in our database.
|
||||||
|
output.add_heading(domain)
|
||||||
|
output.print_error("Domain name is invalid: " + str(e))
|
||||||
|
|
||||||
if domain == env["PRIMARY_HOSTNAME"]:
|
if domain == env["PRIMARY_HOSTNAME"]:
|
||||||
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles)
|
||||||
@@ -259,10 +315,10 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone
|
|||||||
check_mail_domain(domain, env, output)
|
check_mail_domain(domain, env, output)
|
||||||
|
|
||||||
if domain in web_domains:
|
if domain in web_domains:
|
||||||
check_web_domain(domain, rounded_time, env, output)
|
check_web_domain(domain, rounded_time, ssl_certificates, env, output)
|
||||||
|
|
||||||
if domain in dns_domains:
|
if domain in dns_domains:
|
||||||
check_dns_zone_suggestions(domain, env, output, dns_zonefiles)
|
check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records)
|
||||||
|
|
||||||
return (domain, output)
|
return (domain, output)
|
||||||
|
|
||||||
@@ -277,6 +333,7 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
|||||||
|
|
||||||
ip = query_dns(domain, "A")
|
ip = query_dns(domain, "A")
|
||||||
ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
ns_ips = query_dns("ns1." + domain, "A") + '/' + query_dns("ns2." + domain, "A")
|
||||||
|
my_ips = env['PUBLIC_IP'] + ((" / "+env['PUBLIC_IPV6']) if env.get("PUBLIC_IPV6") else "")
|
||||||
|
|
||||||
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
# Check that the ns1/ns2 hostnames resolve to A records. This information probably
|
||||||
# comes from the TLD since the information is set at the registrar as glue records.
|
# comes from the TLD since the information is set at the registrar as glue records.
|
||||||
@@ -299,24 +356,29 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
|||||||
public DNS to update after a change."""
|
public DNS to update after a change."""
|
||||||
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
|
% (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'], env['PUBLIC_IP'], ns_ips))
|
||||||
|
|
||||||
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP in public DNS.
|
# Check that PRIMARY_HOSTNAME resolves to PUBLIC_IP[V6] in public DNS.
|
||||||
if ip == env['PUBLIC_IP']:
|
ipv6 = query_dns(domain, "AAAA") if env.get("PUBLIC_IPV6") else None
|
||||||
output.print_ok("Domain resolves to box's IP address. [%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], env['PUBLIC_IP']))
|
if ip == env['PUBLIC_IP'] and ipv6 in (None, env['PUBLIC_IPV6']):
|
||||||
|
output.print_ok("Domain resolves to box's IP address. [%s ↦ %s]" % (env['PRIMARY_HOSTNAME'], my_ips))
|
||||||
else:
|
else:
|
||||||
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
output.print_error("""This domain must resolve to your box's IP address (%s) in public DNS but it currently resolves
|
||||||
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
to %s. It may take several hours for public DNS to update after a change. This problem may result from other
|
||||||
issues listed here."""
|
issues listed above."""
|
||||||
% (env['PUBLIC_IP'], ip))
|
% (my_ips, ip + ((" / " + ipv6) if ipv6 is not None else "")))
|
||||||
|
|
||||||
# Check reverse DNS on the PRIMARY_HOSTNAME. Note that it might not be
|
|
||||||
|
# Check reverse DNS matches the PRIMARY_HOSTNAME. Note that it might not be
|
||||||
# a DNS zone if it is a subdomain of another domain we have a zone for.
|
# a DNS zone if it is a subdomain of another domain we have a zone for.
|
||||||
ipaddr_rev = dns.reversename.from_address(env['PUBLIC_IP'])
|
existing_rdns_v4 = query_dns(dns.reversename.from_address(env['PUBLIC_IP']), "PTR")
|
||||||
existing_rdns = query_dns(ipaddr_rev, "PTR")
|
existing_rdns_v6 = query_dns(dns.reversename.from_address(env['PUBLIC_IPV6']), "PTR") if env.get("PUBLIC_IPV6") else None
|
||||||
if existing_rdns == domain:
|
if existing_rdns_v4 == domain and existing_rdns_v6 in (None, domain):
|
||||||
output.print_ok("Reverse DNS is set correctly at ISP. [%s ↦ %s]" % (env['PUBLIC_IP'], env['PRIMARY_HOSTNAME']))
|
output.print_ok("Reverse DNS is set correctly at ISP. [%s ↦ %s]" % (my_ips, env['PRIMARY_HOSTNAME']))
|
||||||
else:
|
elif existing_rdns_v4 == existing_rdns_v6 or existing_rdns_v6 is None:
|
||||||
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
output.print_error("""Your box's reverse DNS is currently %s, but it should be %s. Your ISP or cloud provider will have instructions
|
||||||
on setting up reverse DNS for your box at %s.""" % (existing_rdns, domain, env['PUBLIC_IP']) )
|
on setting up reverse DNS for your box.""" % (existing_rdns_v4, domain) )
|
||||||
|
else:
|
||||||
|
output.print_error("""Your box's reverse DNS is currently %s (IPv4) and %s (IPv6), but it should be %s. Your ISP or cloud provider will have instructions
|
||||||
|
on setting up reverse DNS for your box.""" % (existing_rdns_v4, existing_rdns_v6, domain) )
|
||||||
|
|
||||||
# Check the TLSA record.
|
# Check the TLSA record.
|
||||||
tlsa_qname = "_25._tcp." + domain
|
tlsa_qname = "_25._tcp." + domain
|
||||||
@@ -338,11 +400,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
|
|||||||
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
|
check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
|
||||||
|
|
||||||
def check_alias_exists(alias_name, alias, env, output):
|
def check_alias_exists(alias_name, alias, env, output):
|
||||||
mail_alises = dict(get_mail_aliases(env))
|
mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)])
|
||||||
if alias in mail_alises:
|
if alias in mail_aliases:
|
||||||
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias]))
|
if mail_aliases[alias]:
|
||||||
|
output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias]))
|
||||||
else:
|
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 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 which directs email to you or another administrator.""" % alias)
|
||||||
|
|
||||||
def check_dns_zone(domain, env, output, dns_zonefiles):
|
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.
|
# If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query.
|
||||||
@@ -352,30 +417,63 @@ def check_dns_zone(domain, env, output, dns_zonefiles):
|
|||||||
|
|
||||||
# We provide a DNS zone for the domain. It should have NS records set up
|
# We provide a DNS zone for the domain. It should have NS records set up
|
||||||
# at the domain name's registrar pointing to this box. The secondary DNS
|
# at the domain name's registrar pointing to this box. The secondary DNS
|
||||||
# server may be customized. Unfortunately this may not check the domain's
|
# server may be customized.
|
||||||
# whois information -- we may be getting the NS records from us rather than
|
# (I'm not sure whether this necessarily tests the TLD's configuration,
|
||||||
# the TLD, and so we're not actually checking the TLD. For that we'd need
|
# as it should, or if one successful NS line at the TLD will result in
|
||||||
# to do a DNS trace.
|
# this query being answered by the box, which would mean the test is only
|
||||||
ip = query_dns(domain, "A")
|
# half working.)
|
||||||
secondary_ns = get_secondary_dns(get_custom_dns_config(env)) or "ns2." + env['PRIMARY_HOSTNAME']
|
|
||||||
|
custom_dns_records = list(get_custom_dns_config(env)) # generator => list so we can reuse it
|
||||||
|
correct_ip = get_custom_dns_record(custom_dns_records, domain, "A") or env['PUBLIC_IP']
|
||||||
|
custom_secondary_ns = get_secondary_dns(custom_dns_records, mode="NS")
|
||||||
|
secondary_ns = custom_secondary_ns or ["ns2." + env['PRIMARY_HOSTNAME']]
|
||||||
|
|
||||||
existing_ns = query_dns(domain, "NS")
|
existing_ns = query_dns(domain, "NS")
|
||||||
correct_ns = "; ".join(sorted([
|
correct_ns = "; ".join(sorted(["ns1." + env['PRIMARY_HOSTNAME']] + secondary_ns))
|
||||||
"ns1." + env['PRIMARY_HOSTNAME'],
|
ip = query_dns(domain, "A")
|
||||||
secondary_ns,
|
|
||||||
]))
|
probably_external_dns = False
|
||||||
|
|
||||||
if existing_ns.lower() == correct_ns.lower():
|
if existing_ns.lower() == correct_ns.lower():
|
||||||
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
output.print_ok("Nameservers are set correctly at registrar. [%s]" % correct_ns)
|
||||||
elif ip == env['PUBLIC_IP']:
|
elif ip == correct_ip:
|
||||||
# The domain resolves correctly, so maybe the user is using External DNS.
|
# The domain resolves correctly, so maybe the user is using External DNS.
|
||||||
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
|
output.print_warning("""The nameservers set on this domain at your domain name registrar should be %s. They are currently %s.
|
||||||
If you are using External DNS, this may be OK."""
|
If you are using External DNS, this may be OK."""
|
||||||
% (correct_ns, existing_ns) )
|
% (correct_ns, existing_ns) )
|
||||||
|
probably_external_dns = True
|
||||||
else:
|
else:
|
||||||
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
output.print_error("""The nameservers set on this domain are incorrect. They are currently %s. Use your domain name registrar's
|
||||||
control panel to set the nameservers to %s."""
|
control panel to set the nameservers to %s."""
|
||||||
% (existing_ns, correct_ns) )
|
% (existing_ns, correct_ns) )
|
||||||
|
|
||||||
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles):
|
# Check that each custom secondary nameserver resolves the IP address.
|
||||||
|
|
||||||
|
if custom_secondary_ns and not probably_external_dns:
|
||||||
|
for ns in custom_secondary_ns:
|
||||||
|
# We must first resolve the nameserver to an IP address so we can query it.
|
||||||
|
ns_ip = query_dns(ns, "A")
|
||||||
|
if not ns_ip:
|
||||||
|
output.print_error("Secondary nameserver %s is not valid (it doesn't resolve to an IP address)." % ns)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Now query it to see what it says about this domain.
|
||||||
|
ip = query_dns(domain, "A", at=ns_ip, nxdomain=None)
|
||||||
|
if ip == correct_ip:
|
||||||
|
output.print_ok("Secondary nameserver %s resolved the domain correctly." % ns)
|
||||||
|
elif ip is None:
|
||||||
|
output.print_error("Secondary nameserver %s is not configured to resolve this domain." % ns)
|
||||||
|
else:
|
||||||
|
output.print_error("Secondary nameserver %s is not configured correctly. (It resolved this domain as %s. It should be %s.)" % (ns, ip, correct_ip))
|
||||||
|
|
||||||
|
def check_dns_zone_suggestions(domain, env, output, dns_zonefiles, domains_with_a_records):
|
||||||
|
# Warn if a custom DNS record is preventing this or the automatic www redirect from
|
||||||
|
# being served.
|
||||||
|
if domain in domains_with_a_records:
|
||||||
|
output.print_warning("""Web has been disabled for this domain because you have set a custom DNS record.""")
|
||||||
|
if "www." + domain in domains_with_a_records:
|
||||||
|
output.print_warning("""A redirect from 'www.%s' has been disabled for this domain because you have set a custom DNS record on the www subdomain.""" % domain)
|
||||||
|
|
||||||
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
# Since DNSSEC is optional, if a DS record is NOT set at the registrar suggest it.
|
||||||
# (If it was set, we did the check earlier.)
|
# (If it was set, we did the check earlier.)
|
||||||
if query_dns(domain, "DS", nxdomain=None) is None:
|
if query_dns(domain, "DS", nxdomain=None) is None:
|
||||||
@@ -386,7 +484,9 @@ def check_dnssec(domain, env, output, dns_zonefiles, is_checking_primary=False):
|
|||||||
# See if the domain has a DS record set at the registrar. The DS record may have
|
# See if the domain has a DS record set at the registrar. The DS record may have
|
||||||
# several forms. We have to be prepared to check for any valid record. We've
|
# several forms. We have to be prepared to check for any valid record. We've
|
||||||
# pre-generated all of the valid digests --- read them in.
|
# pre-generated all of the valid digests --- read them in.
|
||||||
ds_correct = open('/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds').read().strip().split("\n")
|
ds_file = '/etc/nsd/zones/' + dns_zonefiles[domain] + '.ds'
|
||||||
|
if not os.path.exists(ds_file): return # Domain is in our database but DNS has not yet been updated.
|
||||||
|
ds_correct = open(ds_file).read().strip().split("\n")
|
||||||
digests = { }
|
digests = { }
|
||||||
for rr_ds in ds_correct:
|
for rr_ds in ds_correct:
|
||||||
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
|
ds_keytag, ds_alg, ds_digalg, ds_digest = rr_ds.split("\t")[4].split(" ")
|
||||||
@@ -482,7 +582,7 @@ def check_mail_domain(domain, env, output):
|
|||||||
|
|
||||||
# Check that the postmaster@ email address exists. Not required if the domain has a
|
# Check that the postmaster@ email address exists. Not required if the domain has a
|
||||||
# catch-all address or domain alias.
|
# 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)
|
check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
|
||||||
|
|
||||||
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
# Stop if the domain is listed in the Spamhaus Domain Block List.
|
||||||
@@ -496,25 +596,33 @@ def check_mail_domain(domain, env, output):
|
|||||||
which may prevent recipients from receiving your mail.
|
which may prevent recipients from receiving your mail.
|
||||||
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
See http://www.spamhaus.org/dbl/ and http://www.spamhaus.org/query/domain/%s.""" % (dbl, domain))
|
||||||
|
|
||||||
def check_web_domain(domain, rounded_time, env, output):
|
def check_web_domain(domain, rounded_time, ssl_certificates, env, output):
|
||||||
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
# See if the domain's A record resolves to our PUBLIC_IP. This is already checked
|
||||||
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
# for PRIMARY_HOSTNAME, for which it is required for mail specifically. For it and
|
||||||
# other domains, it is required to access its website.
|
# other domains, it is required to access its website.
|
||||||
if domain != env['PRIMARY_HOSTNAME']:
|
if domain != env['PRIMARY_HOSTNAME']:
|
||||||
ip = query_dns(domain, "A")
|
ok_values = []
|
||||||
if ip == env['PUBLIC_IP']:
|
for (rtype, expected) in (("A", env['PUBLIC_IP']), ("AAAA", env.get('PUBLIC_IPV6'))):
|
||||||
output.print_ok("Domain resolves to this box's IP address. [%s ↦ %s]" % (domain, env['PUBLIC_IP']))
|
if not expected: continue # IPv6 is not configured
|
||||||
|
value = query_dns(domain, rtype)
|
||||||
|
if value == expected:
|
||||||
|
ok_values.append(value)
|
||||||
else:
|
else:
|
||||||
output.print_error("""This domain should resolve to your box's IP address (%s) if you would like the box to serve
|
output.print_error("""This domain should resolve to your box's IP address (%s %s) if you would like the box to serve
|
||||||
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
webmail or a website on this domain. The domain currently resolves to %s in public DNS. It may take several hours for
|
||||||
public DNS to update after a change. This problem may result from other issues listed here.""" % (env['PUBLIC_IP'], ip))
|
public DNS to update after a change. This problem may result from other issues listed here.""" % (rtype, expected, value))
|
||||||
|
return
|
||||||
|
|
||||||
# We need a SSL certificate for PRIMARY_HOSTNAME because that's where the
|
# If both A and AAAA are correct...
|
||||||
|
output.print_ok("Domain resolves to this box's IP address. [%s ↦ %s]" % (domain, '; '.join(ok_values)))
|
||||||
|
|
||||||
|
|
||||||
|
# We need a TLS certificate for PRIMARY_HOSTNAME because that's where the
|
||||||
# user will log in with IMAP or webmail. Any other domain we serve a
|
# user will log in with IMAP or webmail. Any other domain we serve a
|
||||||
# website for also needs a signed certificate.
|
# website for also needs a signed certificate.
|
||||||
check_ssl_cert(domain, rounded_time, env, output)
|
check_ssl_cert(domain, rounded_time, ssl_certificates, env, output)
|
||||||
|
|
||||||
def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
def query_dns(qname, rtype, nxdomain='[Not Set]', at=None):
|
||||||
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
# Make the qname absolute by appending a period. Without this, dns.resolver.query
|
||||||
# will fall back a failed lookup to a second query with this machine's hostname
|
# will fall back a failed lookup to a second query with this machine's hostname
|
||||||
# appended. This has been causing some false-positive Spamhaus reports. The
|
# appended. This has been causing some false-positive Spamhaus reports. The
|
||||||
@@ -523,9 +631,20 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
|||||||
if isinstance(qname, str):
|
if isinstance(qname, str):
|
||||||
qname += "."
|
qname += "."
|
||||||
|
|
||||||
|
# Use the default nameservers (as defined by the system, which is our locally
|
||||||
|
# running bind server), or if the 'at' argument is specified, use that host
|
||||||
|
# as the nameserver.
|
||||||
|
resolver = dns.resolver.get_default_resolver()
|
||||||
|
if at:
|
||||||
|
resolver = dns.resolver.Resolver()
|
||||||
|
resolver.nameservers = [at]
|
||||||
|
|
||||||
|
# Set a timeout so that a non-responsive server doesn't hold us back.
|
||||||
|
resolver.timeout = 5
|
||||||
|
|
||||||
# Do the query.
|
# Do the query.
|
||||||
try:
|
try:
|
||||||
response = dns.resolver.query(qname, rtype)
|
response = resolver.query(qname, rtype)
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
# Host did not have an answer for this query; not sure what the
|
# Host did not have an answer for this query; not sure what the
|
||||||
# difference is between the two exceptions.
|
# difference is between the two exceptions.
|
||||||
@@ -539,188 +658,45 @@ def query_dns(qname, rtype, nxdomain='[Not Set]'):
|
|||||||
# can compare to a well known order.
|
# can compare to a well known order.
|
||||||
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
return "; ".join(sorted(str(r).rstrip('.') for r in response))
|
||||||
|
|
||||||
def check_ssl_cert(domain, rounded_time, env, output):
|
def check_ssl_cert(domain, rounded_time, ssl_certificates, env, output):
|
||||||
# Check that SSL certificate is signed.
|
# Check that TLS certificate is signed.
|
||||||
|
|
||||||
# Skip the check if the A record is not pointed here.
|
# Skip the check if the A record is not pointed here.
|
||||||
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
if query_dns(domain, "A", None) not in (env['PUBLIC_IP'], None): return
|
||||||
|
|
||||||
# Where is the SSL stored?
|
# Where is the certificate file stored?
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
||||||
|
if tls_cert is None:
|
||||||
if not os.path.exists(ssl_certificate):
|
output.print_warning("""No TLS (SSL) certificate is installed for this domain. Visitors to a website on
|
||||||
output.print_error("The SSL certificate file for this domain is missing.")
|
this domain will get a security warning. If you are not serving a website on this domain, you do
|
||||||
|
not need to take any action. Use the TLS Certificates page in the control panel to install a
|
||||||
|
TLS certificate.""")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check that the certificate is good.
|
# Check that the certificate is good.
|
||||||
|
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, rounded_time=rounded_time)
|
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"], rounded_time=rounded_time)
|
||||||
|
|
||||||
if cert_status == "OK":
|
if cert_status == "OK":
|
||||||
# The certificate is ok. The details has expiry info.
|
# The certificate is ok. The details has expiry info.
|
||||||
output.print_ok("SSL certificate is signed & valid. %s %s" % (ssl_via if ssl_via else "", cert_status_details))
|
output.print_ok("TLS (SSL) certificate is signed & valid. " + cert_status_details)
|
||||||
|
|
||||||
elif cert_status == "SELF-SIGNED":
|
elif cert_status == "SELF-SIGNED":
|
||||||
# Offer instructions for purchasing a signed certificate.
|
# Offer instructions for purchasing a signed certificate.
|
||||||
|
|
||||||
fingerprint = shell('check_output', [
|
|
||||||
"openssl",
|
|
||||||
"x509",
|
|
||||||
"-in", ssl_certificate,
|
|
||||||
"-noout",
|
|
||||||
"-fingerprint"
|
|
||||||
])
|
|
||||||
fingerprint = re.sub(".*Fingerprint=", "", fingerprint).strip()
|
|
||||||
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
output.print_error("""The SSL certificate for this domain is currently self-signed. You will get a security
|
output.print_error("""The TLS (SSL) certificate for this domain is currently self-signed. You will get a security
|
||||||
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
warning when you check or send email and when visiting this domain in a web browser (for webmail or
|
||||||
static site hosting). Use the SSL Certificates page in this control panel to install a signed SSL certificate.
|
static site hosting).""")
|
||||||
You may choose to leave the self-signed certificate in place and confirm the security exception, but check that
|
|
||||||
the certificate fingerprint matches the following:""")
|
|
||||||
output.print_line("")
|
|
||||||
output.print_line(" " + fingerprint, monospace=True)
|
|
||||||
else:
|
else:
|
||||||
output.print_warning("""The SSL certificate for this domain is currently self-signed. Visitors to a website on
|
output.print_error("""The TLS (SSL) certificate for this domain is self-signed.""")
|
||||||
this domain will get a security warning. If you are not serving a website on this domain, then it is
|
|
||||||
safe to leave the self-signed certificate in place. Use the SSL Certificates page in this control panel to
|
|
||||||
install a signed SSL certificate.""")
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
output.print_error("The SSL certificate has a problem: " + cert_status)
|
output.print_error("The TLS (SSL) certificate has a problem: " + cert_status)
|
||||||
if cert_status_details:
|
if cert_status_details:
|
||||||
output.print_line("")
|
output.print_line("")
|
||||||
output.print_line(cert_status_details)
|
output.print_line(cert_status_details)
|
||||||
output.print_line("")
|
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.
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
cert_dump = cert_dump.split("\n")
|
|
||||||
certificate_names = set()
|
|
||||||
cert_expiration_date = None
|
|
||||||
while len(cert_dump) > 0:
|
|
||||||
line = cert_dump.pop(0)
|
|
||||||
|
|
||||||
# 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))
|
|
||||||
|
|
||||||
# Grab the expiration date for testing later.
|
|
||||||
m = re.match(" Not After : (.*)", line)
|
|
||||||
if m:
|
|
||||||
cert_expiration_date = dateutil.parser.parse(m.group(1))
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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".
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
# 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"])
|
|
||||||
+ [ssl_certificate],
|
|
||||||
input=chaincerts.encode('ascii'),
|
|
||||||
trap=True)
|
|
||||||
|
|
||||||
if "self signed" in verifyoutput:
|
|
||||||
# Certificate is self-signed.
|
|
||||||
return ("SELF-SIGNED", None)
|
|
||||||
|
|
||||||
elif retcode != 0:
|
|
||||||
if "unable to get local issuer certificate" in verifyoutput:
|
|
||||||
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
|
||||||
|
|
||||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
|
||||||
return ("There is a problem with the SSL certificate.", verifyoutput.strip())
|
|
||||||
|
|
||||||
else:
|
|
||||||
# `openssl verify` returned a zero exit status so the cert is currently
|
|
||||||
# good.
|
|
||||||
|
|
||||||
# But is it expiring soon?
|
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
|
||||||
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"))
|
|
||||||
elif ndays <= 14:
|
|
||||||
expiry_info = "The certificate expires in less than two weeks, on %s." % cert_expiration_date.strftime("%x")
|
|
||||||
elif ndays <= 31:
|
|
||||||
expiry_info = "The certificate expires in less than a month, on %s." % cert_expiration_date.strftime("%x")
|
|
||||||
else:
|
|
||||||
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x")
|
|
||||||
|
|
||||||
if ndays <= 31 and warn_if_expiring_soon:
|
|
||||||
return ("The certificate is expiring soon: " + expiry_info, None)
|
|
||||||
|
|
||||||
# Return the special OK code.
|
|
||||||
return ("OK", expiry_info)
|
|
||||||
|
|
||||||
_apt_updates = None
|
_apt_updates = None
|
||||||
def list_apt_updates(apt_update=True):
|
def list_apt_updates(apt_update=True):
|
||||||
# See if we have this information cached recently.
|
# See if we have this information cached recently.
|
||||||
@@ -755,15 +731,44 @@ def list_apt_updates(apt_update=True):
|
|||||||
|
|
||||||
return pkgs
|
return pkgs
|
||||||
|
|
||||||
def run_and_output_changes(env, pool, send_via_email):
|
def what_version_is_this(env):
|
||||||
|
# This function runs `git describe --abbrev=0` 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", "--abbrev=0"], env={"GIT_DIR": os.path.join(miab_dir, '.git')}).strip()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def get_latest_miab_version():
|
||||||
|
# This pings https://mailinabox.email/setup.sh and extracts the tag named in
|
||||||
|
# the script to determine the current product version.
|
||||||
|
import urllib.request
|
||||||
|
return re.search(b'TAG=(.*)', urllib.request.urlopen("https://mailinabox.email/setup.sh?ping=1").read()).group(1).decode("utf8")
|
||||||
|
|
||||||
|
def check_miab_version(env, output):
|
||||||
|
config = load_settings(env)
|
||||||
|
|
||||||
|
if config.get("privacy", True):
|
||||||
|
output.print_warning("Mail-in-a-Box version check disabled by privacy setting.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
this_ver = what_version_is_this(env)
|
||||||
|
except:
|
||||||
|
this_ver = "Unknown"
|
||||||
|
|
||||||
|
latest_ver = get_latest_miab_version()
|
||||||
|
|
||||||
|
if this_ver == latest_ver:
|
||||||
|
output.print_ok("Mail-in-a-Box is up to date. You are running version %s." % this_ver)
|
||||||
|
else:
|
||||||
|
output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://mailinabox.email. "
|
||||||
|
% (this_ver, latest_ver))
|
||||||
|
|
||||||
|
def run_and_output_changes(env, pool):
|
||||||
import json
|
import json
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
if not send_via_email:
|
|
||||||
out = ConsoleOutput()
|
out = ConsoleOutput()
|
||||||
else:
|
|
||||||
import io
|
|
||||||
out = FileOutput(io.StringIO(""), 70)
|
|
||||||
|
|
||||||
# Run status checks.
|
# Run status checks.
|
||||||
cur = BufferedOutput()
|
cur = BufferedOutput()
|
||||||
@@ -822,28 +827,6 @@ def run_and_output_changes(env, pool, send_via_email):
|
|||||||
out.add_heading(category)
|
out.add_heading(category)
|
||||||
out.print_warning("This section was removed.")
|
out.print_warning("This section was removed.")
|
||||||
|
|
||||||
if send_via_email:
|
|
||||||
# If there were changes, send off an email.
|
|
||||||
buf = out.buf.getvalue()
|
|
||||||
if len(buf) > 0:
|
|
||||||
# create MIME message
|
|
||||||
from email.message import Message
|
|
||||||
msg = Message()
|
|
||||||
msg['From'] = "\"%s\" <administrator@%s>" % (env['PRIMARY_HOSTNAME'], env['PRIMARY_HOSTNAME'])
|
|
||||||
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)
|
|
||||||
mailserver.ehlo()
|
|
||||||
mailserver.sendmail(
|
|
||||||
"administrator@%s" % env['PRIMARY_HOSTNAME'], # MAIL FROM
|
|
||||||
"administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO
|
|
||||||
msg.as_string())
|
|
||||||
mailserver.quit()
|
|
||||||
|
|
||||||
# Store the current status checks output for next time.
|
# Store the current status checks output for next time.
|
||||||
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
os.makedirs(os.path.dirname(cache_fn), exist_ok=True)
|
||||||
with open(cache_fn, "w") as f:
|
with open(cache_fn, "w") as f:
|
||||||
@@ -874,7 +857,7 @@ class FileOutput:
|
|||||||
words = re.split("(\s+)", message)
|
words = re.split("(\s+)", message)
|
||||||
linelen = 0
|
linelen = 0
|
||||||
for w in words:
|
for w in words:
|
||||||
if linelen + len(w) > self.width-1-len(first_line):
|
if self.width and (linelen + len(w) > self.width-1-len(first_line)):
|
||||||
print(file=self.buf)
|
print(file=self.buf)
|
||||||
print(" ", end="", file=self.buf)
|
print(" ", end="", file=self.buf)
|
||||||
linelen = 0
|
linelen = 0
|
||||||
@@ -890,11 +873,23 @@ class FileOutput:
|
|||||||
class ConsoleOutput(FileOutput):
|
class ConsoleOutput(FileOutput):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.buf = sys.stdout
|
self.buf = sys.stdout
|
||||||
|
|
||||||
|
# Do nice line-wrapping according to the size of the terminal.
|
||||||
|
# The 'stty' program queries standard input for terminal information.
|
||||||
|
if sys.stdin.isatty():
|
||||||
try:
|
try:
|
||||||
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
|
self.width = int(shell('check_output', ['stty', 'size']).split()[1])
|
||||||
except:
|
except:
|
||||||
self.width = 76
|
self.width = 76
|
||||||
|
|
||||||
|
else:
|
||||||
|
# However if standard input is not a terminal, we would get
|
||||||
|
# "stty: standard input: Inappropriate ioctl for device". So
|
||||||
|
# we test with sys.stdin.isatty first, and if it is not a
|
||||||
|
# terminal don't do any line wrapping. When this script is
|
||||||
|
# run from cron, or if stdin has been redirected, this happens.
|
||||||
|
self.width = None
|
||||||
|
|
||||||
class BufferedOutput:
|
class BufferedOutput:
|
||||||
# Record all of the instance method calls so we can play them back later.
|
# Record all of the instance method calls so we can play them back later.
|
||||||
def __init__(self, with_lines=None):
|
def __init__(self, with_lines=None):
|
||||||
@@ -921,17 +916,21 @@ if __name__ == "__main__":
|
|||||||
run_checks(False, env, ConsoleOutput(), pool)
|
run_checks(False, env, ConsoleOutput(), pool)
|
||||||
|
|
||||||
elif sys.argv[1] == "--show-changes":
|
elif sys.argv[1] == "--show-changes":
|
||||||
run_and_output_changes(env, pool, sys.argv[-1] == "--smtp")
|
run_and_output_changes(env, pool)
|
||||||
|
|
||||||
elif sys.argv[1] == "--check-primary-hostname":
|
elif sys.argv[1] == "--check-primary-hostname":
|
||||||
# See if the primary hostname appears resolvable and has a signed certificate.
|
# See if the primary hostname appears resolvable and has a signed certificate.
|
||||||
domain = env['PRIMARY_HOSTNAME']
|
domain = env['PRIMARY_HOSTNAME']
|
||||||
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
if query_dns(domain, "A") != env['PUBLIC_IP']:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
if not os.path.exists(ssl_certificate):
|
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env)
|
||||||
|
if not os.path.exists(tls_cert["certificate"]):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key, warn_if_expiring_soon=False)
|
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"], warn_if_expiring_soon=False)
|
||||||
if cert_status != "OK":
|
if cert_status != "OK":
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif sys.argv[1] == "--version":
|
||||||
|
print(what_version_is_this(env))
|
||||||
|
|||||||
@@ -7,33 +7,61 @@
|
|||||||
|
|
||||||
<h3>Add a mail alias</h3>
|
<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="form-group">
|
||||||
<div class="col-sm-offset-1 col-sm-11">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
<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="catchall">Catch-All</button>
|
||||||
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
<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="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 mail from one domain to another domain, preserving the part before the @-sign.</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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">
|
<div class="col-sm-10">
|
||||||
<input type="email" class="form-control" id="addaliasEmail">
|
<input type="email" class="form-control" id="addaliasAddress">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<div class="form-group">
|
<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">
|
<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’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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -51,13 +79,14 @@
|
|||||||
<th></th>
|
<th></th>
|
||||||
<th>Alias<br></th>
|
<th>Alias<br></th>
|
||||||
<th>Forwards To</th>
|
<th>Forwards To</th>
|
||||||
|
<th>Permitted Senders</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, and admin@ email addresses are required on some domains.</small></p>
|
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some domains.</small></p>
|
||||||
|
|
||||||
<div style="display: none">
|
<div style="display: none">
|
||||||
<table>
|
<table>
|
||||||
@@ -70,8 +99,9 @@
|
|||||||
<span class="glyphicon glyphicon-trash"></span>
|
<span class="glyphicon glyphicon-trash"></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class='email'> </td>
|
<td class='address'> </td>
|
||||||
<td class='target'> </td>
|
<td class='forwardsTo'> </td>
|
||||||
|
<td class='senders'> </td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +128,12 @@ function show_aliases() {
|
|||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
|
||||||
if (alias.required) n.addClass('alias-required');
|
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.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
||||||
n.find('td.email').text(alias.source_display)
|
n.find('td.address').text(alias.address_display)
|
||||||
for (var j = 0; j < alias.destination.length; j++)
|
for (var j = 0; j < alias.forwards_to.length; j++)
|
||||||
n.find('td.target').append($("<div></div>").text(alias.destination[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);
|
$('#alias_table tbody').append(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,25 +143,25 @@ function show_aliases() {
|
|||||||
$('#alias_type_buttons button').off('click').click(function() {
|
$('#alias_type_buttons button').off('click').click(function() {
|
||||||
$('#alias_type_buttons button').removeClass('active');
|
$('#alias_type_buttons button').removeClass('active');
|
||||||
$(this).addClass('active');
|
$(this).addClass('active');
|
||||||
|
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
|
||||||
if ($(this).attr('data-mode') == "regular") {
|
if ($(this).attr('data-mode') == "regular") {
|
||||||
$('#addaliasEmail').attr('type', 'email');
|
$('#addaliasAddress').attr('type', 'email');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||||
$('#alias_mode_info').slideUp();
|
$('#alias_mode_info').slideUp();
|
||||||
|
$('#addalias-form .regularalias').removeClass('hidden');
|
||||||
} else if ($(this).attr('data-mode') == "catchall") {
|
} else if ($(this).attr('data-mode') == "catchall") {
|
||||||
$('#addaliasEmail').attr('type', 'text');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
|
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#alias_mode_info span').addClass('hidden');
|
$('#addalias-form .catchall').removeClass('hidden');
|
||||||
$('#alias_mode_info span.catchall').removeClass('hidden');
|
|
||||||
} else if ($(this).attr('data-mode') == "domainalias") {
|
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||||
$('#addaliasEmail').attr('type', 'text');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)');
|
$('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#alias_mode_info').slideDown();
|
||||||
$('#alias_mode_info span').addClass('hidden');
|
$('#addalias-form .domainalias').removeClass('hidden');
|
||||||
$('#alias_mode_info span.domainalias').removeClass('hidden');
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
||||||
@@ -139,15 +171,21 @@ function show_aliases() {
|
|||||||
var is_alias_add_update = false;
|
var is_alias_add_update = false;
|
||||||
function do_add_alias() {
|
function do_add_alias() {
|
||||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||||
var email = $("#addaliasEmail").val();
|
var form_address = $("#addaliasAddress").val();
|
||||||
var targets = $("#addaliasTargets").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(
|
api(
|
||||||
"/mail/aliases/add",
|
"/mail/aliases/add",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||||
source: email,
|
address: form_address,
|
||||||
destination: targets
|
forwards_to: form_forwardsto,
|
||||||
|
permitted_senders: form_senders
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
@@ -162,48 +200,55 @@ function do_add_alias() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aliases_reset_form() {
|
function aliases_reset_form() {
|
||||||
$("#addaliasEmail").prop('disabled', false);
|
$("#addaliasAddress").prop('disabled', false);
|
||||||
$("#addaliasEmail").val('')
|
$("#addaliasAddress").val('')
|
||||||
$("#addaliasTargets").val('')
|
$("#addaliasForwardsTo").val('')
|
||||||
|
$("#addaliasSenders").val('')
|
||||||
$('#alias-cancel').addClass('hidden');
|
$('#alias-cancel').addClass('hidden');
|
||||||
$('#add-alias-button').text('Add Alias');
|
$('#add-alias-button').text('Add Alias');
|
||||||
is_alias_add_update = false;
|
is_alias_add_update = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_edit(elem) {
|
function aliases_edit(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var address = $(elem).parents('tr').attr('data-address');
|
||||||
var targetdivs = $(elem).parents('tr').find('.target div');
|
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
||||||
var targets = "";
|
var senderdivs = $(elem).parents('tr').find('.senders div');
|
||||||
for (var i = 0; i < targetdivs.length; i++)
|
var forwardsTo = "";
|
||||||
targets += $(targetdivs[i]).text() + "\n";
|
for (var i = 0; i < receiverdivs.length; i++)
|
||||||
|
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
||||||
is_alias_add_update = true;
|
var senders = "";
|
||||||
$('#alias-cancel').removeClass('hidden');
|
for (var i = 0; i < senderdivs.length; i++)
|
||||||
$("#addaliasEmail").prop('disabled', true);
|
senders += $(senderdivs[i]).text() + "\n";
|
||||||
$("#addaliasEmail").val(email);
|
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
||||||
$("#addaliasTargets").val(targets);
|
|
||||||
$('#add-alias-button').text('Update');
|
|
||||||
if (email.charAt(0) == '@' && targets.charAt(0) == '@')
|
|
||||||
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
$('#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();
|
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
||||||
else
|
else
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click();
|
$('#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 })
|
$('body').animate({ scrollTop: 0 })
|
||||||
|
is_alias_add_update = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_remove(elem) {
|
function aliases_remove(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var row_address = $(elem).parents('tr').attr('data-address');
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Remove Alias",
|
"Remove Alias",
|
||||||
"Remove " + email + "?",
|
"Remove " + row_address + "?",
|
||||||
"Remove",
|
"Remove",
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/remove",
|
"/mail/aliases/remove",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
source: email
|
address: row_address
|
||||||
},
|
},
|
||||||
function(r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
|
|
||||||
<h3>Using a Secondary Nameserver</h3>
|
<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 “slave”) 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 “slave”) 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;">
|
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -81,9 +82,15 @@
|
|||||||
<button type="submit" class="btn btn-primary">Update</button>
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="secondarydns-clear-instructions" class="form-group" style="display: none">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-1 col-sm-11">
|
<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>.
|
||||||
|
</p>
|
||||||
|
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
||||||
|
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -152,8 +159,8 @@ function show_custom_dns() {
|
|||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(data) {
|
function(data) {
|
||||||
$('#secondarydnsHostname').val(data.hostname ? data.hostname : '');
|
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
||||||
$('#secondarydns-clear-instructions').toggle(data.hostname != null);
|
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
@@ -210,7 +217,7 @@ function do_set_secondary_dns() {
|
|||||||
"/dns/secondary-nameserver",
|
"/dns/secondary-nameserver",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
hostname: $('#secondarydnsHostname').val()
|
hostnames: $('#secondarydnsHostname').val()
|
||||||
},
|
},
|
||||||
function(data) {
|
function(data) {
|
||||||
if (data == "") return; // nothing updated
|
if (data == "") return; // nothing updated
|
||||||
|
|||||||
@@ -34,6 +34,15 @@
|
|||||||
|
|
||||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="alert" role="alert">
|
||||||
|
<span class="glyphicon glyphicon-info-sign"></span>
|
||||||
|
You may encounter zone file errors when attempting to create a TXT record with a long string.
|
||||||
|
<a href="http://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
||||||
|
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
<table id="external_dns_settings" class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -9,17 +9,10 @@
|
|||||||
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" integrity="sha256-MfvZlkHCEqatNoGiOXveE8FIwMzZg4W85qfrfIFBfYc=" crossorigin="anonymous">
|
||||||
<style>
|
<style>
|
||||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
|
||||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding-top: 50px;
|
overflow-y: scroll;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
font-family: Raleway, sans-serif;
|
font-family: sans-serif;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,15 +63,14 @@
|
|||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap-theme.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css" integrity="sha256-bHQiqcFbnJb1Qhh61RY9cMh6kR0gTuQY6iFOBj1yj00=" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
|
<!--[if lt IE 8]><p>Internet Explorer version 8 or any modern web browser is required to use this website, sorry.<![endif]-->
|
||||||
<!--[if gt IE 7]><!-->
|
<!--[if gt IE 7]><!-->
|
||||||
|
|
||||||
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
<div class="navbar navbar-inverse navbar-static-top" role="navigation">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="navbar-header">
|
<div class="navbar-header">
|
||||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
|
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
|
||||||
@@ -95,12 +87,13 @@
|
|||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
||||||
<li><a href="#ssl" onclick="return show_panel(this);">SSL Certificates</a></li>
|
<li><a href="#tls" onclick="return show_panel(this);">TLS (SSL) Certificates</a></li>
|
||||||
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||||
<li class="divider"></li>
|
<li class="divider"></li>
|
||||||
<li class="dropdown-header">Advanced Options</li>
|
<li class="dropdown-header">Advanced Pages</li>
|
||||||
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||||
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||||
|
<li><a href="/admin/munin">Munin Monitoring</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
@@ -162,7 +155,7 @@
|
|||||||
{% include "web.html" %}
|
{% include "web.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel_ssl" class="admin_panel">
|
<div id="panel_tls" class="admin_panel">
|
||||||
{% include "ssl.html" %}
|
{% include "ssl.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,8 +191,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js" integrity="sha256-rsPUGdUPBXgalvIj4YKJrrUlmLXbOb6Cp7cdxn1qeUc=" crossorigin="anonymous"></script>
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js" integrity="sha256-Sk3nkD6mLTMOF0EOpNtsIry+s1CsaqQC1rVLTAy+0yc=" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var global_modal_state = null;
|
var global_modal_state = null;
|
||||||
@@ -270,7 +263,7 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ajax_num_executing_requests = 0;
|
var ajax_num_executing_requests = 0;
|
||||||
function ajax(options) {
|
function ajax_with_indicator(options) {
|
||||||
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||||
function hide_loading_indicator() {
|
function hide_loading_indicator() {
|
||||||
ajax_num_executing_requests--;
|
ajax_num_executing_requests--;
|
||||||
@@ -332,7 +325,7 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
show_modal_error("Error", "Something went wrong, sorry.")
|
show_modal_error("Error", "Something went wrong, sorry.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ajax({
|
ajax_with_indicator({
|
||||||
url: "/admin" + url,
|
url: "/admin" + url,
|
||||||
method: method,
|
method: method,
|
||||||
cache: false,
|
cache: false,
|
||||||
|
|||||||
@@ -57,11 +57,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
<script>
|
<script>
|
||||||
function do_login() {
|
function do_login() {
|
||||||
if ($('#loginEmail').val() == "") {
|
if ($('#loginEmail').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email address.")
|
show_modal_error("Login Failed", "Enter your email address.", function() {
|
||||||
|
$('#loginEmail').focus();
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($('#loginPassword').val() == "") {
|
if ($('#loginPassword').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email password.")
|
show_modal_error("Login Failed", "Enter your email password.", function() {
|
||||||
|
$('#loginPassword').focus();
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ function do_login() {
|
|||||||
// Open the next panel the user wants to go to. Do this after the XHR response
|
// Open the next panel the user wants to go to. Do this after the XHR response
|
||||||
// is over so that we don't start a new XHR request while this one is finishing,
|
// is over so that we don't start a new XHR request while this one is finishing,
|
||||||
// which confuses the loading indicator.
|
// which confuses the loading indicator.
|
||||||
setTimeout(function() { show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel) }, 300);
|
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -126,4 +130,14 @@ function do_logout() {
|
|||||||
sessionStorage.removeItem("miab-cp-credentials");
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
show_panel('login');
|
show_panel('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function show_login() {
|
||||||
|
$('#loginEmail,#loginPassword').each(function() {
|
||||||
|
var input = $(this);
|
||||||
|
if (!$.trim(input.val())) {
|
||||||
|
input.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
|
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
|
||||||
<tr><th>Mail server</th> <td>{{hostname}}</td>
|
<tr><th>Mail server</th> <td>{{hostname}}</td>
|
||||||
<tr><th>IMAP Port</th> <td>993</td></tr>
|
<tr><th>IMAP Port</th> <td>993</td></tr>
|
||||||
<tr><th>IMAP Security</th> <td>SSL</td></tr>
|
<tr><th>IMAP Security</th> <td>SSL or TLS</td></tr>
|
||||||
<tr><th>SMTP Port</th> <td>587</td></tr>
|
<tr><th>SMTP Port</th> <td>587</td></tr>
|
||||||
<tr><th>SMTP Security</td> <td>STARTTLS <small>(“always” or “required”, if prompted)</small></td></tr>
|
<tr><th>SMTP Security</td> <td>STARTTLS <small>(“always” or “required”, if prompted)</small></td></tr>
|
||||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||||
@@ -38,6 +38,8 @@
|
|||||||
|
|
||||||
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
||||||
|
|
||||||
|
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p>
|
||||||
|
|
||||||
<h4>Exchange/ActiveSync settings</h4>
|
<h4>Exchange/ActiveSync settings</h4>
|
||||||
|
|
||||||
<p>On iOS devices, devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
|
<p>On iOS devices, devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
<p>Your box using a technique called greylisting to cut down on spam. Greylisting works by delaying mail from people you haven’t received mail from before for up to about 10 minutes. The vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please give it up to 10-15 minutes to arrive.</p>
|
<p>Your box using a technique called greylisting to cut down on spam. Greylisting works by delaying mail from people you haven’t received mail from before for up to about 10 minutes. The vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please give it up to 10-15 minutes to arrive.</p>
|
||||||
|
|
||||||
<h4>+tag addresses</h4>
|
<h4>+tag addresses</h4>
|
||||||
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you can also accept mail at <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to create aliases or to segment incoming mail for your own filtering rules.</p>
|
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you’ll also automatically get mail sent to <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.</p>
|
||||||
|
|
||||||
<h4>Use only this box to send as you</h4>
|
<h4>Use only this box to send as you</h4>
|
||||||
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
|
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
<style>
|
<style>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>SSL Certificates</h2>
|
<h2>TLS (SSL) Certificates</h2>
|
||||||
|
|
||||||
|
<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address that the connection is secure between you and the owner of that address.</p>
|
||||||
|
|
||||||
|
<p>You need a TLS certificate for this box’s hostname ({{hostname}}) and every other domain name and subdomain that this box is hosting a website for (see the list below).</p>
|
||||||
|
|
||||||
|
<div id="ssl_provision">
|
||||||
|
<h3>Provision a Certificate</h3>
|
||||||
|
|
||||||
|
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
|
||||||
|
<button onclick='return provision_tls_cert();' class='btn btn-primary' style="float: left; margin: 0 1.5em 1em 0;">Provision</button>
|
||||||
|
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:<br>
|
||||||
|
<span class="text-primary"></span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clearfix"> </div>
|
||||||
|
|
||||||
|
<div id="ssl_provision_result"></div>
|
||||||
|
|
||||||
|
<div id="ssl_provision_problems_div" style="display: none;">
|
||||||
|
<p style="margin-bottom: .5em;">Certificates cannot be automatically provisioned for:</p>
|
||||||
|
<table id="ssl_provision_problems" style="margin-top: 0;" class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Problem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Use the <em>Install Certificate</em> button below for these domains.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Certificate Status</h3>
|
<h3>Certificate Status</h3>
|
||||||
|
|
||||||
|
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed through <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a> 14 days prior to expiration.</p>
|
||||||
|
|
||||||
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto;">
|
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto; display: none">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
@@ -18,29 +52,39 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>Advanced:<br>Install a multi-domain or wildcard certificate for the <code>{{hostname}}</code> domain to have it automatically applied to any domains it is valid for.</p>
|
|
||||||
|
|
||||||
<h3 id="ssl_install_header">Install SSL Certificate</h3>
|
<h3 id="ssl_install_header">Install Certificate</h3>
|
||||||
|
|
||||||
<p>There are many places where you can get a free or cheap SSL certificate. We recommend <a href="https://www.namecheap.com/security/ssl-certificates/domain-validation.aspx">Namecheap’s $9 certificate</a> or <a href="https://www.startssl.com/">StartSSL’s free express lane</a>.</p>
|
<p>There are many places where you can get a free or cheap certificate. We recommend <a href="https://www.namecheap.com/security/ssl-certificates/domain-validation.aspx">Namecheap’s $9 certificate</a>, <a href="https://www.startssl.com/">StartSSL’s free express lane</a> or <a href="https://buy.wosign.com/free/">WoSign’s free TLS</a></a>.</p>
|
||||||
|
|
||||||
<p>Which domain are you getting an SSL certificate for?</p>
|
<p>Which domain are you getting a certificate for?</p>
|
||||||
|
|
||||||
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
|
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
|
||||||
|
|
||||||
|
<p>(A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides the one you choose above.)</p>
|
||||||
|
|
||||||
|
<p>What country are you in? This is required by some TLS certificate providers. You may leave this blank if you know your TLS certificate provider doesn't require it.</p>
|
||||||
|
|
||||||
|
<p><select id="sslcc" onchange="show_csr()" class="form-control" style="width: auto">
|
||||||
|
<option value="">(Select)</option>
|
||||||
|
{% for code, name in csr_country_codes %}
|
||||||
|
<option value="{{code}}">{{name}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></p>
|
||||||
|
|
||||||
<div id="csr_info" style="display: none">
|
<div id="csr_info" style="display: none">
|
||||||
<p>You will need to provide the SSL certificate provider this Certificate Signing Request (CSR):</p>
|
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
|
||||||
|
|
||||||
<pre id="ssl_csr"></pre>
|
<pre id="ssl_csr"></pre>
|
||||||
|
|
||||||
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
|
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
|
||||||
|
|
||||||
<p>The SSL certificate provider will then provide you with an SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>
|
<p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p>
|
||||||
|
|
||||||
<p style="margin-bottom: .5em">SSL certificate:</p>
|
<p style="margin-bottom: .5em">TLS/SSL certificate:</p>
|
||||||
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea></p>
|
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
<p style="margin-bottom: .5em">SSL intermediate chain (if provided):</p>
|
<p style="margin-bottom: .5em">TLS/SSL intermediate chain (if provided):</p>
|
||||||
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea></p>
|
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
<p>After you paste in the information, click the install button.</p>
|
<p>After you paste in the information, click the install button.</p>
|
||||||
@@ -49,26 +93,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_ssl() {
|
function show_tls(keep_provisioning_shown) {
|
||||||
api(
|
api(
|
||||||
"/web/domains",
|
"/ssl/status",
|
||||||
"GET",
|
"GET",
|
||||||
{
|
{
|
||||||
},
|
},
|
||||||
function(domains) {
|
function(res) {
|
||||||
|
// provisioning status
|
||||||
|
|
||||||
|
if (!keep_provisioning_shown)
|
||||||
|
$('#ssl_provision').toggle(res.can_provision.length + res.cant_provision.length > 0)
|
||||||
|
|
||||||
|
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
|
||||||
|
if (res.can_provision.length > 0)
|
||||||
|
$('#ssl_provision_p span').text(res.can_provision.join(", "));
|
||||||
|
|
||||||
|
$('#ssl_provision_problems_div').toggle(res.cant_provision.length > 0);
|
||||||
|
$('#ssl_provision_problems tbody').text("");
|
||||||
|
for (var i = 0; i < res.cant_provision.length; i++) {
|
||||||
|
var domain = res.cant_provision[i];
|
||||||
|
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td></tr>");
|
||||||
|
$('#ssl_provision_problems tbody').append(row);
|
||||||
|
row.attr('data-domain', domain.domain);
|
||||||
|
row.find('.domain a').text(domain.domain);
|
||||||
|
row.find('.domain a').attr('href', 'https://' + domain.domain);
|
||||||
|
row.find('.status').text(domain.problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// certificate status
|
||||||
|
var domains = res.status;
|
||||||
var tb = $('#ssl_domains tbody');
|
var tb = $('#ssl_domains tbody');
|
||||||
tb.text('');
|
tb.text('');
|
||||||
$('#ssldomain').html('<option value="">(select)</option>');
|
$('#ssldomain').html('<option value="">(select)</option>');
|
||||||
|
$('#ssl_domains').show();
|
||||||
for (var i = 0; i < domains.length; i++) {
|
for (var i = 0; i < domains.length; i++) {
|
||||||
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
||||||
tb.append(row);
|
tb.append(row);
|
||||||
row.attr('data-domain', domains[i].domain);
|
row.attr('data-domain', domains[i].domain);
|
||||||
row.find('.domain a').text(domains[i].domain);
|
row.find('.domain a').text(domains[i].domain);
|
||||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||||
row.addClass("text-" + domains[i].ssl_certificate[0]);
|
if (domains[i].status == "not-applicable") {
|
||||||
row.find('.status').text(domains[i].ssl_certificate[1]);
|
domains[i].status = "muted"; // text-muted css class
|
||||||
if (domains[i].ssl_certificate[0] == "success") {
|
row.find('.actions a').remove(); // no actions applicable
|
||||||
|
}
|
||||||
|
row.addClass("text-" + domains[i].status);
|
||||||
|
row.find('.status').text(domains[i].text);
|
||||||
|
if (domains[i].status == "success") {
|
||||||
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
||||||
} else {
|
} else {
|
||||||
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
|
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
|
||||||
@@ -82,18 +153,20 @@ function show_ssl() {
|
|||||||
function ssl_install(elem) {
|
function ssl_install(elem) {
|
||||||
var domain = $(elem).parents('tr').attr('data-domain');
|
var domain = $(elem).parents('tr').attr('data-domain');
|
||||||
$('#ssldomain').val(domain);
|
$('#ssldomain').val(domain);
|
||||||
$('#csr_info').slideDown();
|
|
||||||
$('#ssl_csr').text('Loading...');
|
|
||||||
show_csr();
|
show_csr();
|
||||||
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_csr() {
|
function show_csr() {
|
||||||
|
if ($('#ssldomain').val() == "") return;
|
||||||
|
$('#csr_info').slideDown();
|
||||||
|
$('#ssl_csr').text('Loading...');
|
||||||
api(
|
api(
|
||||||
"/ssl/csr/" + $('#ssldomain').val(),
|
"/ssl/csr/" + $('#ssldomain').val(),
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
|
countrycode: $('#sslcc').val()
|
||||||
},
|
},
|
||||||
function(data) {
|
function(data) {
|
||||||
$('#ssl_csr').text(data);
|
$('#ssl_csr').text(data);
|
||||||
@@ -112,10 +185,100 @@ function install_cert() {
|
|||||||
function(status) {
|
function(status) {
|
||||||
if (/^OK($|\n)/.test(status)) {
|
if (/^OK($|\n)/.test(status)) {
|
||||||
console.log(status)
|
console.log(status)
|
||||||
show_modal_error("SSL Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
|
show_modal_error("TLS Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
|
||||||
} else {
|
} else {
|
||||||
show_modal_error("SSL Certificate Installation", status);
|
show_modal_error("TLS Certificate Installation", status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var agree_to_tos_url_prompt = null;
|
||||||
|
var agree_to_tos_url = null;
|
||||||
|
function provision_tls_cert() {
|
||||||
|
// Automatically provision any certs.
|
||||||
|
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
|
||||||
|
api(
|
||||||
|
"/ssl/provision",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
agree_to_tos_url: agree_to_tos_url
|
||||||
|
},
|
||||||
|
function(status) {
|
||||||
|
// Clear last attempt.
|
||||||
|
agree_to_tos_url = null;
|
||||||
|
$('#ssl_provision_result').text("");
|
||||||
|
may_reenable_provision_button = true;
|
||||||
|
|
||||||
|
// Nothing was done. There might also be problem domains, but we've already displayed those.
|
||||||
|
if (status.requests.length == 0) {
|
||||||
|
show_modal_error("TLS Certificate Provisioning", "There were no domain names to provision certificates for.");
|
||||||
|
// don't return - haven't re-enabled the provision button
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each provisioning API call returns zero or more "requests" which represent
|
||||||
|
// a request to Let's Encrypt for a single certificate. Normally there is just
|
||||||
|
// one request (for a single multi-domain certificate).
|
||||||
|
for (var i = 0; i < status.requests.length; i++) {
|
||||||
|
var r = status.requests[i];
|
||||||
|
|
||||||
|
// create an HTML block to display the results of this request
|
||||||
|
var n = $("<div><h4/><p/></div>");
|
||||||
|
$('#ssl_provision_result').append(n);
|
||||||
|
|
||||||
|
// show a header only to disambiguate request blocks
|
||||||
|
if (status.requests.length > 0)
|
||||||
|
n.find("h4").text(r.domains.join(", "));
|
||||||
|
|
||||||
|
if (r.result == "agree-to-tos") {
|
||||||
|
// user needs to agree to Let's Encrypt's TOS
|
||||||
|
agree_to_tos_url_prompt = r.url;
|
||||||
|
$('#ssl_provision_p .btn').attr('disabled', '1');
|
||||||
|
n.find("p").html("Please open and review <a href='" + r.url + "' target='_blank'>Let's Encrypt's terms of service agreement</a>. You must agree to their terms for a certificate to be automatically provisioned from them.");
|
||||||
|
n.append($('<button onclick="agree_to_tos_url = agree_to_tos_url_prompt; return provision_tls_cert();" class="btn btn-success" style="margin-left: 2em">Agree & Try Again</button>'));
|
||||||
|
|
||||||
|
// don't re-enable the Provision button -- user must use the Agree button
|
||||||
|
may_reenable_provision_button = false;
|
||||||
|
|
||||||
|
} else if (r.result == "error") {
|
||||||
|
n.find("p").addClass("text-danger").text(r.message);
|
||||||
|
|
||||||
|
} else if (r.result == "wait") {
|
||||||
|
// Show a button that counts down to zero, at which point it becomes enabled.
|
||||||
|
n.find("p").text("A certificate is now in the process of being provisioned, but it takes some time. Please wait until the Finish button is enabled, and then click it to acquire the certificate.");
|
||||||
|
var b = $('<button onclick="return provision_tls_cert();" class="btn btn-success" style="margin-left: 2em">Finish</button>');
|
||||||
|
b.attr("disabled", "1");
|
||||||
|
var now = new Date();
|
||||||
|
n.append(b);
|
||||||
|
function ready_to_finish() {
|
||||||
|
var remaining = Math.round(r.seconds - (new Date() - now)/1000);
|
||||||
|
if (remaining > 0) {
|
||||||
|
setTimeout(ready_to_finish, 1000);
|
||||||
|
b.text("Finish (" + remaining + "...)")
|
||||||
|
} else {
|
||||||
|
b.text("Finish (ready)")
|
||||||
|
b.removeAttr("disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready_to_finish();
|
||||||
|
|
||||||
|
// don't re-enable the Provision button -- user must use the Retry button when it becomes enabled
|
||||||
|
may_reenable_provision_button = false;
|
||||||
|
|
||||||
|
} else if (r.result == "installed") {
|
||||||
|
n.find("p").addClass("text-success").text("The TLS certificate was provisioned and installed.");
|
||||||
|
setTimeout("show_tls(true)", 1); // update main table of certificate statuses, call with arg keep_provisioning_shown true so that we don't clear what we just outputted
|
||||||
|
}
|
||||||
|
|
||||||
|
// display the detailed log info in case of problems
|
||||||
|
var trace = $("<div class='small text-muted' style='margin-top: 1.5em'>Log:</div>");
|
||||||
|
n.append(trace);
|
||||||
|
for (var j = 0; j < r.log.length; j++)
|
||||||
|
trace.append($("<div/>").text(r.log[j]));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (may_reenable_provision_button)
|
||||||
|
$('#ssl_provision_p .btn').removeAttr("disabled");
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,17 +5,78 @@
|
|||||||
|
|
||||||
<h2>Backup Status</h2>
|
<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">
|
||||||
|
<p>Backups are stored on this machine’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.</p>
|
||||||
|
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored in an Amazon Web Services S3 bucket. You must have an AWS account already.</p>
|
||||||
|
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Amazon S3 bucket.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-host" 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 backup-target-local backup-target-s3">
|
||||||
|
<label for="min-age" class="col-sm-2 control-label">Days:</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="number" class="form-control" rows="1" id="min-age">
|
||||||
|
<div class="small" style="margin-top: 2px">This is the <i>minimum</i> number of days backup data is kept for. The box makes an incremental backup, so backup data is often kept much longer. An incremental backup file that is less than this number of days old requires that all previous increments back to the most recent full backup, plus that full backup, remain available.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
|
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
<h3>Available Backups</h3>
|
||||||
|
|
||||||
<h3>Current Backups</h3>
|
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id="backup-total-size"></span>.</p>
|
||||||
|
|
||||||
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is currently <span id="backup-total-size"></span>.</p>
|
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -27,8 +88,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<script>
|
<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) {
|
function nice_size(bytes) {
|
||||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -46,19 +113,27 @@ function nice_size(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show_system_backup() {
|
function show_system_backup() {
|
||||||
|
show_custom_backup()
|
||||||
|
|
||||||
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/system/backup/status",
|
"/system/backup/status",
|
||||||
"GET",
|
"GET",
|
||||||
{ },
|
{ },
|
||||||
function(r) {
|
function(r) {
|
||||||
$('#backup-location').text(r.directory);
|
if (r.error) {
|
||||||
$('#backup-encpassword-file').text(r.encpwfile);
|
show_modal_error("Backup Error", $("<pre/>").text(r.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$('#backup-status tbody').html("");
|
$('#backup-status tbody').html("");
|
||||||
var total_disk_size = 0;
|
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>');
|
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
}
|
}
|
||||||
@@ -67,7 +142,7 @@ function show_system_backup() {
|
|||||||
var b = r.backups[i];
|
var b = r.backups[i];
|
||||||
var tr = $('<tr/>');
|
var tr = $('<tr/>');
|
||||||
if (b.full) tr.addClass("full-backup");
|
if (b.full) tr.addClass("full-backup");
|
||||||
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
|
tr.append( $('<td/>').text(b.date_str) );
|
||||||
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
||||||
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
||||||
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
|
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
|
||||||
@@ -83,4 +158,63 @@ function show_system_backup() {
|
|||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#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) {
|
||||||
|
// use .text() --- it's a text response, not html
|
||||||
|
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
// use .text() --- it's a text response, not html
|
||||||
|
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,8 +34,20 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#system-privacy-setting {
|
||||||
|
float: right;
|
||||||
|
max-width: 20em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<div id="system-privacy-setting" style="display: none">
|
||||||
|
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span> New-Version Check</a></div>
|
||||||
|
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of Mail-in-a-Box.)</small></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<table id="system-checks" class="table" style="max-width: 60em">
|
<table id="system-checks" class="table" style="max-width: 60em">
|
||||||
<thead>
|
<thead>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -46,6 +58,18 @@
|
|||||||
<script>
|
<script>
|
||||||
function show_system_status() {
|
function show_system_status() {
|
||||||
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
|
|
||||||
|
api(
|
||||||
|
"/system/privacy",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
current_privacy_setting = r;
|
||||||
|
$('#system-privacy-setting').show();
|
||||||
|
$('#system-privacy-setting a span').text(r ? "Enable" : "Disable");
|
||||||
|
$('#system-privacy-setting p').toggle(r);
|
||||||
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/system/status",
|
"/system/status",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -82,5 +106,20 @@ function show_system_status() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var current_privacy_setting = null;
|
||||||
|
function enable_privacy(status) {
|
||||||
|
api(
|
||||||
|
"/system/privacy",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
value: (status ? "private" : "off")
|
||||||
|
},
|
||||||
|
function(res) {
|
||||||
|
show_system_status();
|
||||||
|
});
|
||||||
|
return false; // disable link
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
<button type="submit" class="btn btn-primary">Add User</button>
|
<button type="submit" class="btn btn-primary">Add User</button>
|
||||||
</form>
|
</form>
|
||||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
<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>Passwords must be at least four characters and may not contain spaces. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</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>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>
|
</ul>
|
||||||
|
|
||||||
<h3>Existing mail users</h3>
|
<h3>Existing mail users</h3>
|
||||||
@@ -164,9 +164,14 @@ function do_add_user() {
|
|||||||
|
|
||||||
function users_set_password(elem) {
|
function users_set_password(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
|
|
||||||
|
var yourpw = "";
|
||||||
|
if (api_credentials != null && email == api_credentials[0])
|
||||||
|
yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Archive User",
|
"Archive User",
|
||||||
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least four characters and may not contain spaces.</small></p>"),
|
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least four characters and may not contain spaces.</small>" + yourpw + "</p>"),
|
||||||
"Set Password",
|
"Set Password",
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
@@ -245,4 +250,13 @@ function mod_priv(elem, add_remove) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generate_random_password() {
|
||||||
|
var pw = "";
|
||||||
|
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
pw += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||||
|
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr");
|
||||||
|
return false; // cancel click
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os.path
|
import os.path
|
||||||
|
|
||||||
CONF_DIR = os.path.join(os.path.dirname(__file__), "../conf")
|
# DO NOT import non-standard modules. This module is imported by
|
||||||
|
# migrate.py which runs on fresh machines before anything is installed
|
||||||
|
# besides Python.
|
||||||
|
|
||||||
|
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
|
||||||
|
|
||||||
def load_environment():
|
def load_environment():
|
||||||
# Load settings from /etc/mailinabox.conf.
|
# Load settings from /etc/mailinabox.conf.
|
||||||
@@ -18,38 +22,78 @@ def save_environment(env):
|
|||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
f.write("%s=%s\n" % (k, v))
|
f.write("%s=%s\n" % (k, v))
|
||||||
|
|
||||||
|
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
||||||
|
|
||||||
|
def write_settings(config, env):
|
||||||
|
import rtyaml
|
||||||
|
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||||
|
with open(fn, "w") as f:
|
||||||
|
f.write(rtyaml.dump(config))
|
||||||
|
|
||||||
|
def load_settings(env):
|
||||||
|
import rtyaml
|
||||||
|
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||||
|
try:
|
||||||
|
config = rtyaml.load(open(fn, "r"))
|
||||||
|
if not isinstance(config, dict): raise ValueError() # caught below
|
||||||
|
return config
|
||||||
|
except:
|
||||||
|
return { }
|
||||||
|
|
||||||
|
# UTILITIES
|
||||||
|
|
||||||
def safe_domain_name(name):
|
def safe_domain_name(name):
|
||||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
return urllib.parse.quote(name, safe='')
|
return urllib.parse.quote(name, safe='')
|
||||||
|
|
||||||
def sort_domains(domain_names, env):
|
def sort_domains(domain_names, env):
|
||||||
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
# Put domain names in a nice sorted order.
|
||||||
# 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.
|
# The nice order will group domain names by DNS zone, i.e. the top-most
|
||||||
groups = ( [], [], [] )
|
# domain name that we serve that ecompasses a set of subdomains. Map
|
||||||
for d in domain_names:
|
# each of the domain names to the zone that contains them. Walk the domains
|
||||||
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
|
# from shortest to longest since zones are always shorter than their
|
||||||
groups[0].append(d)
|
# subdomains.
|
||||||
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
|
zones = { }
|
||||||
groups[1].append(d)
|
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:
|
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.
|
# Sort the zones.
|
||||||
def sort_group(group):
|
zone_domains = sorted(zones.values(),
|
||||||
# Find the top-most domains.
|
key = lambda d : (
|
||||||
top_domains = sorted(d for d in group if len([s for s in group if d.endswith("." + s)]) == 0)
|
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
||||||
ret = []
|
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
|
||||||
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]
|
# Then just dumb lexicographically.
|
||||||
|
d,
|
||||||
|
))
|
||||||
|
|
||||||
return groups[0] + groups[1] + groups[2]
|
# 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):
|
def sort_email_addresses(email_addresses, env):
|
||||||
email_addresses = set(email_addresses)
|
email_addresses = set(email_addresses)
|
||||||
@@ -200,3 +244,18 @@ def wait_for_service(port, public, env, timeout):
|
|||||||
if time.perf_counter() > start+timeout:
|
if time.perf_counter() > start+timeout:
|
||||||
return False
|
return False
|
||||||
time.sleep(min(timeout/4, 1))
|
time.sleep(min(timeout/4, 1))
|
||||||
|
|
||||||
|
def fix_boto():
|
||||||
|
# Google Compute Engine instances install some Python-2-only boto plugins that
|
||||||
|
# conflict with boto running under Python 3. Disable boto's default configuration
|
||||||
|
# file prior to importing boto so that GCE's plugin is not loaded:
|
||||||
|
import os
|
||||||
|
os.environ["BOTO_CONFIG"] = "/etc/boto3.cfg"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from web_update import get_web_domains
|
||||||
|
env = load_environment()
|
||||||
|
domains = get_web_domains(env)
|
||||||
|
for domain in domains:
|
||||||
|
print(domain)
|
||||||
|
|||||||
@@ -2,48 +2,97 @@
|
|||||||
# domains for which a mail account has been set up.
|
# domains for which a mail account has been set up.
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
import os, os.path, shutil, re, tempfile, rtyaml
|
import os.path, re, rtyaml
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains
|
||||||
from dns_update import get_custom_dns_config, do_dns_update
|
from dns_update import get_custom_dns_config, get_dns_zones
|
||||||
|
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
|
||||||
from utils import shell, safe_domain_name, sort_domains
|
from utils import shell, safe_domain_name, sort_domains
|
||||||
|
|
||||||
def get_web_domains(env):
|
def get_web_domains(env, include_www_redirects=True, exclude_dns_elsewhere=True):
|
||||||
# What domains should we serve websites for?
|
# What domains should we serve HTTP(S) for?
|
||||||
domains = set()
|
domains = set()
|
||||||
|
|
||||||
# At the least it's the PRIMARY_HOSTNAME so we can serve webmail
|
# Serve web for all mail domains so that we might at least
|
||||||
# as well as Z-Push for Exchange ActiveSync.
|
|
||||||
domains.add(env['PRIMARY_HOSTNAME'])
|
|
||||||
|
|
||||||
# Also serve web for all mail domains so that we might at least
|
|
||||||
# provide auto-discover of email settings, and also a static website
|
# provide auto-discover of email settings, and also a static website
|
||||||
# if the user wants to make one. These will require an SSL cert.
|
# if the user wants to make one.
|
||||||
domains |= get_mail_domains(env)
|
domains |= get_mail_domains(env)
|
||||||
|
|
||||||
|
if include_www_redirects:
|
||||||
|
# Add 'www.' subdomains that we want to provide default redirects
|
||||||
|
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
|
||||||
|
# the topmost of each domain we serve.
|
||||||
|
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
|
||||||
|
|
||||||
|
if exclude_dns_elsewhere:
|
||||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||||
# IP address than this box. Remove those domains from our list.
|
# IP address than this box. Remove those domains from our list.
|
||||||
dns = get_custom_dns_config(env)
|
domains -= get_domains_with_a_records(env)
|
||||||
for domain, rtype, value in dns:
|
|
||||||
if domain not in domains: continue
|
|
||||||
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value != "local"):
|
|
||||||
domains.remove(domain)
|
|
||||||
|
|
||||||
# Sort the list. Put PRIMARY_HOSTNAME first so it becomes the
|
# Ensure the PRIMARY_HOSTNAME is in the list so we can serve webmail
|
||||||
# default server (nginx's default_server).
|
# as well as Z-Push for Exchange ActiveSync. This can't be removed
|
||||||
|
# by a custom A/AAAA record and is never a 'www.' redirect.
|
||||||
|
domains.add(env['PRIMARY_HOSTNAME'])
|
||||||
|
|
||||||
|
# Sort the list so the nginx conf gets written in a stable order.
|
||||||
domains = sort_domains(domains, env)
|
domains = sort_domains(domains, env)
|
||||||
|
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
def get_domains_with_a_records(env):
|
||||||
|
domains = set()
|
||||||
|
dns = get_custom_dns_config(env)
|
||||||
|
for domain, rtype, value in dns:
|
||||||
|
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])):
|
||||||
|
domains.add(domain)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def get_web_domains_with_root_overrides(env):
|
||||||
|
# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
||||||
|
# which means static hosting is not happening.
|
||||||
|
root_overrides = { }
|
||||||
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
|
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
||||||
|
for domain, settings in custom_settings.items():
|
||||||
|
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
|
||||||
|
('proxy', settings.get('proxies', {}).get('/'))]:
|
||||||
|
if value:
|
||||||
|
root_overrides[domain] = (type, value)
|
||||||
|
return root_overrides
|
||||||
|
|
||||||
def do_web_update(env):
|
def do_web_update(env):
|
||||||
|
# Pre-load what SSL certificates we will use for each domain.
|
||||||
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
# Build an nginx configuration file.
|
# Build an nginx configuration file.
|
||||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||||
|
|
||||||
# Add configuration for each web domain.
|
# Load the templates.
|
||||||
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||||
|
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-alldomains.conf")).read()
|
||||||
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
||||||
|
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||||
|
|
||||||
|
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||||
|
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)
|
||||||
|
|
||||||
|
# Add configuration all other web domains.
|
||||||
|
has_root_proxy_or_redirect = get_web_domains_with_root_overrides(env)
|
||||||
|
web_domains_not_redirect = get_web_domains(env, include_www_redirects=False)
|
||||||
for domain in get_web_domains(env):
|
for domain in get_web_domains(env):
|
||||||
nginx_conf += make_domain_config(domain, template1, template2, env)
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
# PRIMARY_HOSTNAME is handled above.
|
||||||
|
continue
|
||||||
|
if domain in web_domains_not_redirect:
|
||||||
|
# This is a regular domain.
|
||||||
|
if domain not in has_root_proxy_or_redirect:
|
||||||
|
nginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env)
|
||||||
|
else:
|
||||||
|
nginx_conf += make_domain_config(domain, [template0], ssl_certificates, env)
|
||||||
|
else:
|
||||||
|
# Add default 'www.' redirect.
|
||||||
|
nginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env)
|
||||||
|
|
||||||
# Did the file change? If not, don't bother writing & restarting nginx.
|
# Did the file change? If not, don't bother writing & restarting nginx.
|
||||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||||
@@ -64,32 +113,18 @@ def do_web_update(env):
|
|||||||
|
|
||||||
return "web updated\n"
|
return "web updated\n"
|
||||||
|
|
||||||
def make_domain_config(domain, template, template_for_primaryhost, env):
|
def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
# How will we configure this domain.
|
# GET SOME VARIABLES
|
||||||
|
|
||||||
# Where will its root directory be for static files?
|
# Where will its root directory be for static files?
|
||||||
|
|
||||||
root = get_web_root(domain, env)
|
root = get_web_root(domain, env)
|
||||||
|
|
||||||
# What private key and SSL certificate will we use for this domain?
|
# What private key and SSL certificate will we use for this domain?
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env)
|
||||||
|
|
||||||
# For hostnames created after the initial setup, ensure we have an SSL certificate
|
# ADDITIONAL DIRECTIVES.
|
||||||
# available. Make a self-signed one now if one doesn't exist.
|
|
||||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env)
|
|
||||||
|
|
||||||
# Put pieces together.
|
nginx_conf_extra = ""
|
||||||
nginx_conf_parts = re.split("\s*# ADDITIONAL DIRECTIVES HERE\s*", template)
|
|
||||||
nginx_conf = nginx_conf_parts[0] + "\n"
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
nginx_conf += template_for_primaryhost + "\n"
|
|
||||||
|
|
||||||
# Replace substitution strings in the template & return.
|
|
||||||
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
|
||||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
|
||||||
nginx_conf = nginx_conf.replace("$ROOT", root)
|
|
||||||
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
|
|
||||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
|
|
||||||
|
|
||||||
# Because the certificate may change, we should recognize this so we
|
# Because the certificate may change, we should recognize this so we
|
||||||
# can trigger an nginx update.
|
# can trigger an nginx update.
|
||||||
@@ -102,26 +137,50 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
|
|||||||
finally:
|
finally:
|
||||||
f.close()
|
f.close()
|
||||||
return sha1.hexdigest()
|
return sha1.hexdigest()
|
||||||
nginx_conf += "# ssl files sha1: %s / %s\n" % (hashfile(ssl_key), hashfile(ssl_certificate))
|
nginx_conf_extra += "# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||||
|
|
||||||
# Add in any user customizations in YAML format.
|
# Add in any user customizations in YAML format.
|
||||||
|
hsts = "yes"
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
||||||
if domain in yaml:
|
if domain in yaml:
|
||||||
yaml = yaml[domain]
|
yaml = yaml[domain]
|
||||||
|
|
||||||
|
# any proxy or redirect here?
|
||||||
for path, url in yaml.get("proxies", {}).items():
|
for path, url in yaml.get("proxies", {}).items():
|
||||||
nginx_conf += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
nginx_conf_extra += "\tlocation %s {\n\t\tproxy_pass %s;\n\t}\n" % (path, url)
|
||||||
for path, url in yaml.get("redirects", {}).items():
|
for path, url in yaml.get("redirects", {}).items():
|
||||||
nginx_conf += "\trewrite %s %s permanent;\n" % (path, url)
|
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
||||||
|
|
||||||
|
# override the HSTS directive type
|
||||||
|
hsts = yaml.get("hsts", hsts)
|
||||||
|
|
||||||
|
# Add the HSTS header.
|
||||||
|
if hsts == "yes":
|
||||||
|
nginx_conf_extra += "add_header Strict-Transport-Security max-age=31536000;\n"
|
||||||
|
elif hsts == "preload":
|
||||||
|
nginx_conf_extra += "add_header Strict-Transport-Security \"max-age=10886400; includeSubDomains; preload\";\n"
|
||||||
|
|
||||||
# Add in any user customizations in the includes/ folder.
|
# Add in any user customizations in the includes/ folder.
|
||||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||||
if os.path.exists(nginx_conf_custom_include):
|
if os.path.exists(nginx_conf_custom_include):
|
||||||
nginx_conf += "\tinclude %s;\n" % (nginx_conf_custom_include)
|
nginx_conf_extra += "\tinclude %s;\n" % (nginx_conf_custom_include)
|
||||||
|
# PUT IT ALL TOGETHER
|
||||||
|
|
||||||
# Ending.
|
# Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder
|
||||||
nginx_conf += nginx_conf_parts[1]
|
# of the previous template.
|
||||||
|
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
|
||||||
|
for t in templates + [nginx_conf_extra]:
|
||||||
|
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
|
||||||
|
|
||||||
|
# Replace substitution strings in the template & return.
|
||||||
|
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
||||||
|
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
||||||
|
nginx_conf = nginx_conf.replace("$ROOT", root)
|
||||||
|
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
|
||||||
|
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"])
|
||||||
|
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
|
||||||
|
|
||||||
return nginx_conf
|
return nginx_conf
|
||||||
|
|
||||||
@@ -132,151 +191,18 @@ def get_web_root(domain, env, test_exists=True):
|
|||||||
if os.path.exists(root) or not test_exists: break
|
if os.path.exists(root) or not test_exists: break
|
||||||
return root
|
return root
|
||||||
|
|
||||||
def get_domain_ssl_files(domain, env, allow_shared_cert=True):
|
|
||||||
# What SSL private key will we use? Allow the user to override this, but
|
|
||||||
# in many cases using the same private key for all domains would be fine.
|
|
||||||
# Don't allow the user to override the key for PRIMARY_HOSTNAME because
|
|
||||||
# that's what's in the main file.
|
|
||||||
ssl_key = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_private_key.pem')
|
|
||||||
ssl_key_is_alt = False
|
|
||||||
alt_key = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/private_key.pem' % safe_domain_name(domain))
|
|
||||||
if domain != env['PRIMARY_HOSTNAME'] and os.path.exists(alt_key):
|
|
||||||
ssl_key = alt_key
|
|
||||||
ssl_key_is_alt = True
|
|
||||||
|
|
||||||
# What SSL certificate will we use?
|
|
||||||
ssl_certificate_primary = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
|
|
||||||
ssl_via = None
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
# For PRIMARY_HOSTNAME, use the one we generated at set-up time.
|
|
||||||
ssl_certificate = ssl_certificate_primary
|
|
||||||
else:
|
|
||||||
# For other domains, we'll probably use a certificate in a different path.
|
|
||||||
ssl_certificate = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/ssl_certificate.pem' % safe_domain_name(domain))
|
|
||||||
|
|
||||||
# But we can be smart and reuse the main SSL certificate if is has
|
|
||||||
# a Subject Alternative Name matching this domain. Don't do this if
|
|
||||||
# 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":
|
|
||||||
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":
|
|
||||||
ssl_certificate = ssl_certificate_parent
|
|
||||||
ssl_via = "Using multi/wildcard certificate of %s." % domain[4:]
|
|
||||||
|
|
||||||
return ssl_key, ssl_certificate, ssl_via
|
|
||||||
|
|
||||||
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, env):
|
|
||||||
# For domains besides PRIMARY_HOSTNAME, generate a self-signed certificate if
|
|
||||||
# a certificate doesn't already exist. See setup/mail.sh for documentation.
|
|
||||||
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sanity check. Shouldn't happen. A non-primary domain might use this
|
|
||||||
# certificate (see above), but then the certificate should exist anyway.
|
|
||||||
if ssl_certificate == os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem'):
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.path.exists(ssl_certificate):
|
|
||||||
return
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
|
||||||
|
|
||||||
# Generate a new self-signed certificate using the same private key that we already have.
|
|
||||||
|
|
||||||
# Start with a CSR written to a temporary file.
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w") as csr_fp:
|
|
||||||
csr_fp.write(create_csr(domain, ssl_key, env))
|
|
||||||
csr_fp.flush() # since we won't close until after running 'openssl x509', since close triggers delete.
|
|
||||||
|
|
||||||
# And then make the certificate.
|
|
||||||
shell("check_call", [
|
|
||||||
"openssl", "x509", "-req",
|
|
||||||
"-days", "365",
|
|
||||||
"-in", csr_fp.name,
|
|
||||||
"-signkey", ssl_key,
|
|
||||||
"-out", ssl_certificate])
|
|
||||||
|
|
||||||
def create_csr(domain, ssl_key, env):
|
|
||||||
return shell("check_output", [
|
|
||||||
"openssl", "req", "-new",
|
|
||||||
"-key", ssl_key,
|
|
||||||
"-sha256",
|
|
||||||
"-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):
|
|
||||||
return "Invalid domain name."
|
|
||||||
|
|
||||||
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
|
||||||
# The certificate always goes above the chain.
|
|
||||||
import tempfile, os
|
|
||||||
fd, fn = tempfile.mkstemp('.pem')
|
|
||||||
os.write(fd, (ssl_cert + '\n' + ssl_chain).encode("ascii"))
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
# Do validation on the certificate before installing it.
|
|
||||||
from status_checks import check_certificate
|
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env, allow_shared_cert=False)
|
|
||||||
cert_status, cert_status_details = check_certificate(domain, fn, ssl_key)
|
|
||||||
if cert_status != "OK":
|
|
||||||
if cert_status == "SELF-SIGNED":
|
|
||||||
cert_status = "This is a self-signed certificate. I can't install that."
|
|
||||||
os.unlink(fn)
|
|
||||||
if cert_status_details is not None:
|
|
||||||
cert_status += " " + cert_status_details
|
|
||||||
return cert_status
|
|
||||||
|
|
||||||
# Copy the certificate to its expected location.
|
|
||||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
|
||||||
shutil.move(fn, ssl_certificate)
|
|
||||||
|
|
||||||
ret = ["OK"]
|
|
||||||
|
|
||||||
# When updating the cert for PRIMARY_HOSTNAME, also update DNS because it is
|
|
||||||
# used in the DANE TLSA record and restart postfix and dovecot which use
|
|
||||||
# that certificate.
|
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
|
||||||
ret.append( do_dns_update(env) )
|
|
||||||
|
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
|
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
|
|
||||||
ret.append("mail services restarted")
|
|
||||||
|
|
||||||
# Kick nginx so it sees the cert.
|
|
||||||
ret.append( do_web_update(env) )
|
|
||||||
return "\n".join(ret)
|
|
||||||
|
|
||||||
def get_web_domains_info(env):
|
def get_web_domains_info(env):
|
||||||
# load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
www_redirects = set(get_web_domains(env)) - set(get_web_domains(env, include_www_redirects=False))
|
||||||
# which means static hosting is not happening
|
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))
|
||||||
custom_settings = { }
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
|
||||||
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
|
||||||
def has_root_proxy_or_redirect(domain):
|
|
||||||
return custom_settings.get(domain, {}).get('redirects', {}).get('/') or custom_settings.get(domain, {}).get('proxies', {}).get('/')
|
|
||||||
|
|
||||||
# for the SSL config panel, get cert status
|
# for the SSL config panel, get cert status
|
||||||
def check_cert(domain):
|
def check_cert(domain):
|
||||||
from status_checks import check_certificate
|
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
||||||
ssl_key, ssl_certificate, ssl_via = get_domain_ssl_files(domain, env)
|
if tls_cert is None: return ("danger", "No Certificate Installed")
|
||||||
if not os.path.exists(ssl_certificate):
|
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||||
return ("danger", "No Certificate Installed")
|
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
|
||||||
if cert_status == "OK":
|
if cert_status == "OK":
|
||||||
if not ssl_via:
|
|
||||||
return ("success", "Signed & valid. " + cert_status_details)
|
return ("success", "Signed & valid. " + cert_status_details)
|
||||||
else:
|
|
||||||
# This is an alternate domain but using the same cert as the primary domain.
|
|
||||||
return ("success", "Signed & valid. " + ssl_via)
|
|
||||||
elif cert_status == "SELF-SIGNED":
|
elif cert_status == "SELF-SIGNED":
|
||||||
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||||
else:
|
else:
|
||||||
@@ -288,7 +214,7 @@ def get_web_domains_info(env):
|
|||||||
"root": get_web_root(domain, env),
|
"root": get_web_root(domain, env),
|
||||||
"custom_root": get_web_root(domain, env, test_exists=False),
|
"custom_root": get_web_root(domain, env, test_exists=False),
|
||||||
"ssl_certificate": check_cert(domain),
|
"ssl_certificate": check_cert(domain),
|
||||||
"static_enabled": not has_root_proxy_or_redirect(domain),
|
"static_enabled": domain not in (www_redirects | has_root_proxy_or_redirect),
|
||||||
}
|
}
|
||||||
for domain in get_web_domains(env)
|
for domain in get_web_domains(env)
|
||||||
]
|
]
|
||||||
|
|||||||
28
ppa/Makefile
28
ppa/Makefile
@@ -1,27 +1,35 @@
|
|||||||
|
POSTGREY_VERSION=1.35-1+miab1
|
||||||
|
DOVECOT_VERSION=2.2.9-1ubuntu2.1+miab1
|
||||||
|
|
||||||
all: clean build_postgrey build_dovecot_lucene
|
all: clean build_postgrey build_dovecot_lucene
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
# Clean.
|
# Clean.
|
||||||
rm -rf /tmp/build
|
rm -rf /tmp/build
|
||||||
|
|
||||||
# Prepare to build source packages.
|
|
||||||
mkdir -p /tmp/build
|
mkdir -p /tmp/build
|
||||||
|
|
||||||
build_postgrey:
|
build_postgrey: clean
|
||||||
# Download our fork of the Debian postgrey package.
|
# Download the latest Debian postgrey package. It is ahead of Ubuntu,
|
||||||
git clone https://github.com/mail-in-a-box/postgrey /tmp/build/postgrey
|
# and we might as well jump ahead.
|
||||||
|
git clone git://git.debian.org/git/collab-maint/postgrey.git /tmp/build/postgrey
|
||||||
|
|
||||||
# Download the corresponding upstream package.
|
# Download the corresponding upstream package.
|
||||||
wget -O /tmp/build/postgrey_1.35.orig.tar.gz http://postgrey.schweikert.ch/pub/postgrey-1.35.tar.gz
|
wget -O /tmp/build/postgrey_1.35.orig.tar.gz http://postgrey.schweikert.ch/pub/postgrey-1.35.tar.gz
|
||||||
|
|
||||||
|
# Add our source patch to the debian packaging listing.
|
||||||
|
cp postgrey_sources.diff /tmp/build/postgrey/debian/patches/mailinabox
|
||||||
|
|
||||||
|
# Patch the packaging to give it a new version.
|
||||||
|
patch -p1 -d /tmp/build/postgrey < postgrey.diff
|
||||||
|
|
||||||
# Build the source package.
|
# Build the source package.
|
||||||
(cd /tmp/build/postgrey; dpkg-buildpackage -S -us -uc -nc)
|
(cd /tmp/build/postgrey; dpkg-buildpackage -S -us -uc -nc)
|
||||||
|
|
||||||
# Sign the packages.
|
# Sign the packages.
|
||||||
debsign /tmp/build/postgrey_1.35-1miab1_source.changes
|
debsign /tmp/build/postgrey_$(POSTGREY_VERSION)_source.changes
|
||||||
|
|
||||||
# Upload to PPA.
|
# Upload to PPA.
|
||||||
dput ppa:mail-in-a-box/ppa /tmp/build/postgrey_1.35-1miab1_source.changes
|
dput ppa:mail-in-a-box/ppa /tmp/build/postgrey_$(POSTGREY_VERSION)_source.changes
|
||||||
|
|
||||||
# Clear the intermediate files.
|
# Clear the intermediate files.
|
||||||
rm -rf /tmp/build/postgrey
|
rm -rf /tmp/build/postgrey
|
||||||
@@ -30,7 +38,7 @@ build_postgrey:
|
|||||||
#sudo apt-get build-dep -y postgrey
|
#sudo apt-get build-dep -y postgrey
|
||||||
#(cd /tmp/build/postgrey; dpkg-buildpackage -us -uc -nc)
|
#(cd /tmp/build/postgrey; dpkg-buildpackage -us -uc -nc)
|
||||||
|
|
||||||
build_dovecot_lucene:
|
build_dovecot_lucene: clean
|
||||||
# Get the upstream source.
|
# Get the upstream source.
|
||||||
(cd /tmp/build; apt-get source dovecot)
|
(cd /tmp/build; apt-get source dovecot)
|
||||||
|
|
||||||
@@ -41,10 +49,10 @@ build_dovecot_lucene:
|
|||||||
(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -S -us -uc -nc)
|
(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -S -us -uc -nc)
|
||||||
|
|
||||||
# Sign the packages.
|
# Sign the packages.
|
||||||
#debsign /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
|
debsign /tmp/build/dovecot_$(DOVECOT_VERSION)_source.changes
|
||||||
|
|
||||||
# Upload it.
|
# Upload it.
|
||||||
#dput ppa:mail-in-a-box/ppa /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
|
dput ppa:mail-in-a-box/ppa /tmp/build/dovecot_$(DOVECOT_VERSION)_source.changes
|
||||||
|
|
||||||
# TESTING BINARY PACKAGE
|
# TESTING BINARY PACKAGE
|
||||||
# Install build dependencies and build dependencies we've added in our patch,
|
# Install build dependencies and build dependencies we've added in our patch,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ Mail-in-a-Box maintains a Launchpad.net PPA ([Mail-in-a-Box PPA](https://launchp
|
|||||||
Packages
|
Packages
|
||||||
--------
|
--------
|
||||||
|
|
||||||
* [postgrey](https://github.com/mail-in-a-box/postgrey), with a modification to whitelist senders that are whitelisted by [dnswl.org](https://www.dnswl.org/) (i.e. don't greylist mail from them).
|
* postgrey, a fork of [postgrey](http://postgrey.schweikert.ch/) based on the [latest Debian package](http://git.debian.org/?p=collab-maint/postgrey.git), with a modification to whitelist senders that are whitelisted by [dnswl.org](https://www.dnswl.org/) (i.e. don't greylist mail from known good senders).
|
||||||
|
|
||||||
|
* dovecot-lucene, [dovecot's lucene full text search plugin](http://wiki2.dovecot.org/Plugins/FTS/Lucene), which isn't built by Ubuntu's dovecot package maintainer unfortunately.
|
||||||
|
|
||||||
Building
|
Building
|
||||||
--------
|
--------
|
||||||
@@ -30,9 +32,9 @@ To build:
|
|||||||
# Build & upload to launchpad.
|
# Build & upload to launchpad.
|
||||||
vagrant ssh -- "cd /vagrant && make"
|
vagrant ssh -- "cd /vagrant && make"
|
||||||
|
|
||||||
To use on a Mail-in-a-Box box, add the PPA and then upgrade packages:
|
Mail-in-a-Box adds our PPA during setup, but if you need to do that yourself for testing:
|
||||||
|
|
||||||
apt-add-repository ppa:mail-in-a-box/ppa
|
apt-add-repository ppa:mail-in-a-box/ppa
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get upgrade
|
apt-get install postgrey dovecot-lucene
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
--- a/debian/control
|
--- a/debian/control
|
||||||
+++ b/debian/control
|
+++ b/debian/control
|
||||||
@@ -1,210 +1,22 @@
|
@@ -1,210 +1,23 @@
|
||||||
Source: dovecot
|
Source: dovecot
|
||||||
Section: mail
|
Section: mail
|
||||||
Priority: optional
|
Priority: optional
|
||||||
@@ -13,11 +13,14 @@
|
|||||||
+Build-Depends: debhelper (>= 7.2.3~), dpkg-dev (>= 1.16.1), pkg-config, libssl-dev, libpam0g-dev, libldap2-dev, libpq-dev, libmysqlclient-dev, libsqlite3-dev, libsasl2-dev, zlib1g-dev, libkrb5-dev, drac-dev (>= 1.12-5), libbz2-dev, libdb-dev, libcurl4-gnutls-dev, libexpat-dev, libwrap0-dev, dh-systemd, po-debconf, lsb-release, libclucene-dev (>= 2.3), liblzma-dev, libexttextcat-dev, libstemmer-dev, hardening-wrapper, dh-autoreconf, autotools-dev
|
+Build-Depends: debhelper (>= 7.2.3~), dpkg-dev (>= 1.16.1), pkg-config, libssl-dev, libpam0g-dev, libldap2-dev, libpq-dev, libmysqlclient-dev, libsqlite3-dev, libsasl2-dev, zlib1g-dev, libkrb5-dev, drac-dev (>= 1.12-5), libbz2-dev, libdb-dev, libcurl4-gnutls-dev, libexpat-dev, libwrap0-dev, dh-systemd, po-debconf, lsb-release, libclucene-dev (>= 2.3), liblzma-dev, libexttextcat-dev, libstemmer-dev, hardening-wrapper, dh-autoreconf, autotools-dev
|
||||||
Standards-Version: 3.9.4
|
Standards-Version: 3.9.4
|
||||||
Homepage: http://dovecot.org/
|
Homepage: http://dovecot.org/
|
||||||
Vcs-Git: git://git.debian.org/git/collab-maint/dovecot.git
|
-Vcs-Git: git://git.debian.org/git/collab-maint/dovecot.git
|
||||||
Vcs-Browser: http://git.debian.org/?p=collab-maint/dovecot.git
|
-Vcs-Browser: http://git.debian.org/?p=collab-maint/dovecot.git
|
||||||
|
+Vcs-Git: https://github.com/mail-in-a-box/mailinabox
|
||||||
|
+Vcs-Browser: https://github.com/mail-in-a-box/mailinabox
|
||||||
|
|
||||||
-Package: dovecot-core
|
-Package: dovecot-core
|
||||||
-Architecture: any
|
+Package: dovecot-lucene
|
||||||
|
Architecture: any
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-runtime (>= 0.76-13.1), openssl, adduser, ucf (>= 2.0020), ssl-cert (>= 1.0-11ubuntu1), lsb-base (>= 3.2-12ubuntu3)
|
-Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-runtime (>= 0.76-13.1), openssl, adduser, ucf (>= 2.0020), ssl-cert (>= 1.0-11ubuntu1), lsb-base (>= 3.2-12ubuntu3)
|
||||||
-Suggests: ntp, dovecot-gssapi, dovecot-sieve, dovecot-pgsql, dovecot-mysql, dovecot-sqlite, dovecot-ldap, dovecot-imapd, dovecot-pop3d, dovecot-lmtpd, dovecot-managesieved, dovecot-solr, ufw
|
-Suggests: ntp, dovecot-gssapi, dovecot-sieve, dovecot-pgsql, dovecot-mysql, dovecot-sqlite, dovecot-ldap, dovecot-imapd, dovecot-pop3d, dovecot-lmtpd, dovecot-managesieved, dovecot-solr, ufw
|
||||||
-Recommends: ntpdate
|
-Recommends: ntpdate
|
||||||
@@ -25,12 +28,14 @@
|
|||||||
-Replaces: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
|
-Replaces: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
|
||||||
-Breaks: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
|
-Breaks: dovecot-common (<< 1:2.0.14-2~), mailavenger (<< 0.8.1-4)
|
||||||
-Description: secure POP3/IMAP server - core files
|
-Description: secure POP3/IMAP server - core files
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
+Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (>= 1:2.2.9-1ubuntu2.1)
|
||||||
- reliability. It tries very hard to handle all error conditions and verify
|
+Description: secure POP3/IMAP server - Lucene support
|
||||||
- that all data is valid, making it nearly impossible to crash. It supports
|
Dovecot is a mail server whose major goals are security and extreme
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
reliability. It tries very hard to handle all error conditions and verify
|
||||||
- fast, extensible, and portable.
|
that all data is valid, making it nearly impossible to crash. It supports
|
||||||
- .
|
mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
||||||
|
fast, extensible, and portable.
|
||||||
|
.
|
||||||
- This package contains the Dovecot main server and its command line utility.
|
- This package contains the Dovecot main server and its command line utility.
|
||||||
-
|
-
|
||||||
-Package: dovecot-dev
|
-Package: dovecot-dev
|
||||||
@@ -151,17 +156,15 @@
|
|||||||
- This package provides LDAP support for Dovecot.
|
- This package provides LDAP support for Dovecot.
|
||||||
-
|
-
|
||||||
-Package: dovecot-gssapi
|
-Package: dovecot-gssapi
|
||||||
+Package: dovecot-lucene
|
-Architecture: any
|
||||||
Architecture: any
|
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
||||||
Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - GSSAPI support
|
-Description: secure POP3/IMAP server - GSSAPI support
|
||||||
+Description: secure POP3/IMAP server - Lucene support
|
- Dovecot is a mail server whose major goals are security and extreme
|
||||||
Dovecot is a mail server whose major goals are security and extreme
|
- reliability. It tries very hard to handle all error conditions and verify
|
||||||
reliability. It tries very hard to handle all error conditions and verify
|
- that all data is valid, making it nearly impossible to crash. It supports
|
||||||
that all data is valid, making it nearly impossible to crash. It supports
|
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
||||||
mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
- fast, extensible, and portable.
|
||||||
fast, extensible, and portable.
|
- .
|
||||||
.
|
|
||||||
- This package provides GSSAPI authentication support for Dovecot.
|
- This package provides GSSAPI authentication support for Dovecot.
|
||||||
-
|
-
|
||||||
-Package: dovecot-sieve
|
-Package: dovecot-sieve
|
||||||
@@ -216,7 +219,9 @@
|
|||||||
- This package contains configuration files for dovecot.
|
- This package contains configuration files for dovecot.
|
||||||
- .
|
- .
|
||||||
- This package modifies postfix's configuration to integrate with dovecot
|
- This package modifies postfix's configuration to integrate with dovecot
|
||||||
+ This package provides Lucene full text search support for Dovecot.
|
+ This package provides Lucene full text search support for Dovecot. It has been modified by Mail-in-a-Box
|
||||||
|
+ to supply a dovecot-lucene package compatible with the official ubuntu trusty dovecot-core.
|
||||||
|
|
||||||
diff --git a/debian/dovecot-lucene.links b/debian/dovecot-lucene.links
|
diff --git a/debian/dovecot-lucene.links b/debian/dovecot-lucene.links
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 0000000..6ffcbeb
|
index 0000000..6ffcbeb
|
||||||
@@ -247,11 +252,9 @@ index 0000000..3d933a5
|
|||||||
+++ b/debian/dovecot-lucene.triggers
|
+++ b/debian/dovecot-lucene.triggers
|
||||||
@@ -0,0 +1 @@
|
@@ -0,0 +1 @@
|
||||||
+activate register-dovecot-plugin
|
+activate register-dovecot-plugin
|
||||||
diff --git a/debian/rules b/debian/rules
|
|
||||||
index dcee2f6..9533a4a 100755
|
|
||||||
--- a/debian/rules
|
--- a/debian/rules
|
||||||
+++ b/debian/rules
|
+++ b/debian/rules
|
||||||
@@ -40,6 +40,7 @@ config-stamp: configure
|
@@ -40,6 +40,7 @@
|
||||||
--with-solr \
|
--with-solr \
|
||||||
--with-ioloop=best \
|
--with-ioloop=best \
|
||||||
--with-libwrap \
|
--with-libwrap \
|
||||||
@@ -259,7 +262,7 @@ index dcee2f6..9533a4a 100755
|
|||||||
--host=$(DEB_HOST_GNU_TYPE) \
|
--host=$(DEB_HOST_GNU_TYPE) \
|
||||||
--build=$(DEB_BUILD_GNU_TYPE) \
|
--build=$(DEB_BUILD_GNU_TYPE) \
|
||||||
--prefix=/usr \
|
--prefix=/usr \
|
||||||
@@ -95,6 +96,10 @@ install: build
|
@@ -95,6 +96,10 @@
|
||||||
dh_testroot
|
dh_testroot
|
||||||
dh_clean -k
|
dh_clean -k
|
||||||
dh_installdirs
|
dh_installdirs
|
||||||
@@ -270,7 +273,7 @@ index dcee2f6..9533a4a 100755
|
|||||||
$(MAKE) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
$(MAKE) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
||||||
$(MAKE) -C $(PIGEONHOLE_DIR) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
$(MAKE) -C $(PIGEONHOLE_DIR) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
||||||
rm `find $(CURDIR)/debian -name '*.la'`
|
rm `find $(CURDIR)/debian -name '*.la'`
|
||||||
@@ -209,7 +214,7 @@ binary-arch: build install
|
@@ -209,7 +214,7 @@
|
||||||
dh_installdocs -a
|
dh_installdocs -a
|
||||||
dh_installexamples -a
|
dh_installexamples -a
|
||||||
dh_installpam -a
|
dh_installpam -a
|
||||||
@@ -279,7 +282,7 @@ index dcee2f6..9533a4a 100755
|
|||||||
dh_systemd_enable
|
dh_systemd_enable
|
||||||
dh_installinit -pdovecot-core --name=dovecot
|
dh_installinit -pdovecot-core --name=dovecot
|
||||||
dh_systemd_start
|
dh_systemd_start
|
||||||
@@ -220,10 +225,10 @@ binary-arch: build install
|
@@ -220,10 +225,10 @@
|
||||||
dh_lintian -a
|
dh_lintian -a
|
||||||
dh_installchangelogs -a ChangeLog
|
dh_installchangelogs -a ChangeLog
|
||||||
dh_link -a
|
dh_link -a
|
||||||
@@ -292,3 +295,25 @@ index dcee2f6..9533a4a 100755
|
|||||||
dh_makeshlibs -a -n
|
dh_makeshlibs -a -n
|
||||||
dh_installdeb -a
|
dh_installdeb -a
|
||||||
dh_shlibdeps -a
|
dh_shlibdeps -a
|
||||||
|
--- a/debian/changelog
|
||||||
|
+++ a/debian/changelog
|
||||||
|
@@ -1,3 +1,9 @@
|
||||||
|
+dovecot (1:2.2.9-1ubuntu2.1+miab1) trusty; urgency=low
|
||||||
|
+
|
||||||
|
+ * Changed to just build dovecot-lucene for Mail-in-a-box PPA
|
||||||
|
+
|
||||||
|
+ -- Joshua Tauberer <jt@occams.info> Sat, 14 May 2015 16:13:00 -0400
|
||||||
|
+
|
||||||
|
dovecot (1:2.2.9-1ubuntu2.1) trusty-security; urgency=medium
|
||||||
|
|
||||||
|
* SECURITY UPDATE: denial of service via SSL connection exhaustion
|
||||||
|
--- a/debian/copyright 2014-03-07 07:26:37.000000000 -0500
|
||||||
|
+++ b/debian/copyright 2015-05-23 18:17:42.668005535 -0400
|
||||||
|
@@ -1,3 +1,7 @@
|
||||||
|
+This package is a fork by Mail-in-a-box (https://mailinabox.email). Original
|
||||||
|
+copyright statement follows:
|
||||||
|
+----------------------------------------------------------------------------
|
||||||
|
+
|
||||||
|
This package was debianized by Jaldhar H. Vyas <jaldhar@debian.org> on
|
||||||
|
Tue, 3 Dec 2002 01:10:07 -0500.
|
||||||
|
|
||||||
|
|||||||
80
ppa/postgrey.diff
Normal file
80
ppa/postgrey.diff
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
diff --git a/debian/NEWS b/debian/NEWS
|
||||||
|
index dd09744..de7b640 100644
|
||||||
|
--- a/debian/NEWS
|
||||||
|
+++ b/debian/NEWS
|
||||||
|
@@ -1,3 +1,9 @@
|
||||||
|
+postgrey (1.35-1+miab1)
|
||||||
|
+
|
||||||
|
+ Added DNSWL.org whitelisting.
|
||||||
|
+
|
||||||
|
+ -- Joshua Tauberer <jt@occams.info> Mon May 18 18:58:40 EDT 2015
|
||||||
|
+
|
||||||
|
postgrey (1.32-1) unstable; urgency=low
|
||||||
|
|
||||||
|
Postgrey is now listening to port 10023 and not 60000. The latter was an
|
||||||
|
diff --git a/debian/changelog b/debian/changelog
|
||||||
|
index 1058e15..e5e3557 100644
|
||||||
|
--- a/debian/changelog
|
||||||
|
+++ b/debian/changelog
|
||||||
|
@@ -1,3 +1,9 @@
|
||||||
|
+postgrey (1.35-1+miab1) trusty; urgency=low
|
||||||
|
+
|
||||||
|
+ * Added DNSWL.org whitelisting.
|
||||||
|
+
|
||||||
|
+ -- Joshua Tauberer <jt@occams.info> Mon, 18 May 2015 21:58:40 +0000
|
||||||
|
+
|
||||||
|
postgrey (1.35-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* New upstream release (Closes: 756486)
|
||||||
|
diff --git a/debian/control b/debian/control
|
||||||
|
index ce12ba6..0a82855 100644
|
||||||
|
--- a/debian/control
|
||||||
|
+++ b/debian/control
|
||||||
|
@@ -1,14 +1,11 @@
|
||||||
|
Source: postgrey
|
||||||
|
Section: mail
|
||||||
|
Priority: optional
|
||||||
|
-Maintainer: Antonio Radici <antonio@debian.org>
|
||||||
|
-Uploaders: Jon Daley <jondaley-guest@alioth.debian.org>
|
||||||
|
+Maintainer: Joshua Tauberer <jt@occams.info>
|
||||||
|
Build-Depends: debhelper (>= 7), quilt
|
||||||
|
Build-Depends-Indep: po-debconf
|
||||||
|
Standards-Version: 3.9.6
|
||||||
|
Homepage: http://postgrey.schweikert.ch/
|
||||||
|
-Vcs-Browser: http://git.debian.org/?p=collab-maint/postgrey.git
|
||||||
|
-Vcs-Git: git://git.debian.org/git/collab-maint/postgrey.git
|
||||||
|
|
||||||
|
Package: postgrey
|
||||||
|
Architecture: all
|
||||||
|
@@ -25,3 +22,6 @@ Description: greylisting implementation for Postfix
|
||||||
|
.
|
||||||
|
While Postgrey is designed for use with Postfix, it can also be used
|
||||||
|
with Exim.
|
||||||
|
+ .
|
||||||
|
+ This version has been modified by Mail-in-a-Box to whitelist senders
|
||||||
|
+ in the DNSWL.org list. See https://mailinabox.email.
|
||||||
|
diff --git a/debian/copyright b/debian/copyright
|
||||||
|
index 3cbe377..bf09b89 100644
|
||||||
|
--- a/debian/copyright
|
||||||
|
+++ b/debian/copyright
|
||||||
|
@@ -1,6 +1,10 @@
|
||||||
|
+This package is a fork by Mail-in-a-Box (https://mailinabox.email). Original
|
||||||
|
+copyright statement follows:
|
||||||
|
+----------------------------------------------------------------------------
|
||||||
|
+
|
||||||
|
This Debian package was prepared by Adrian von Bidder <cmot@debian.org> in
|
||||||
|
July 2004, then the package was adopted by Antonio Radici <antonio@dyne.org>
|
||||||
|
-in Sept 2009
|
||||||
|
+in Sept 2009.
|
||||||
|
|
||||||
|
It was downloaded from http://postgrey.schweikert.ch/
|
||||||
|
|
||||||
|
diff --git a/debian/patches/series b/debian/patches/series
|
||||||
|
index f4c5e31..3cd62b8 100644
|
||||||
|
--- a/debian/patches/series
|
||||||
|
+++ b/debian/patches/series
|
||||||
|
@@ -1,3 +1,3 @@
|
||||||
|
imported-upstream-diff
|
||||||
|
disable-transaction-logic
|
||||||
|
-
|
||||||
|
+mailinabox
|
||||||
100
ppa/postgrey_sources.diff
Normal file
100
ppa/postgrey_sources.diff
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
Description: whitelist whatever dnswl.org whitelists
|
||||||
|
.
|
||||||
|
postgrey (1.35-1+miab1) unstable; urgency=low
|
||||||
|
.
|
||||||
|
* Added DNSWL.org whitelisting.
|
||||||
|
Author: Joshua Tauberer <jt@occams.info>
|
||||||
|
|
||||||
|
--- postgrey-1.35.orig/README
|
||||||
|
+++ postgrey-1.35/README
|
||||||
|
@@ -13,7 +13,7 @@ Requirements
|
||||||
|
- BerkeleyDB (Perl Module)
|
||||||
|
- Berkeley DB >= 4.1 (Library)
|
||||||
|
- Digest::SHA (Perl Module, only for --privacy option)
|
||||||
|
-
|
||||||
|
+- Net::DNS (Perl Module)
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
-------------
|
||||||
|
--- postgrey-1.35.orig/postgrey
|
||||||
|
+++ postgrey-1.35/postgrey
|
||||||
|
@@ -18,6 +18,7 @@ use Fcntl ':flock'; # import LOCK_* cons
|
||||||
|
use Sys::Hostname;
|
||||||
|
use Sys::Syslog; # used only to find out which version we use
|
||||||
|
use POSIX qw(strftime setlocale LC_ALL);
|
||||||
|
+use Net::DNS; # for DNSWL.org whitelisting
|
||||||
|
|
||||||
|
use vars qw(@ISA);
|
||||||
|
@ISA = qw(Net::Server::Multiplex);
|
||||||
|
@@ -26,6 +27,8 @@ my $VERSION = '1.35';
|
||||||
|
my $DEFAULT_DBDIR = '/var/lib/postgrey';
|
||||||
|
my $CONFIG_DIR = '/etc/postgrey';
|
||||||
|
|
||||||
|
+my $dns_resolver = Net::DNS::Resolver->new;
|
||||||
|
+
|
||||||
|
sub cidr_parse($)
|
||||||
|
{
|
||||||
|
defined $_[0] or return undef;
|
||||||
|
@@ -48,6 +51,36 @@ sub cidr_match($$$)
|
||||||
|
return ($addr & $mask) == $net;
|
||||||
|
}
|
||||||
|
|
||||||
|
+sub reverseDottedQuad {
|
||||||
|
+ # This is the sub _chkValidPublicIP from Net::DNSBL by PJ Goodwin
|
||||||
|
+ # at http://www.the42.net/net-dnsbl.
|
||||||
|
+ my ($quad) = @_;
|
||||||
|
+ if ($quad =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) {
|
||||||
|
+ my ($ip1,$ip2,$ip3,$ip4) = ($1, $2, $3, $4);
|
||||||
|
+ if (
|
||||||
|
+ $ip1 == 10 || #10.0.0.0/8 (10/8)
|
||||||
|
+ ($ip1 == 172 && $ip2 >= 16 && $ip2 <= 31) || #172.16.0.0/12 (172.16/12)
|
||||||
|
+ ($ip1 == 192 && $ip2 == 168) || #192.168.0.0/16 (192.168/16)
|
||||||
|
+ $quad eq '127.0.0.1' # localhost
|
||||||
|
+ ) {
|
||||||
|
+ # toss the RFC1918 specified privates
|
||||||
|
+ return undef;
|
||||||
|
+ } elsif (
|
||||||
|
+ ($ip1 <= 1 || $ip1 > 254) ||
|
||||||
|
+ ($ip2 < 0 || $ip2 > 255) ||
|
||||||
|
+ ($ip3 < 0 || $ip3 > 255) ||
|
||||||
|
+ ($ip4 < 0 || $ip4 > 255)
|
||||||
|
+ ) {
|
||||||
|
+ #invalid oct, toss it;
|
||||||
|
+ return undef;
|
||||||
|
+ }
|
||||||
|
+ my $revquad = $ip4 . "." . $ip3 . "." . $ip2 . "." . $ip1;
|
||||||
|
+ return $revquad;
|
||||||
|
+ } else { # invalid quad
|
||||||
|
+ return undef;
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
sub read_clients_whitelists($)
|
||||||
|
{
|
||||||
|
my ($self) = @_;
|
||||||
|
@@ -361,6 +394,25 @@ sub smtpd_access_policy($$)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ # whitelist clients in dnswl.org
|
||||||
|
+ my $revip = reverseDottedQuad($attr->{client_address});
|
||||||
|
+ if ($revip) { # valid IP / plausibly in DNSWL
|
||||||
|
+ my $answer = $dns_resolver->send($revip . '.list.dnswl.org');
|
||||||
|
+ if ($answer && scalar($answer->answer) > 0) {
|
||||||
|
+ my @rrs = $answer->answer;
|
||||||
|
+ if ($rrs[0]->type eq 'A' && $rrs[0]->address ne '127.0.0.255') {
|
||||||
|
+ # Address appears in DNSWL. (127.0.0.255 means we were rate-limited.)
|
||||||
|
+ my $code = $rrs[0]->address;
|
||||||
|
+ if ($code =~ /^127.0.(\d+)\.([0-3])$/) {
|
||||||
|
+ my %dnswltrust = (0 => 'legitimate', 1 => 'occasional spam', 2 => 'rare spam', 3 => 'highly unlikely to send spam');
|
||||||
|
+ $code = $2 . '/' . $dnswltrust{$2};
|
||||||
|
+ }
|
||||||
|
+ $self->mylog_action($attr, 'pass', 'client whitelisted by dnswl.org (' . $code . ')');
|
||||||
|
+ return 'DUNNO';
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
# auto whitelist clients (see below for explanation)
|
||||||
|
my ($cawl_db, $cawl_key, $cawl_count, $cawl_last);
|
||||||
|
if($self->{postgrey}{awl_clients}) {
|
||||||
31
security.md
31
security.md
@@ -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:
|
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.
|
* 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 (more on that below).
|
* 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
|
User Credentials
|
||||||
----------------
|
----------------
|
||||||
@@ -35,7 +38,7 @@ These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_La
|
|||||||
|
|
||||||
The services all follow these rules:
|
The services all follow these rules:
|
||||||
|
|
||||||
* SSL certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))
|
* TLS certificates are generated with 2048-bit RSA keys and SHA-256 fingerprints. The box provides a self-signed certificate by default. The [setup guide](https://mailinabox.email/guide.html) explains how to verify the certificate fingerprint on first login. Users are encouraged to replace the certificate with a proper CA-signed one. ([source](setup/ssl.sh))
|
||||||
* Only TLSv1, TLSv1.1 and TLSv1.2 are offered (the older SSL protocols are not offered).
|
* Only TLSv1, TLSv1.1 and TLSv1.2 are offered (the older SSL protocols are not offered).
|
||||||
* Export-grade ciphers, the anonymous DH/ECDH algorithms (aNULL), and clear-text ciphers (eNULL) are not offered.
|
* Export-grade ciphers, the anonymous DH/ECDH algorithms (aNULL), and clear-text ciphers (eNULL) are not offered.
|
||||||
* The minimum cipher key length offered is 112 bits. The maximum is 256 bits. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy.
|
* The minimum cipher key length offered is 112 bits. The maximum is 256 bits. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy.
|
||||||
@@ -56,6 +59,8 @@ The cipher and protocol selection are chosen to support the following clients:
|
|||||||
|
|
||||||
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
|
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
|
||||||
|
|
||||||
|
When using the web-based administrative control panel, after logging in an API key is placed in the browser's local storage (rather than, say, the user's actual password). The API key is an HMAC based on the user's email address and current password, and it is keyed by a secret known only to the control panel service. By resetting an administrator's password, any HMACs previously generated for that user will expire.
|
||||||
|
|
||||||
### Console access
|
### Console access
|
||||||
|
|
||||||
Console access (e.g. via SSH) is configured by the system image used to create the box, typically from by a cloud virtual machine provider (e.g. Digital Ocean). Mail-in-a-Box does not set any console access settings, although it will warn the administrator in the System Status Checks if password-based login is turned on.
|
Console access (e.g. via SSH) is configured by the system image used to create the box, typically from by a cloud virtual machine provider (e.g. Digital Ocean). Mail-in-a-Box does not set any console access settings, although it will warn the administrator in the System Status Checks if password-based login is turned on.
|
||||||
@@ -67,17 +72,29 @@ If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that
|
|||||||
Outbound Mail
|
Outbound Mail
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
### Domain Policy Records
|
The basic protocols of email delivery did not plan for the presence of adversaries on the network. For a number of reasons it is not possible in most cases to guarantee that a connection to a recipient server is secure.
|
||||||
|
|
||||||
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))
|
### DNSSEC
|
||||||
|
|
||||||
|
The first step in resolving the destination server for an email address is performing a DNS look-up for the MX record of the domain name. The box uses a locally-running [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC)-aware nameserver to perform the lookup. If the domain name has DNSSEC enabled, DNSSEC guards against DNS records being tampered with.
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
The basic protocols of email delivery did not plan for the need for encryption. For a number of reasons it is not possible in most cases to guarantee that a connection to a recipient server is secure. However, the box --- along with the vast majority of mail servers --- uses [opportunistic encryption](https://en.wikipedia.org/wiki/Opportunistic_encryption), meaning the mail is encrypted in transit and protected from passive eavesdropping, but it is not protected from an active man-in-the-middle attack. Modern encryption settings will be used to the extent the recipient server supports them. ([source](setup/mail-postfix.sh))
|
The box (along with the vast majority of mail servers) uses [opportunistic encryption](https://en.wikipedia.org/wiki/Opportunistic_encryption), meaning the mail is encrypted in transit and protected from passive eavesdropping, but it is not protected from an active man-in-the-middle attack. Modern encryption settings will be used to the extent the recipient server supports them. ([source](setup/mail-postfix.sh))
|
||||||
|
|
||||||
### DANE
|
### DANE
|
||||||
|
|
||||||
The box is [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC)-aware (via a locally running DNSSEC-aware nameserver). When sending outbound mail, if the recipient's domain name supports DNSSEC and has published a [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) record, which contains a certificate fingerprint, the receiving MTA (server) must support TLS and its certificate must match the fingerprint. In other words, when a DANE TLSA record is published by the recipient, then on-the-wire encryption is forced between the box and the recipient MTA. ([source](setup/mail-postfix.sh))
|
If the recipient's domain name supports DNSSEC and has published a [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) record, then on-the-wire encryption is forced between the box and the recipient MTA and this encryption is not subject to a man-in-the-middle attack. The TLSA record contains a certificate fingerprint which the receiving MTA (server) must present to the box. ([source](setup/mail-postfix.sh))
|
||||||
|
|
||||||
|
### Domain Policy Records
|
||||||
|
|
||||||
|
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
|
Incoming Mail
|
||||||
-------------
|
-------------
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
#########################################################
|
#########################################################
|
||||||
# This script is intended to be run like this:
|
# This script is intended to be run like this:
|
||||||
#
|
#
|
||||||
# curl https://.../bootstrap.sh | sudo bash
|
# curl https://mailinabox.email/setup.sh | sudo bash
|
||||||
#
|
#
|
||||||
#########################################################
|
#########################################################
|
||||||
|
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG=v0.10
|
TAG=v0.17c
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
@@ -18,9 +18,12 @@ fi
|
|||||||
|
|
||||||
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
||||||
if [ ! -d $HOME/mailinabox ]; then
|
if [ ! -d $HOME/mailinabox ]; then
|
||||||
|
if [ ! -f /usr/bin/git ]; then
|
||||||
echo Installing git . . .
|
echo Installing git . . .
|
||||||
|
apt-get -q -q update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
||||||
echo
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
echo Downloading Mail-in-a-Box $TAG. . .
|
echo Downloading Mail-in-a-Box $TAG. . .
|
||||||
git clone \
|
git clone \
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ source setup/functions.sh # load our functions
|
|||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# Install DKIM...
|
# Install DKIM...
|
||||||
|
echo Installing OpenDKIM/OpenDMARC...
|
||||||
apt_install opendkim opendkim-tools opendmarc
|
apt_install opendkim opendkim-tools opendmarc
|
||||||
|
|
||||||
# Make sure configuration directories exist.
|
# Make sure configuration directories exist.
|
||||||
@@ -35,13 +36,18 @@ RequireSafeKeys false
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create a new DKIM key. This creates
|
# Create a new DKIM key. This creates mail.private and mail.txt
|
||||||
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
# in $STORAGE_ROOT/mail/dkim. The former is the private key and
|
||||||
# is the actual private key and the latter is the suggested DNS TXT
|
# the latter is the suggested DNS TXT entry which we'll include
|
||||||
# entry which we'll want to include in our DNS setup.
|
# in our DNS setup. Note that 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
|
if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then
|
||||||
# Should we specify -h rsa-sha256?
|
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim
|
||||||
opendkim-genkey -r -s mail -D $STORAGE_ROOT/mail/dkim
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure files are owned by the opendkim user and are private otherwise.
|
# Ensure files are owned by the opendkim user and are private otherwise.
|
||||||
|
|||||||
12
setup/dns.sh
12
setup/dns.sh
@@ -15,7 +15,7 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||||
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
||||||
|
echo "Installing nsd (DNS server)..."
|
||||||
apt_install nsd ldnsutils openssh-client
|
apt_install nsd ldnsutils openssh-client
|
||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
@@ -78,7 +78,7 @@ FIRST=1 #NODOC
|
|||||||
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
||||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
||||||
if [ $FIRST == 1 ]; then
|
if [ $FIRST == 1 ]; then
|
||||||
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
echo "Generating DNSSEC signing keys..."
|
||||||
FIRST=0 #NODOC
|
FIRST=0 #NODOC
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -88,13 +88,17 @@ if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
|||||||
#
|
#
|
||||||
# `ldns-keygen` outputs the new key's filename to stdout, which
|
# `ldns-keygen` outputs the new key's filename to stdout, which
|
||||||
# we're capturing into the `KSK` variable.
|
# we're capturing into the `KSK` variable.
|
||||||
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 2048 -k _domain_);
|
#
|
||||||
|
# ldns-keygen uses /dev/random for generating random numbers by default.
|
||||||
|
# This is slow and unecessary if we ensure /dev/urandom is seeded properly,
|
||||||
|
# so we use /dev/urandom. See system.sh for an explanation. See #596, #115.
|
||||||
|
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 2048 -k _domain_);
|
||||||
|
|
||||||
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
||||||
# rotated more often than a KSK, although we have no plans to
|
# rotated more often than a KSK, although we have no plans to
|
||||||
# rotate it (and doing so would be difficult to do without
|
# rotate it (and doing so would be difficult to do without
|
||||||
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
|
# disturbing DNS availability.) Omit `-k` and use a shorter key length.
|
||||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 1024 _domain_);
|
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 1024 _domain_);
|
||||||
|
|
||||||
# These generate two sets of files like:
|
# These generate two sets of files like:
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
||||||
|
|
||||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
# 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
|
fi
|
||||||
@@ -39,32 +39,12 @@ function apt_get_quiet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function apt_install {
|
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=$@
|
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
|
apt_get_quiet install $PACKAGES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,15 @@
|
|||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
# Install packages...
|
|
||||||
|
|
||||||
|
# Install packages for dovecot. These are all core dovecot plugins,
|
||||||
|
# but dovecot-lucene is packaged by *us* in the Mail-in-a-Box PPA,
|
||||||
|
# not by Ubuntu.
|
||||||
|
|
||||||
|
echo "Installing Dovecot (IMAP server)..."
|
||||||
apt_install \
|
apt_install \
|
||||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||||
dovecot-sieve dovecot-managesieved
|
dovecot-sieve dovecot-managesieved dovecot-lucene
|
||||||
|
|
||||||
# The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically
|
# The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically
|
||||||
# enable IMAP, POP and LMTP protocols.
|
# enable IMAP, POP and LMTP protocols.
|
||||||
@@ -52,6 +56,9 @@ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
|||||||
mail_privileged_group=mail \
|
mail_privileged_group=mail \
|
||||||
first_valid_uid=0
|
first_valid_uid=0
|
||||||
|
|
||||||
|
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
|
||||||
|
cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf
|
||||||
|
|
||||||
# ### IMAP/POP
|
# ### IMAP/POP
|
||||||
|
|
||||||
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
||||||
@@ -81,17 +88,29 @@ sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
|||||||
# this are minimal. But for good measure, let's go to 4 minutes to halve the
|
# this are minimal. But for good measure, let's go to 4 minutes to halve the
|
||||||
# bandwidth and number of times the device's networking might be woken up.
|
# bandwidth and number of times the device's networking might be woken up.
|
||||||
# The risk is that if the connection is silent for too long it might be reset
|
# The risk is that if the connection is silent for too long it might be reset
|
||||||
# by a peer. See #129 and http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/.
|
# by a peer. See [#129](https://github.com/mail-in-a-box/mailinabox/issues/129)
|
||||||
|
# and [How bad is IMAP IDLE](http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/).
|
||||||
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||||
imap_idle_notify_interval="4 mins"
|
imap_idle_notify_interval="4 mins"
|
||||||
|
|
||||||
# Set POP3 UIDL
|
# Set POP3 UIDL.
|
||||||
# UIDLs are used by POP3 clients to keep track of what messages they've downloaded.
|
# UIDLs are used by POP3 clients to keep track of what messages they've downloaded.
|
||||||
# For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY
|
# For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY
|
||||||
# and UID values, the default in Dovecot.
|
# and UID values, the default in Dovecot.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
|
tools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
|
||||||
pop3_uidl_format="%08Xu%08Xv"
|
pop3_uidl_format="%08Xu%08Xv"
|
||||||
|
|
||||||
|
# Full Text Search - Enable full text search of mail using dovecot's lucene plugin,
|
||||||
|
# which *we* package and distribute (dovecot-lucene package).
|
||||||
|
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||||
|
mail_plugins="\$mail_plugins fts fts_lucene"
|
||||||
|
cat > /etc/dovecot/conf.d/90-plugin-fts.conf << EOF;
|
||||||
|
plugin {
|
||||||
|
fts = lucene
|
||||||
|
fts_lucene = whitespace_chars=@.
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# ### LDA (LMTP)
|
# ### LDA (LMTP)
|
||||||
|
|
||||||
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
||||||
@@ -136,6 +155,12 @@ sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/
|
|||||||
#
|
#
|
||||||
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
# * `sieve_before`: The path to our global sieve which handles moving spam to the Spam folder.
|
||||||
#
|
#
|
||||||
|
# * `sieve_before2`: The path to our global sieve directory for sieve which can contain .sieve files
|
||||||
|
# to run globally for every user before their own sieve files run.
|
||||||
|
#
|
||||||
|
# * `sieve_after`: The path to our global sieve directory which can contain .sieve files
|
||||||
|
# to run globally for every user after their own sieve files run.
|
||||||
|
#
|
||||||
# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
|
# * `sieve`: The path to the user's main active script. ManageSieve will create a symbolic
|
||||||
# link here to the actual sieve script. It should not be in the mailbox directory
|
# link here to the actual sieve script. It should not be in the mailbox directory
|
||||||
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
||||||
@@ -145,6 +170,8 @@ sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/
|
|||||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||||
plugin {
|
plugin {
|
||||||
sieve_before = /etc/dovecot/sieve-spam.sieve
|
sieve_before = /etc/dovecot/sieve-spam.sieve
|
||||||
|
sieve_before2 = $STORAGE_ROOT/mail/sieve/global_before
|
||||||
|
sieve_after = $STORAGE_ROOT/mail/sieve/global_after
|
||||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||||
}
|
}
|
||||||
@@ -168,11 +195,16 @@ chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
|
|||||||
|
|
||||||
# Same for the sieve scripts.
|
# Same for the sieve scripts.
|
||||||
mkdir -p $STORAGE_ROOT/mail/sieve
|
mkdir -p $STORAGE_ROOT/mail/sieve
|
||||||
|
mkdir -p $STORAGE_ROOT/mail/sieve/global_before
|
||||||
|
mkdir -p $STORAGE_ROOT/mail/sieve/global_after
|
||||||
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
chown -R mail.mail $STORAGE_ROOT/mail/sieve
|
||||||
|
|
||||||
# Allow the IMAP/POP ports in the firewall.
|
# Allow the IMAP/POP ports in the firewall.
|
||||||
ufw_allow imaps
|
ufw_allow imaps
|
||||||
ufw_allow pop3s
|
ufw_allow pop3s
|
||||||
|
|
||||||
|
# Allow the Sieve port in the firewall.
|
||||||
|
ufw_allow sieve
|
||||||
|
|
||||||
# Restart services.
|
# Restart services.
|
||||||
restart_service dovecot
|
restart_service dovecot
|
||||||
|
|||||||
@@ -41,7 +41,15 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# always will.
|
# always will.
|
||||||
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
||||||
# untrusted opportunistically-encrypted connections.
|
# untrusted opportunistically-encrypted connections.
|
||||||
|
#
|
||||||
|
# postgrey is going to come in via the Mail-in-a-Box PPA, which publishes
|
||||||
|
# a modified version of postgrey that lets senders whitelisted by dnswl.org
|
||||||
|
# pass through without being greylisted. So please note [dnswl's license terms](https://www.dnswl.org/?page_id=9):
|
||||||
|
# > Every user with more than 100’000 queries per day on the public nameserver
|
||||||
|
# > 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
|
apt_install postfix postfix-pcre postgrey ca-certificates
|
||||||
|
|
||||||
# ### Basic Settings
|
# ### Basic Settings
|
||||||
@@ -49,15 +57,26 @@ apt_install postfix postfix-pcre postgrey ca-certificates
|
|||||||
# Set some basic settings...
|
# Set some basic settings...
|
||||||
#
|
#
|
||||||
# * Have postfix listen on all network interfaces.
|
# * Have postfix listen on all network interfaces.
|
||||||
|
# * Make outgoing connections on a particular interface (if multihomed) so that SPF passes on the receiving side.
|
||||||
# * Set our name (the Debian default seems to be "localhost" but make it our hostname).
|
# * Set our name (the Debian default seems to be "localhost" but make it our hostname).
|
||||||
# * Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.
|
# * Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.
|
||||||
# * Set the SMTP banner (which must have the hostname first, then anything).
|
# * Set the SMTP banner (which must have the hostname first, then anything).
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
inet_interfaces=all \
|
inet_interfaces=all \
|
||||||
|
smtp_bind_address=$PRIVATE_IP \
|
||||||
|
smtp_bind_address6=$PRIVATE_IPV6 \
|
||||||
myhostname=$PRIMARY_HOSTNAME\
|
myhostname=$PRIMARY_HOSTNAME\
|
||||||
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
|
smtpd_banner="\$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see https://mailinabox.email/)" \
|
||||||
mydestination=localhost
|
mydestination=localhost
|
||||||
|
|
||||||
|
# Tweak some queue settings:
|
||||||
|
# * Inform users when their e-mail delivery is delayed more than 3 hours (default is not to warn).
|
||||||
|
# * Stop trying to send an undeliverable e-mail after 2 days (instead of 5), and for bounce messages just try for 1 day.
|
||||||
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
|
delay_warning_time=3h \
|
||||||
|
maximal_queue_lifetime=2d \
|
||||||
|
bounce_queue_lifetime=1d
|
||||||
|
|
||||||
# ### Outgoing Mail
|
# ### Outgoing Mail
|
||||||
|
|
||||||
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
||||||
@@ -86,6 +105,12 @@ tools/editconf.py /etc/postfix/master.cf -s -w \
|
|||||||
# Install the `outgoing_mail_header_filters` file required by the new 'authclean' service.
|
# 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
|
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
|
# 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
|
# require TLS before a user is allowed to authenticate. This also makes
|
||||||
# opportunistic TLS available on *incoming* mail.
|
# opportunistic TLS available on *incoming* mail.
|
||||||
@@ -153,6 +178,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_non_fqdn_sender`: Reject not-nice-looking return paths.
|
||||||
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
|
# * `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.
|
# * `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_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
|
||||||
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
||||||
@@ -166,7 +192,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
|
# 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
|
# "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 \
|
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"
|
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
|
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
|
|||||||
if [ ! -f $db_path ]; then
|
if [ ! -f $db_path ]; then
|
||||||
echo Creating new user database: $db_path;
|
echo Creating new user database: $db_path;
|
||||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | 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
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
@@ -69,6 +69,27 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
smtpd_sasl_path=private/auth \
|
smtpd_sasl_path=private/auth \
|
||||||
smtpd_sasl_auth_enable=yes
|
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
|
# ### Destination Validation
|
||||||
|
|
||||||
# Use a Sqlite3 database to check whether a destination email address exists,
|
# 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 \
|
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
|
||||||
local_recipient_maps=\$virtual_mailbox_maps
|
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;
|
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
|
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
|
||||||
EOF
|
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;
|
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=$db_path
|
||||||
query = SELECT 1 FROM users WHERE email='%s'
|
query = SELECT 1 FROM users WHERE email='%s'
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# SQL statement to rewrite an email address if an alias is present.
|
# 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.
|
# Postfix makes multiple queries for each incoming mail. It first
|
||||||
# To fix this, not only query the aliases table but also the users
|
# queries the whole email address, then just the user part in certain
|
||||||
# table, i.e. turn users into aliases from themselves to themselves.
|
# 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
|
# 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
|
# 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;
|
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
||||||
dbpath=$db_path
|
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
|
EOF
|
||||||
|
|
||||||
# Restart Services
|
# Restart Services
|
||||||
|
|||||||
@@ -2,9 +2,27 @@
|
|||||||
|
|
||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
|
|
||||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
echo "Installing Mail-in-a-Box system management daemon..."
|
||||||
hide_output pip3 install rtyaml "email_validator==0.1.0-rc5"
|
|
||||||
# email_validator is repeated in setup/questions.sh
|
# Install packages.
|
||||||
|
# flask, yaml, dnspython, and dateutil are all for our Python 3 management daemon itself.
|
||||||
|
# duplicity does backups. python-pip is so we can 'pip install boto' for Python 2, for duplicity, so it can do backups to AWS S3.
|
||||||
|
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil python-pip
|
||||||
|
|
||||||
|
# These are required to pip install cryptography.
|
||||||
|
apt_install build-essential libssl-dev libffi-dev python3-dev
|
||||||
|
|
||||||
|
# Install other Python 3 packages used by the management daemon.
|
||||||
|
# The first line is the packages that Josh maintains himself!
|
||||||
|
# NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
|
||||||
|
hide_output pip3 install --upgrade \
|
||||||
|
rtyaml "email_validator>=1.0.0" "free_tls_certificates>=0.1.3" \
|
||||||
|
"idna>=2.0.0" "cryptography>=1.0.2" boto psutil
|
||||||
|
|
||||||
|
# duplicity uses python 2 so we need to get the python 2 package of boto to have backups to S3.
|
||||||
|
# boto from the Ubuntu package manager is too out-of-date -- it doesn't support the newer
|
||||||
|
# S3 api used in some regions, which breaks backups to those regions. See #627, #653.
|
||||||
|
hide_output pip install --upgrade boto
|
||||||
|
|
||||||
# Create a backup directory and a random key for encrypting backups.
|
# Create a backup directory and a random key for encrypting backups.
|
||||||
mkdir -p $STORAGE_ROOT/backup
|
mkdir -p $STORAGE_ROOT/backup
|
||||||
@@ -22,28 +40,18 @@ rm -f /etc/init.d/mailinabox
|
|||||||
ln -s $(pwd)/conf/management-initscript /etc/init.d/mailinabox
|
ln -s $(pwd)/conf/management-initscript /etc/init.d/mailinabox
|
||||||
hide_output update-rc.d mailinabox defaults
|
hide_output update-rc.d mailinabox defaults
|
||||||
|
|
||||||
# Perform a daily backup.
|
# Remove old files we no longer use.
|
||||||
cat > /etc/cron.daily/mailinabox-backup << EOF;
|
rm -f /etc/cron.daily/mailinabox-backup
|
||||||
#!/bin/bash
|
rm -f /etc/cron.daily/mailinabox-statuschecks
|
||||||
|
|
||||||
|
# Perform nightly tasks at 3am in system time: take a backup, run
|
||||||
|
# status checks and email the administrator any changes.
|
||||||
|
|
||||||
|
cat > /etc/cron.d/mailinabox-nightly << EOF;
|
||||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||||
# Perform a backup.
|
# Run nightly tasks: backup, status checks.
|
||||||
$(pwd)/management/backup.py
|
0 3 * * * root (cd `pwd` && management/daily_tasks.sh)
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.daily/mailinabox-backup
|
|
||||||
|
|
||||||
# Perform daily status checks. Compare each day to the previous
|
# Start the management server.
|
||||||
# for changes and mail the changes to the administrator.
|
|
||||||
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
|
|
||||||
#!/bin/bash
|
|
||||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
|
||||||
# Run status checks.
|
|
||||||
$(pwd)/management/status_checks.py --show-changes --smtp
|
|
||||||
EOF
|
|
||||||
chmod +x /etc/cron.daily/mailinabox-statuschecks
|
|
||||||
|
|
||||||
|
|
||||||
# Start it. Remove the api key file first so that start.sh
|
|
||||||
# can wait for it to be created to know that the management
|
|
||||||
# server is ready.
|
|
||||||
rm -f /var/lib/mailinabox/api.key
|
|
||||||
restart_service mailinabox
|
restart_service mailinabox
|
||||||
|
|||||||
@@ -95,6 +95,47 @@ def migration_7(env):
|
|||||||
# Save.
|
# Save.
|
||||||
conn.commit()
|
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 migration_10(env):
|
||||||
|
# Clean up the SSL certificates directory.
|
||||||
|
|
||||||
|
# Move the primary certificate to a new name and then
|
||||||
|
# symlink it to the system certificate path.
|
||||||
|
import datetime
|
||||||
|
system_certificate = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_certificate.pem')
|
||||||
|
if not os.path.islink(system_certificate): # not already a symlink
|
||||||
|
new_path = os.path.join(env["STORAGE_ROOT"], 'ssl', env['PRIMARY_HOSTNAME'] + "-" + datetime.datetime.now().date().isoformat().replace("-", "") + ".pem")
|
||||||
|
print("Renamed", system_certificate, "to", new_path, "and created a symlink for the original location.")
|
||||||
|
shutil.move(system_certificate, new_path)
|
||||||
|
os.symlink(new_path, system_certificate)
|
||||||
|
|
||||||
|
# Flatten the directory structure. For any directory
|
||||||
|
# that contains a single file named ssl_certificate.pem,
|
||||||
|
# move the file out and name it the same as the directory,
|
||||||
|
# and remove the directory.
|
||||||
|
for sslcert in glob.glob(os.path.join( env["STORAGE_ROOT"], 'ssl/*/ssl_certificate.pem' )):
|
||||||
|
d = os.path.dirname(sslcert)
|
||||||
|
if len(os.listdir(d)) == 1:
|
||||||
|
# This certificate is the only file in that directory.
|
||||||
|
newname = os.path.join(env["STORAGE_ROOT"], 'ssl', os.path.basename(d) + '.pem')
|
||||||
|
if not os.path.exists(newname):
|
||||||
|
shutil.move(sslcert, newname)
|
||||||
|
os.rmdir(d)
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
|
|||||||
61
setup/munin.sh
Executable file
61
setup/munin.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Munin: resource monitoring tool
|
||||||
|
#################################################
|
||||||
|
|
||||||
|
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
|
||||||
|
cat > /etc/munin/munin.conf <<EOF;
|
||||||
|
dbdir /var/lib/munin
|
||||||
|
htmldir /var/cache/munin/www
|
||||||
|
logdir /var/log/munin
|
||||||
|
rundir /var/run/munin
|
||||||
|
tmpldir /etc/munin/templates
|
||||||
|
|
||||||
|
includedir /etc/munin/munin-conf.d
|
||||||
|
|
||||||
|
# a simple host tree
|
||||||
|
[$PRIMARY_HOSTNAME]
|
||||||
|
address 127.0.0.1
|
||||||
|
|
||||||
|
# send alerts to the following address
|
||||||
|
contacts admin
|
||||||
|
contact.admin.command mail -s "Munin notification ${var:host}" administrator@$PRIMARY_HOSTNAME
|
||||||
|
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 -f
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# (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
|
||||||
@@ -7,57 +7,80 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
|
|
||||||
# ### Installing ownCloud
|
# ### Installing ownCloud
|
||||||
|
|
||||||
|
echo "Installing ownCloud (contacts/calendar)..."
|
||||||
|
|
||||||
apt_install \
|
apt_install \
|
||||||
dbconfig-common \
|
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-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*
|
apt-get purge -qq -y owncloud*
|
||||||
|
|
||||||
# Install ownCloud from source of this version:
|
# Install ownCloud from source of this version:
|
||||||
owncloud_ver=8.0.3
|
owncloud_ver=8.1.1
|
||||||
owncloud_hash=3192f3d783f81247eaf2914df63afdd593def4e5
|
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.
|
||||||
|
if [ ! -f $STORAGE_ROOT/owncloud/config.php ] \
|
||||||
|
&& [ -f /usr/local/lib/owncloud/config/config.php ]; then
|
||||||
|
|
||||||
|
# Move config.php and symlink back into previous location.
|
||||||
|
echo "Migrating owncloud/config.php to new location."
|
||||||
|
mv /usr/local/lib/owncloud/config/config.php $STORAGE_ROOT/owncloud/config.php \
|
||||||
|
&& \
|
||||||
|
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
||||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
if [ ! -d /usr/local/lib/owncloud/ ] \
|
||||||
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
|| ! 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.
|
# Clear out the existing ownCloud.
|
||||||
rm -f /tmp/owncloud-config.php
|
if [ -d /usr/local/lib/owncloud/ ]; then
|
||||||
if [ ! -d /usr/local/lib/owncloud/ ]; then
|
|
||||||
echo installing ownCloud...
|
|
||||||
else
|
|
||||||
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
|
echo "upgrading ownCloud to $owncloud_ver (backing up existing ownCloud directory to /tmp/owncloud-backup-$$)..."
|
||||||
cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
|
|
||||||
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download and extract ownCloud.
|
# Extract ownCloud
|
||||||
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
|
||||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
||||||
rm -f /tmp/owncloud.zip
|
rm -f /tmp/owncloud.zip
|
||||||
|
|
||||||
# The two apps we actually want are not in ownCloud core. Clone them from
|
# The two apps we actually want are not in ownCloud core. Clone them from
|
||||||
# their github repositories.
|
# their github repositories.
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
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/owncloudarchive/contacts 4ff855e7c2075309041bead09fbb9eb7df678244 '' /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/owncloudarchive/calendar ec53139b144c0f842c33813305612e8006c42ea5 '' /usr/local/lib/owncloud/apps/calendar
|
||||||
|
|
||||||
# Fix weird permissions.
|
# Fix weird permissions.
|
||||||
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
||||||
|
|
||||||
# Restore configuration file if we're doing an upgrade.
|
# Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously
|
||||||
if [ -f /tmp/owncloud-config.php ]; then
|
# put in, and in new installs we're creating a symlink and will create the actual config later).
|
||||||
mv /tmp/owncloud-config.php /usr/local/lib/owncloud/config/config.php
|
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
|
||||||
fi
|
|
||||||
|
|
||||||
# Make sure permissions are correct or the upgrade step won't run.
|
# Make sure permissions are correct or the upgrade step won't run.
|
||||||
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
||||||
# that error.
|
# that error.
|
||||||
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
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).
|
# If this isn't a new installation, immediately run the upgrade script.
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
# 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
|
fi
|
||||||
|
|
||||||
# ### Configuring ownCloud
|
# ### Configuring ownCloud
|
||||||
@@ -65,29 +88,30 @@ fi
|
|||||||
# Setup ownCloud if the ownCloud database does not yet exist. Running setup when
|
# Setup ownCloud if the ownCloud database does not yet exist. Running setup when
|
||||||
# the database does exist wipes the database and user data.
|
# the database does exist wipes the database and user data.
|
||||||
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
||||||
# Create a configuration file.
|
# Create user data directory
|
||||||
|
mkdir -p $STORAGE_ROOT/owncloud
|
||||||
|
|
||||||
|
# Create an initial configuration file.
|
||||||
TIMEZONE=$(cat /etc/timezone)
|
TIMEZONE=$(cat /etc/timezone)
|
||||||
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
||||||
cat > /usr/local/lib/owncloud/config/config.php <<EOF;
|
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
|
||||||
<?php
|
<?php
|
||||||
\$CONFIG = array (
|
\$CONFIG = array (
|
||||||
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
||||||
|
|
||||||
'instanceid' => '$instanceid',
|
'instanceid' => '$instanceid',
|
||||||
|
|
||||||
'trusted_domains' =>
|
|
||||||
array (
|
|
||||||
0 => '$PRIMARY_HOSTNAME',
|
|
||||||
),
|
|
||||||
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
||||||
|
|
||||||
'overwritewebroot' => '/cloud',
|
'overwritewebroot' => '/cloud',
|
||||||
|
'overwrite.cli.url' => '/cloud',
|
||||||
'user_backends' => array(
|
'user_backends' => array(
|
||||||
array(
|
array(
|
||||||
'class'=>'OC_User_IMAP',
|
'class'=>'OC_User_IMAP',
|
||||||
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
'memcache.local' => '\\OC\\Memcache\\Memcached',
|
||||||
"memcached_servers" => array (
|
"memcached_servers" => array (
|
||||||
array('localhost', 11211),
|
array('localhost', 11211),
|
||||||
),
|
),
|
||||||
@@ -109,7 +133,7 @@ EOF
|
|||||||
# Create an auto-configuration file to fill in database settings
|
# Create an auto-configuration file to fill in database settings
|
||||||
# when the install script is run. Make an administrator account
|
# when the install script is run. Make an administrator account
|
||||||
# here or else the install can't finish.
|
# here or else the install can't finish.
|
||||||
adminpassword=$(dd if=/dev/random bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
|
adminpassword=$(dd if=/dev/urandom bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
|
||||||
cat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
|
cat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
|
||||||
<?php
|
<?php
|
||||||
\$AUTOCONFIG = array (
|
\$AUTOCONFIG = array (
|
||||||
@@ -125,15 +149,39 @@ EOF
|
|||||||
?>
|
?>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create user data directory and set permissions
|
# Set permissions
|
||||||
mkdir -p $STORAGE_ROOT/owncloud
|
|
||||||
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
|
|
||||||
# Execute ownCloud's setup step, which creates the ownCloud sqlite database.
|
# Execute ownCloud's setup step, which creates the ownCloud sqlite database.
|
||||||
# It also wipes it if it exists. And it deletes the autoconfig.php file.
|
# It also wipes it if it exists. And it updates config.php with database
|
||||||
|
# settings and deletes the autoconfig.php file.
|
||||||
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Update config.php.
|
||||||
|
# * trusted_domains is reset to localhost by autoconfig starting with ownCloud 8.1.1,
|
||||||
|
# so set it here. It also can change if the box's PRIMARY_HOSTNAME changes, so
|
||||||
|
# this will make sure it has the right value.
|
||||||
|
# * Some settings 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['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
||||||
|
|
||||||
|
\$CONFIG['memcache.local'] = '\\OC\\Memcache\\Memcached';
|
||||||
|
\$CONFIG['overwrite.cli.url'] = '/cloud';
|
||||||
|
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address
|
||||||
|
|
||||||
|
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.
|
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
||||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||||
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
||||||
@@ -143,6 +191,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 contacts
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar
|
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
|
# Set PHP FPM values to support large file uploads
|
||||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ fi
|
|||||||
|
|
||||||
# Check that we have enough memory.
|
# Check that we have enough memory.
|
||||||
#
|
#
|
||||||
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 KB,
|
# /proc/meminfo reports free memory in kibibytes. Our baseline will be 768 MB,
|
||||||
# which is 750000 kibibytes.
|
# which is 750000 kibibytes.
|
||||||
#
|
#
|
||||||
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
# Skip the check if we appear to be running inside of Vagrant, because that's really just for testing.
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ if [ -z "$NONINTERACTIVE" ]; then
|
|||||||
# this was being able to ask the user for input even if stdin has been redirected,
|
# this was being able to ask the user for input even if stdin has been redirected,
|
||||||
# e.g. if we piped a bootstrapping install script to bash to get started. In that
|
# e.g. if we piped a bootstrapping install script to bash to get started. In that
|
||||||
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
|
# case, the nifty '[ -t 0 ]' test won't work. But with Vagrant we must suppress so we
|
||||||
# use a shell flag instead. Really supress any output from installing dialog.
|
# use a shell flag instead. Really suppress any output from installing dialog.
|
||||||
#
|
#
|
||||||
# Also install depencies needed to validate the email address.
|
# Also install dependencies needed to validate the email address.
|
||||||
|
if [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then
|
||||||
echo Installing packages needed for setup...
|
echo Installing packages needed for setup...
|
||||||
|
apt-get -q -q update
|
||||||
apt_get_quiet install dialog python3 python3-pip || exit 1
|
apt_get_quiet install dialog python3 python3-pip || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# email_validator is repeated in setup/management.sh
|
# 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>=1.0.0" || exit 1
|
||||||
|
|
||||||
message_box "Mail-in-a-Box Installation" \
|
message_box "Mail-in-a-Box Installation" \
|
||||||
"Hello and thanks for deploying a Mail-in-a-Box!
|
"Hello and thanks for deploying a Mail-in-a-Box!
|
||||||
\n\nI'm going to ask you a few questions.
|
\n\nI'm going to ask you a few questions.
|
||||||
\n\nTo change your answers later, just run 'sudo mailinabox' from the command line."
|
\n\nTo change your answers later, just run 'sudo mailinabox' from the command line.
|
||||||
|
\n\nNOTE: You should only install this on a brand new Ubuntu installation 100% dedicated to Mail-in-a-Box. Mail-in-a-Box will, for example, remove apache2."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The box needs a name.
|
# The box needs a name.
|
||||||
@@ -165,31 +169,45 @@ if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
|
|||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# We need a country code to generate a certificate signing request. However
|
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
||||||
# if a CSR already exists then we won't be generating a new one and there's
|
if [ "$PUBLIC_IP" = "auto" ]; then
|
||||||
# no reason to ask for the country code now. $STORAGE_ROOT has not yet been
|
# Use a public API to get our public IP address, or fall back to local network configuration.
|
||||||
# set so we'll check if $DEFAULT_STORAGE_ROOT and $DEFAULT_CSR_COUNTRY are
|
PUBLIC_IP=$(get_publicip_from_web_service 4 || get_default_privateip 4)
|
||||||
# set (the values from the current mailinabox.conf) and if the CSR exists
|
fi
|
||||||
# in the expected location.
|
if [ "$PUBLIC_IPV6" = "auto" ]; then
|
||||||
if [ ! -z "$DEFAULT_STORAGE_ROOT" ] && [ ! -z "$DEFAULT_CSR_COUNTRY" ] && [ -f $DEFAULT_STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
# Use a public API to get our public IPv6 address, or fall back to local network configuration.
|
||||||
CSR_COUNTRY=$DEFAULT_CSR_COUNTRY
|
PUBLIC_IPV6=$(get_publicip_from_web_service 6 || get_default_privateip 6)
|
||||||
|
fi
|
||||||
|
if [ "$PRIMARY_HOSTNAME" = "auto" ]; then
|
||||||
|
PRIMARY_HOSTNAME=$(get_default_hostname)
|
||||||
|
elif [ "$PRIMARY_HOSTNAME" = "auto-easy" ]; then
|
||||||
|
# Generate a probably-unique subdomain under our justtesting.email domain.
|
||||||
|
PRIMARY_HOSTNAME=`echo $PUBLIC_IP | sha1sum | cut -c1-5`.justtesting.email
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$CSR_COUNTRY" ]; then
|
# Set STORAGE_USER and STORAGE_ROOT to default values (user-data and /home/user-data), unless
|
||||||
# Get a list of country codes. Separate codes from country names with a ^.
|
# we've already got those values from a previous run.
|
||||||
# The input_menu function modifies shell word expansion to ignore spaces
|
if [ -z "$STORAGE_USER" ]; then
|
||||||
# (since country names can have spaces) and use ^ instead.
|
STORAGE_USER=$([[ -z "$DEFAULT_STORAGE_USER" ]] && echo "user-data" || echo "$DEFAULT_STORAGE_USER")
|
||||||
country_code_list=$(grep -v "^#" setup/csr_country_codes.tsv | sed "s/\(..\)\t\([^\t]*\).*/\1^\2/")
|
|
||||||
|
|
||||||
input_menu "Country Code" \
|
|
||||||
"Choose the country where you live or where your organization is based.
|
|
||||||
\n\n(This is used to create an SSL certificate.)
|
|
||||||
\n\nCountry Code:" \
|
|
||||||
"$country_code_list" \
|
|
||||||
CSR_COUNTRY
|
|
||||||
|
|
||||||
if [ -z "$CSR_COUNTRY" ]; then
|
|
||||||
# user hit ESC/cancel
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
if [ -z "$STORAGE_ROOT" ]; then
|
||||||
|
STORAGE_ROOT=$([[ -z "$DEFAULT_STORAGE_ROOT" ]] && echo "/home/$STORAGE_USER" || echo "$DEFAULT_STORAGE_ROOT")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show the configuration, since the user may have not entered it manually.
|
||||||
|
echo
|
||||||
|
echo "Primary Hostname: $PRIMARY_HOSTNAME"
|
||||||
|
echo "Public IP Address: $PUBLIC_IP"
|
||||||
|
if [ ! -z "$PUBLIC_IPV6" ]; then
|
||||||
|
echo "Public IPv6 Address: $PUBLIC_IPV6"
|
||||||
|
fi
|
||||||
|
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
|
||||||
|
echo "Private IP Address: $PRIVATE_IP"
|
||||||
|
fi
|
||||||
|
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
|
||||||
|
echo "Private IPv6 Address: $PRIVATE_IPV6"
|
||||||
|
fi
|
||||||
|
if [ -f /usr/bin/git ] && [ -d .git ]; then
|
||||||
|
echo "Mail-in-a-Box Version: " $(git describe)
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|||||||
@@ -16,23 +16,40 @@ source setup/functions.sh # load our functions
|
|||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
||||||
# Install packages.
|
# Install packages.
|
||||||
apt_install spampd razor pyzor dovecot-antispam
|
# libmail-dkim-perl is needed to make the spamassassin DKIM module work.
|
||||||
|
# For more information see Debian Bug #689414:
|
||||||
|
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=689414
|
||||||
|
echo "Installing SpamAssassin..."
|
||||||
|
apt_install spampd razor pyzor dovecot-antispam libmail-dkim-perl
|
||||||
|
|
||||||
# Allow spamassassin to download new rules.
|
# Allow spamassassin to download new rules.
|
||||||
tools/editconf.py /etc/default/spamassassin \
|
tools/editconf.py /etc/default/spamassassin \
|
||||||
CRON=1
|
CRON=1
|
||||||
|
|
||||||
# Configure pyzor.
|
# Configure pyzor, which is a client to a live database of hashes of
|
||||||
hide_output pyzor discover
|
# 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:
|
# Configure spampd:
|
||||||
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
|
# * Pass messages on to docevot on port 10026. This is actually the default setting but we don't
|
||||||
# want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
|
# want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.)
|
||||||
# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
|
# * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which
|
||||||
# is Spamassassin (spamc)'s own default. Specified in KBytes.
|
# is Spamassassin (spamc)'s own default. Specified in KBytes.
|
||||||
|
# * Disable localmode so Pyzor, DKIM and DNS checks can be used.
|
||||||
tools/editconf.py /etc/default/spampd \
|
tools/editconf.py /etc/default/spampd \
|
||||||
DESTPORT=10026 \
|
DESTPORT=10026 \
|
||||||
ADDOPTS="\"--maxsize=500\""
|
ADDOPTS="\"--maxsize=500\"" \
|
||||||
|
LOCALONLY=0
|
||||||
|
|
||||||
# Spamassassin normally wraps spam as an attachment inside a fresh
|
# Spamassassin normally wraps spam as an attachment inside a fresh
|
||||||
# email with a report about the message. This also protects the user
|
# email with a report about the message. This also protects the user
|
||||||
@@ -82,6 +99,7 @@ cat > /etc/dovecot/conf.d/99-local-spampd.conf << EOF;
|
|||||||
plugin {
|
plugin {
|
||||||
antispam_backend = pipe
|
antispam_backend = pipe
|
||||||
antispam_spam_pattern_ignorecase = SPAM
|
antispam_spam_pattern_ignorecase = SPAM
|
||||||
|
antispam_trash_pattern_ignorecase = trash;Deleted *
|
||||||
antispam_allow_append_to_spam = yes
|
antispam_allow_append_to_spam = yes
|
||||||
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
|
antispam_pipe_program_spam_args = /usr/local/bin/sa-learn-pipe.sh;--spam
|
||||||
antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
|
antispam_pipe_program_notspam_args = /usr/local/bin/sa-learn-pipe.sh;--ham
|
||||||
|
|||||||
94
setup/ssl.sh
94
setup/ssl.sh
@@ -1,52 +1,98 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# SSL Certificate
|
# RSA private key, SSL certificate, Diffie-Hellman bits files
|
||||||
# ---------------
|
# -------------------------------------------
|
||||||
|
|
||||||
# Create a self-signed SSL certificate if one has not yet been created.
|
# Create an RSA private key, a self-signed SSL certificate, and some
|
||||||
|
# Diffie-Hellman cipher bits, if they have not yet been created.
|
||||||
#
|
#
|
||||||
# The certificate is for PRIMARY_HOSTNAME specifically and is used for:
|
# The RSA private key and certificate are used for:
|
||||||
#
|
#
|
||||||
|
# * DNSSEC DANE TLSA records
|
||||||
# * IMAP
|
# * IMAP
|
||||||
# * SMTP submission (port 587) and opportunistic TLS (when on the receiving end)
|
# * SMTP (opportunistic TLS for port 25 and submission on port 587)
|
||||||
# * the DNSSEC DANE TLSA record for SMTP
|
# * HTTPS
|
||||||
# * HTTPS (for PRIMARY_HOSTNAME only)
|
|
||||||
#
|
#
|
||||||
# When other domains besides PRIMARY_HOSTNAME are served over HTTPS,
|
# The certificate is created with its CN set to the PRIMARY_HOSTNAME. It is
|
||||||
# we generate a domain-specific self-signed certificate in the management
|
# also used for other domains served over HTTPS until the user installs a
|
||||||
# daemon (web_update.py) as needed.
|
# better certificate for those domains.
|
||||||
|
#
|
||||||
|
# The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a
|
||||||
|
# Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
|
||||||
|
# provides Perfect Forward Secrecy.
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
|
# Show a status line if we are going to take any action in this file.
|
||||||
|
if [ ! -f /usr/bin/openssl ] \
|
||||||
|
|| [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ] \
|
||||||
|
|| [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ] \
|
||||||
|
|| [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
||||||
|
echo "Creating initial SSL certificate and perfect forward secrecy Diffie-Hellman parameters..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install openssl.
|
||||||
|
|
||||||
apt_install openssl
|
apt_install openssl
|
||||||
|
|
||||||
|
# Create a directory to store TLS-related things like "SSL" certificates.
|
||||||
|
|
||||||
mkdir -p $STORAGE_ROOT/ssl
|
mkdir -p $STORAGE_ROOT/ssl
|
||||||
|
|
||||||
# Generate a new private key.
|
# Generate a new private key.
|
||||||
# Set the umask so the key file is not world-readable.
|
#
|
||||||
|
# The key is only as good as the entropy available to openssl so that it
|
||||||
|
# can generate a random key. "OpenSSL’s built-in RSA key generator ....
|
||||||
|
# is seeded on first use with (on Linux) 32 bytes read from /dev/urandom,
|
||||||
|
# the process ID, user ID, and the current time in seconds. [During key
|
||||||
|
# generation OpenSSL] mixes into the entropy pool the current time in seconds,
|
||||||
|
# the process ID, and the possibly uninitialized contents of a ... buffer
|
||||||
|
# ... dozens to hundreds of times."
|
||||||
|
#
|
||||||
|
# A perfect storm of issues can cause the generated key to be not very random:
|
||||||
|
#
|
||||||
|
# * improperly seeded /dev/urandom, but see system.sh for how we mitigate this
|
||||||
|
# * the user ID of this process is always the same (we're root), so that seed is useless
|
||||||
|
# * zero'd memory (plausible on embedded systems, cloud VMs?)
|
||||||
|
# * a predictable process ID (likely on an embedded/virtualized system)
|
||||||
|
# * a system clock reset to a fixed time on boot
|
||||||
|
#
|
||||||
|
# Since we properly seed /dev/urandom in system.sh we should be fine, but I leave
|
||||||
|
# in the rest of the notes in case that ever changes.
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
|
||||||
|
# Set the umask so the key file is never world-readable.
|
||||||
(umask 077; hide_output \
|
(umask 077; hide_output \
|
||||||
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate a certificate signing request.
|
# Generate a self-signed SSL certificate because things like nginx, dovecot,
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr ]; then
|
# etc. won't even start without some certificate in place, and we need nginx
|
||||||
hide_output \
|
# so we can offer the user a control panel to install a better certificate.
|
||||||
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr \
|
|
||||||
-sha256 -subj "/C=$CSR_COUNTRY/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate a SSL certificate by self-signing.
|
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/ssl_certificate.pem ]; then
|
||||||
|
# Generate a certificate signing request.
|
||||||
|
CSR=/tmp/ssl_cert_sign_req-$$.csr
|
||||||
|
hide_output \
|
||||||
|
openssl req -new -key $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CSR \
|
||||||
|
-sha256 -subj "/C=/ST=/L=/O=/CN=$PRIMARY_HOSTNAME"
|
||||||
|
|
||||||
|
# Generate the self-signed certificate.
|
||||||
|
CERT=$STORAGE_ROOT/ssl/$PRIMARY_HOSTNAME-selfsigned-$(date --rfc-3339=date | sed s/-//g).pem
|
||||||
hide_output \
|
hide_output \
|
||||||
openssl x509 -req -days 365 \
|
openssl x509 -req -days 365 \
|
||||||
-in $STORAGE_ROOT/ssl/ssl_cert_sign_req.csr -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $STORAGE_ROOT/ssl/ssl_certificate.pem
|
-in $CSR -signkey $STORAGE_ROOT/ssl/ssl_private_key.pem -out $CERT
|
||||||
|
|
||||||
|
# Delete the certificate signing request because it has no other purpose.
|
||||||
|
rm -f $CSR
|
||||||
|
|
||||||
|
# Symlink the certificate into the system certificate path, so system services
|
||||||
|
# can find it.
|
||||||
|
ln -s $CERT $STORAGE_ROOT/ssl/ssl_certificate.pem
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For nginx and postfix, pre-generate some Diffie-Hellman cipher bits which is
|
# Generate some Diffie-Hellman cipher bits.
|
||||||
# used when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman
|
# openssl's default bit length for this is 1024 bits, but we'll create
|
||||||
# provides Perfect Forward Secrecy. openssl's default is 1024 bits, but we'll
|
# 2048 bits of bits per the latest recommendations.
|
||||||
# create 2048.
|
|
||||||
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
if [ ! -f $STORAGE_ROOT/ssl/dh2048.pem ]; then
|
||||||
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
|
openssl dhparam -out $STORAGE_ROOT/ssl/dh2048.pem 2048
|
||||||
fi
|
fi
|
||||||
|
|||||||
101
setup/start.sh
101
setup/start.sh
@@ -10,8 +10,8 @@ source setup/preflight.sh
|
|||||||
|
|
||||||
# Ensure Python reads/writes files in UTF-8. If the machine
|
# Ensure Python reads/writes files in UTF-8. If the machine
|
||||||
# triggers some other locale in Python, like ASCII encoding,
|
# triggers some other locale in Python, like ASCII encoding,
|
||||||
# Python may not be able to read/write files. Here and in
|
# Python may not be able to read/write files. This is also
|
||||||
# the management daemon startup script.
|
# in the management daemon startup script and the cron script.
|
||||||
|
|
||||||
if [ -z `locale -a | grep en_US.utf8` ]; then
|
if [ -z `locale -a | grep en_US.utf8` ]; then
|
||||||
# Generate locale if not exists
|
# Generate locale if not exists
|
||||||
@@ -23,6 +23,9 @@ export LC_ALL=en_US.UTF-8
|
|||||||
export LANG=en_US.UTF-8
|
export LANG=en_US.UTF-8
|
||||||
export LC_TYPE=en_US.UTF-8
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
|
# Fix so line drawing characters are shown correctly in Putty on Windows. See #744.
|
||||||
|
export NCURSES_NO_UTF8_ACS=1
|
||||||
|
|
||||||
# Recall the last settings used if we're running this a second time.
|
# Recall the last settings used if we're running this a second time.
|
||||||
if [ -f /etc/mailinabox.conf ]; then
|
if [ -f /etc/mailinabox.conf ]; then
|
||||||
# Run any system migrations before proceeding. Since this is a second run,
|
# Run any system migrations before proceeding. Since this is a second run,
|
||||||
@@ -34,6 +37,8 @@ if [ -f /etc/mailinabox.conf ]; then
|
|||||||
cat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf
|
cat /etc/mailinabox.conf | sed s/^/DEFAULT_/ > /tmp/mailinabox.prev.conf
|
||||||
source /tmp/mailinabox.prev.conf
|
source /tmp/mailinabox.prev.conf
|
||||||
rm -f /tmp/mailinabox.prev.conf
|
rm -f /tmp/mailinabox.prev.conf
|
||||||
|
else
|
||||||
|
FIRST_TIME_SETUP=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Put a start script in a global location. We tell the user to run 'mailinabox'
|
# Put a start script in a global location. We tell the user to run 'mailinabox'
|
||||||
@@ -45,73 +50,32 @@ source setup/start.sh
|
|||||||
EOF
|
EOF
|
||||||
chmod +x /usr/local/bin/mailinabox
|
chmod +x /usr/local/bin/mailinabox
|
||||||
|
|
||||||
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, PUBLIC_IPV6, and CSR_COUNTRY
|
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,
|
||||||
# if values have not already been set in environment variables. When running
|
# if values have not already been set in environment variables. When running
|
||||||
# non-interactively, be sure to set values for all!
|
# non-interactively, be sure to set values for all! Also sets STORAGE_USER and
|
||||||
|
# STORAGE_ROOT.
|
||||||
source setup/questions.sh
|
source setup/questions.sh
|
||||||
|
|
||||||
# Automatic configuration, e.g. as used in our Vagrant configuration.
|
|
||||||
if [ "$PUBLIC_IP" = "auto" ]; then
|
|
||||||
# Use a public API to get our public IP address, or fall back to local network configuration.
|
|
||||||
PUBLIC_IP=$(get_publicip_from_web_service 4 || get_default_privateip 4)
|
|
||||||
fi
|
|
||||||
if [ "$PUBLIC_IPV6" = "auto" ]; then
|
|
||||||
# Use a public API to get our public IPv6 address, or fall back to local network configuration.
|
|
||||||
PUBLIC_IPV6=$(get_publicip_from_web_service 6 || get_default_privateip 6)
|
|
||||||
fi
|
|
||||||
if [ "$PRIMARY_HOSTNAME" = "auto-easy" ]; then
|
|
||||||
# Generate a probably-unique subdomain under our justtesting.email domain.
|
|
||||||
PRIMARY_HOSTNAME=`echo $PUBLIC_IP | sha1sum | cut -c1-5`.justtesting.email
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show the configuration, since the user may have not entered it manually.
|
|
||||||
echo
|
|
||||||
echo "Primary Hostname: $PRIMARY_HOSTNAME"
|
|
||||||
echo "Public IP Address: $PUBLIC_IP"
|
|
||||||
if [ ! -z "$PUBLIC_IPV6" ]; then
|
|
||||||
echo "Public IPv6 Address: $PUBLIC_IPV6"
|
|
||||||
fi
|
|
||||||
if [ "$PRIVATE_IP" != "$PUBLIC_IP" ]; then
|
|
||||||
echo "Private IP Address: $PRIVATE_IP"
|
|
||||||
fi
|
|
||||||
if [ "$PRIVATE_IPV6" != "$PUBLIC_IPV6" ]; then
|
|
||||||
echo "Private IPv6 Address: $PRIVATE_IPV6"
|
|
||||||
fi
|
|
||||||
if [ -f .git ]; then
|
|
||||||
echo "Mail-in-a-Box Version: " $(git describe)
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Run some network checks to make sure setup on this machine makes sense.
|
# 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
|
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
||||||
. setup/network-checks.sh
|
source setup/network-checks.sh
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For the first time (if the config file (/etc/mailinabox.conf) not exists):
|
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
|
||||||
# Create the user named "user-data" and store all persistent user
|
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
||||||
# data (mailboxes, etc.) in that user's home directory.
|
# migration (schema) number for the files stored there, assume this is a fresh
|
||||||
#
|
# installation to that directory and write the file to contain the current
|
||||||
# If the config file exists:
|
# migration number for this version of Mail-in-a-Box.
|
||||||
# Apply the existing configuration options for STORAGE_USER/ROOT
|
|
||||||
if [ -z "$STORAGE_USER" ]; then
|
|
||||||
STORAGE_USER=$([[ -z "$DEFAULT_STORAGE_USER" ]] && echo "user-data" || echo "$DEFAULT_STORAGE_USER")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$STORAGE_ROOT" ]; then
|
|
||||||
STORAGE_ROOT=$([[ -z "$DEFAULT_STORAGE_ROOT" ]] && echo "/home/$STORAGE_USER" || echo "$DEFAULT_STORAGE_ROOT")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the STORAGE_USER if it not exists
|
|
||||||
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
|
if ! id -u $STORAGE_USER >/dev/null 2>&1; then
|
||||||
useradd -m $STORAGE_USER
|
useradd -m $STORAGE_USER
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the STORAGE_ROOT if it not exists
|
|
||||||
if [ ! -d $STORAGE_ROOT ]; then
|
if [ ! -d $STORAGE_ROOT ]; then
|
||||||
mkdir -p $STORAGE_ROOT
|
mkdir -p $STORAGE_ROOT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create mailinabox.version file if not exists
|
|
||||||
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
@@ -128,7 +92,6 @@ PUBLIC_IP=$PUBLIC_IP
|
|||||||
PUBLIC_IPV6=$PUBLIC_IPV6
|
PUBLIC_IPV6=$PUBLIC_IPV6
|
||||||
PRIVATE_IP=$PRIVATE_IP
|
PRIVATE_IP=$PRIVATE_IP
|
||||||
PRIVATE_IPV6=$PRIVATE_IPV6
|
PRIVATE_IPV6=$PRIVATE_IPV6
|
||||||
CSR_COUNTRY=$CSR_COUNTRY
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start service configuration.
|
# Start service configuration.
|
||||||
@@ -145,15 +108,21 @@ source setup/webmail.sh
|
|||||||
source setup/owncloud.sh
|
source setup/owncloud.sh
|
||||||
source setup/zpush.sh
|
source setup/zpush.sh
|
||||||
source setup/management.sh
|
source setup/management.sh
|
||||||
|
source setup/munin.sh
|
||||||
|
|
||||||
# Ping the management daemon to write the DNS and nginx configuration files.
|
# Ping the management daemon to write the DNS and nginx configuration files.
|
||||||
while [ ! -f /var/lib/mailinabox/api.key ]; do
|
until nc -z -w 4 localhost 10222
|
||||||
|
do
|
||||||
echo Waiting for the Mail-in-a-Box management daemon to start...
|
echo Waiting for the Mail-in-a-Box management daemon to start...
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
tools/dns_update
|
tools/dns_update
|
||||||
tools/web_update
|
tools/web_update
|
||||||
|
|
||||||
|
# If DNS is already working, try to provision TLS certficates from Let's Encrypt.
|
||||||
|
# Suppress extra reasons why domains aren't getting a new certificate.
|
||||||
|
management/ssl_certificates.py -q
|
||||||
|
|
||||||
# If there aren't any mail users yet, create one.
|
# If there aren't any mail users yet, create one.
|
||||||
source setup/firstuser.sh
|
source setup/firstuser.sh
|
||||||
|
|
||||||
@@ -169,17 +138,19 @@ if management/status_checks.py --check-primary-hostname; then
|
|||||||
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
# Show the nice URL if it appears to be resolving and has a valid certificate.
|
||||||
echo https://$PRIMARY_HOSTNAME/admin
|
echo https://$PRIMARY_HOSTNAME/admin
|
||||||
echo
|
echo
|
||||||
echo If you have a DNS problem use the box\'s IP address and check the SSL fingerprint:
|
echo "If you have a DNS problem put the box's IP address in the URL"
|
||||||
echo https://$PUBLIC_IP/admin
|
echo "(https://$PUBLIC_IP/admin) but then check the SSL fingerprint:"
|
||||||
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||||
|
| sed "s/SHA1 Fingerprint=//"
|
||||||
else
|
else
|
||||||
echo https://$PUBLIC_IP/admin
|
echo https://$PUBLIC_IP/admin
|
||||||
echo
|
echo
|
||||||
echo You will be alerted that the website has an invalid certificate. Check that
|
echo You will be alerted that the website has an invalid certificate. Check that
|
||||||
echo the certificate fingerprint matches:
|
echo the certificate fingerprint matches:
|
||||||
echo
|
echo
|
||||||
fi
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
|
||||||
| sed "s/SHA1 Fingerprint=//"
|
| sed "s/SHA1 Fingerprint=//"
|
||||||
echo
|
echo
|
||||||
echo Then you can confirm the security exception and continue.
|
echo Then you can confirm the security exception and continue.
|
||||||
echo
|
echo
|
||||||
|
fi
|
||||||
|
|||||||
129
setup/system.sh
129
setup/system.sh
@@ -1,9 +1,30 @@
|
|||||||
|
source /etc/mailinabox.conf
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
|
||||||
# Basic System Configuration
|
# Basic System Configuration
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
# ### Install Packages
|
# ### Add Mail-in-a-Box's PPA.
|
||||||
|
|
||||||
|
# We've built several .deb packages on our own that we want to include.
|
||||||
|
# One is a replacement for Ubuntu's stock postgrey package that makes
|
||||||
|
# some enhancements. The other is dovecot-lucene, a Lucene-based full
|
||||||
|
# text search plugin for (and by) dovecot, which is not available in
|
||||||
|
# Ubuntu currently.
|
||||||
|
#
|
||||||
|
# So, first ensure add-apt-repository is installed, then use it to install
|
||||||
|
# the [mail-in-a-box ppa](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa).
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# ### Update Packages
|
||||||
|
|
||||||
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
||||||
|
|
||||||
@@ -11,6 +32,8 @@ echo Updating system packages...
|
|||||||
hide_output apt-get update
|
hide_output apt-get update
|
||||||
apt_get_quiet upgrade
|
apt_get_quiet upgrade
|
||||||
|
|
||||||
|
# ### Install System Packages
|
||||||
|
|
||||||
# Install basic utilities.
|
# Install basic utilities.
|
||||||
#
|
#
|
||||||
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
|
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
|
||||||
@@ -20,15 +43,109 @@ apt_get_quiet upgrade
|
|||||||
# * cron: Runs background processes periodically.
|
# * cron: Runs background processes periodically.
|
||||||
# * ntp: keeps the system time correct
|
# * ntp: keeps the system time correct
|
||||||
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
# * fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
|
||||||
|
# * netcat-openbsd: `nc` command line networking tool
|
||||||
# * git: we install some things directly from github
|
# * git: we install some things directly from github
|
||||||
# * sudo: allows privileged users to execute commands as root without being root
|
# * 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
|
# * bc: allows us to do math to compute sane defaults
|
||||||
|
|
||||||
|
echo Installing system packages...
|
||||||
apt_install python3 python3-dev python3-pip \
|
apt_install python3 python3-dev python3-pip \
|
||||||
wget curl git sudo coreutils bc \
|
netcat-openbsd wget curl git sudo coreutils bc \
|
||||||
haveged unattended-upgrades cron ntp fail2ban
|
haveged pollinate \
|
||||||
|
unattended-upgrades cron ntp fail2ban
|
||||||
|
|
||||||
|
# ### Set the system timezone
|
||||||
|
#
|
||||||
|
# Some systems are missing /etc/timezone, which we cat into the configs for
|
||||||
|
# Z-Push and ownCloud, so we need to set it to something. Daily cron tasks
|
||||||
|
# like the system backup are run at a time tied to the system timezone, so
|
||||||
|
# letting the user choose will help us identify the right time to do those
|
||||||
|
# things (i.e. late at night in whatever timezone the user actually lives
|
||||||
|
# in).
|
||||||
|
#
|
||||||
|
# However, changing the timezone once it is set seems to confuse fail2ban
|
||||||
|
# and requires restarting fail2ban (done below in the fail2ban
|
||||||
|
# section) and syslog (see #328). There might be other issues, and it's
|
||||||
|
# not likely the user will want to change this, so we only ask on first
|
||||||
|
# setup.
|
||||||
|
if [ -z "$NONINTERACTIVE" ]; then
|
||||||
|
if [ ! -f /etc/timezone ] || [ ! -z $FIRST_TIME_SETUP ]; then
|
||||||
|
# If the file is missing or this is the user's first time running
|
||||||
|
# Mail-in-a-Box setup, run the interactive timezone configuration
|
||||||
|
# tool.
|
||||||
|
dpkg-reconfigure tzdata
|
||||||
|
restart_service rsyslog
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# This is a non-interactive setup so we can't ask the user.
|
||||||
|
# If /etc/timezone is missing, set it to UTC.
|
||||||
|
if [ ! -f /etc/timezone ]; then
|
||||||
|
echo "Setting timezone to UTC."
|
||||||
|
echo "Etc/UTC" > /etc/timezone
|
||||||
|
restart_service rsyslog
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ### Seed /dev/urandom
|
||||||
|
#
|
||||||
|
# /dev/urandom is used by various components for generating random bytes for
|
||||||
|
# encryption keys and passwords:
|
||||||
|
#
|
||||||
|
# * TLS private key (see `ssl.sh`, which calls `openssl genrsa`)
|
||||||
|
# * DNSSEC signing keys (see `dns.sh`)
|
||||||
|
# * our management server's API key (via Python's os.urandom method)
|
||||||
|
# * Roundcube's SECRET_KEY (`webmail.sh`)
|
||||||
|
# * ownCloud's administrator account password (`owncloud.sh`)
|
||||||
|
#
|
||||||
|
# Why /dev/urandom? It's the same as /dev/random, except that it doesn't wait
|
||||||
|
# for a constant new stream of entropy. In practice, we only need a little
|
||||||
|
# entropy at the start to get going. After that, we can safely pull a random
|
||||||
|
# stream from /dev/urandom and not worry about how much entropy has been
|
||||||
|
# added to the stream. (http://www.2uo.de/myths-about-urandom/) So we need
|
||||||
|
# to worry about /dev/urandom being seeded properly (which is also an issue
|
||||||
|
# for /dev/random), but after that /dev/urandom is superior to /dev/random
|
||||||
|
# because it's faster and doesn't block indefinitely to wait for hardware
|
||||||
|
# entropy. Note that `openssl genrsa` even uses `/dev/urandom`, and if it's
|
||||||
|
# good enough for generating an RSA private key, it's good enough for anything
|
||||||
|
# else we may need.
|
||||||
|
#
|
||||||
|
# Now about that seeding issue....
|
||||||
|
#
|
||||||
|
# /dev/urandom is seeded from "the uninitialized contents of the pool buffers when
|
||||||
|
# the kernel starts, the startup clock time in nanosecond resolution,...and
|
||||||
|
# entropy saved across boots to a local file" as well as the order of
|
||||||
|
# execution of concurrent accesses to /dev/urandom. (Heninger et al 2012,
|
||||||
|
# https://factorable.net/weakkeys12.conference.pdf) But when memory is zeroed,
|
||||||
|
# the system clock is reset on boot, /etc/init.d/urandom has not yet run, or
|
||||||
|
# the machine is single CPU or has no concurrent accesses to /dev/urandom prior
|
||||||
|
# to this point, /dev/urandom may not be seeded well. After this, /dev/urandom
|
||||||
|
# draws from the same entropy sources as /dev/random, but it doesn't block or
|
||||||
|
# issue any warnings if no entropy is actually available. (http://www.2uo.de/myths-about-urandom/)
|
||||||
|
# Entropy might not be readily available because this machine has no user input
|
||||||
|
# devices (common on servers!) and either no hard disk or not enough IO has
|
||||||
|
# ocurred yet --- although haveged tries to mitigate this. So there's a good chance
|
||||||
|
# that accessing /dev/urandom will not be drawing from any hardware entropy and under
|
||||||
|
# a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom
|
||||||
|
# may not be seeded at all.
|
||||||
|
#
|
||||||
|
# The first thing we'll do is block until we can seed /dev/urandom with enough
|
||||||
|
# hardware entropy to get going, by drawing from /dev/random. haveged makes this
|
||||||
|
# less likely to stall for very long.
|
||||||
|
|
||||||
|
echo Initializing system random number generator...
|
||||||
|
dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null
|
||||||
|
|
||||||
|
# This is supposedly sufficient. But because we're not sure if hardware entropy
|
||||||
|
# is really any good on virtualized systems, we'll also seed from Ubuntu's
|
||||||
|
# pollinate servers:
|
||||||
|
|
||||||
|
pollinate -q -r
|
||||||
|
|
||||||
|
# Between these two, we really ought to be all set.
|
||||||
|
|
||||||
|
# ### Package maintenance
|
||||||
|
#
|
||||||
# Allow apt to install system updates automatically every day.
|
# Allow apt to install system updates automatically every day.
|
||||||
|
|
||||||
cat > /etc/apt/apt.conf.d/02periodic <<EOF;
|
cat > /etc/apt/apt.conf.d/02periodic <<EOF;
|
||||||
@@ -111,7 +228,9 @@ restart_service resolvconf
|
|||||||
# ### Fail2Ban Service
|
# ### Fail2Ban Service
|
||||||
|
|
||||||
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh
|
# Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix and ssh
|
||||||
cp conf/fail2ban/jail.local /etc/fail2ban/jail.local
|
cat conf/fail2ban/jail.local \
|
||||||
|
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
|
||||||
|
> /etc/fail2ban/jail.local
|
||||||
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
|
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
|
||||||
|
|
||||||
restart_service fail2ban
|
restart_service fail2ban
|
||||||
|
|||||||
21
setup/web.sh
21
setup/web.sh
@@ -17,6 +17,7 @@ fi
|
|||||||
#
|
#
|
||||||
# Turn off nginx's default website.
|
# Turn off nginx's default website.
|
||||||
|
|
||||||
|
echo "Installing Nginx (web server)..."
|
||||||
apt_install nginx php5-fpm
|
apt_install nginx php5-fpm
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
@@ -24,13 +25,27 @@ rm -f /etc/nginx/sites-enabled/default
|
|||||||
# Copy in a nginx configuration file for common and best-practices
|
# Copy in a nginx configuration file for common and best-practices
|
||||||
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
# SSL settings from @konklone. Replace STORAGE_ROOT so it can find
|
||||||
# the DH params.
|
# the DH params.
|
||||||
|
rm -f /etc/nginx/nginx-ssl.conf # we used to put it here
|
||||||
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||||
conf/nginx-ssl.conf > /etc/nginx/nginx-ssl.conf
|
conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf
|
||||||
|
|
||||||
# Fix some nginx defaults.
|
# Fix some nginx defaults.
|
||||||
# The server_names_hash_bucket_size seems to prevent long domain names?
|
# The server_names_hash_bucket_size seems to prevent long domain names!
|
||||||
|
# The default, according to nginx's docs, depends on "the size of the
|
||||||
|
# processor’s cache line." It could be as low as 32. We fixed it at
|
||||||
|
# 64 in 2014 to accommodate a long domain name (20 characters?). But
|
||||||
|
# even at 64, a 58-character domain name won't work (#93), so now
|
||||||
|
# we're going up to 128.
|
||||||
tools/editconf.py /etc/nginx/nginx.conf -s \
|
tools/editconf.py /etc/nginx/nginx.conf -s \
|
||||||
server_names_hash_bucket_size="64;"
|
server_names_hash_bucket_size="128;"
|
||||||
|
|
||||||
|
# Tell PHP not to expose its version number in the X-Powered-By header.
|
||||||
|
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
|
# Bump up PHP's max_children to support more concurrent connections
|
||||||
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
# and then we'll manually install roundcube from source.
|
# and then we'll manually install roundcube from source.
|
||||||
|
|
||||||
# These dependencies are from `apt-cache showpkg roundcube-core`.
|
# These dependencies are from `apt-cache showpkg roundcube-core`.
|
||||||
|
echo "Installing Roundcube (webmail)..."
|
||||||
apt_install \
|
apt_install \
|
||||||
dbconfig-common \
|
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-crypt-gpg php5-gd php5-pspell \
|
||||||
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
||||||
|
apt_get_quiet remove php-mail-mimedecode # no longer needed since Roundcube 1.1.3
|
||||||
|
|
||||||
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
# We used to install Roundcube from Ubuntu, without triggering the dependencies #NODOC
|
||||||
# on Apache and MySQL, by downloading the debs and installing them manually. #NODOC
|
# on Apache and MySQL, by downloading the debs and installing them manually. #NODOC
|
||||||
@@ -32,11 +34,12 @@ apt-get purge -qq -y roundcube* #NODOC
|
|||||||
# Install Roundcube from source if it is not already present or if it is out of date.
|
# Install Roundcube from source if it is not already present or if it is out of date.
|
||||||
# Combine the Roundcube version number with the commit hash of vacation_sieve to track
|
# Combine the Roundcube version number with the commit hash of vacation_sieve to track
|
||||||
# whether we have the latest version.
|
# whether we have the latest version.
|
||||||
VERSION=1.1.1
|
VERSION=1.1.4
|
||||||
HASH=08222f382a8dd89bba7dbbad595f48443bec0aa2
|
HASH=4883c8bb39fadf8af94ffb09ee426cba9f8ef2e3
|
||||||
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
||||||
PERSISTENT_LOGIN_VERSION=9a0bc59493beb573d515f82aec443e2098365d11
|
PERSISTENT_LOGIN_VERSION=1e9d724476a370ce917a2fcd5b3217b0c306c24e
|
||||||
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION
|
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
|
||||||
|
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:a
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
if [ ! -f /usr/local/lib/roundcubemail/version ]; then
|
||||||
# not installed yet #NODOC
|
# not installed yet #NODOC
|
||||||
@@ -47,12 +50,11 @@ elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
|||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
# install roundcube
|
# install roundcube
|
||||||
echo installing Roundcube webmail $VERSION...
|
|
||||||
wget_verify \
|
wget_verify \
|
||||||
http://downloads.sourceforge.net/project/roundcubemail/roundcubemail/$VERSION/roundcubemail-$VERSION.tar.gz \
|
https://s3.amazonaws.com/joshdata/mail-in-a-box/public/roundcubemail-$VERSION.tar.gz \
|
||||||
$HASH \
|
$HASH \
|
||||||
/tmp/roundcube.tgz
|
/tmp/roundcube.tgz
|
||||||
tar -C /usr/local/lib -zxf /tmp/roundcube.tgz
|
tar -C /usr/local/lib --no-same-owner -zxf /tmp/roundcube.tgz
|
||||||
rm -rf /usr/local/lib/roundcubemail
|
rm -rf /usr/local/lib/roundcubemail
|
||||||
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
|
mv /usr/local/lib/roundcubemail-$VERSION/ /usr/local/lib/roundcubemail
|
||||||
rm -f /tmp/roundcube.tgz
|
rm -f /tmp/roundcube.tgz
|
||||||
@@ -63,6 +65,9 @@ if [ $needs_update == 1 ]; then
|
|||||||
# install roundcube persistent_login plugin
|
# install roundcube persistent_login plugin
|
||||||
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' /usr/local/lib/roundcubemail/plugins/persistent_login
|
git_clone https://github.com/mfreiholz/Roundcube-Persistent-Login-Plugin.git $PERSISTENT_LOGIN_VERSION '' /usr/local/lib/roundcubemail/plugins/persistent_login
|
||||||
|
|
||||||
|
# install roundcube html5_notifier plugin
|
||||||
|
git_clone https://github.com/kitist/html5_notifier.git $HTML5_NOTIFIER_VERSION '' /usr/local/lib/roundcubemail/plugins/html5_notifier
|
||||||
|
|
||||||
# record the version we've installed
|
# record the version we've installed
|
||||||
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
|
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
|
||||||
fi
|
fi
|
||||||
@@ -70,7 +75,7 @@ fi
|
|||||||
# ### Configuring Roundcube
|
# ### Configuring Roundcube
|
||||||
|
|
||||||
# Generate a safe 24-character secret key of safe characters.
|
# Generate a safe 24-character secret key of safe characters.
|
||||||
SECRET_KEY=$(dd if=/dev/random bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
|
SECRET_KEY=$(dd if=/dev/urandom bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
|
||||||
|
|
||||||
# Create a configuration file.
|
# Create a configuration file.
|
||||||
#
|
#
|
||||||
@@ -96,7 +101,7 @@ cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
|||||||
\$config['support_url'] = 'https://mailinabox.email/';
|
\$config['support_url'] = 'https://mailinabox.email/';
|
||||||
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
||||||
\$config['des_key'] = '$SECRET_KEY';
|
\$config['des_key'] = '$SECRET_KEY';
|
||||||
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
|
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
|
||||||
\$config['skin'] = 'classic';
|
\$config['skin'] = 'classic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ source /etc/mailinabox.conf # load global vars
|
|||||||
|
|
||||||
# Prereqs.
|
# Prereqs.
|
||||||
|
|
||||||
|
echo "Installing Z-Push (Exchange/ActiveSync server)..."
|
||||||
apt_install \
|
apt_install \
|
||||||
php-soap php5-imap libawl-php php5-xsl
|
php-soap php5-imap libawl-php php5-xsl
|
||||||
|
|
||||||
php5enmod imap
|
php5enmod imap
|
||||||
|
|
||||||
# Copy Z-Push into place.
|
# Copy Z-Push into place.
|
||||||
TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
|
TARGETHASH=80cbe53de4ab8dd598d1f2af6f0a23fa396c529a
|
||||||
needs_update=0 #NODOC
|
needs_update=0 #NODOC
|
||||||
if [ ! -f /usr/local/lib/z-push/version ]; then
|
if [ ! -f /usr/local/lib/z-push/version ]; then
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
@@ -30,7 +31,6 @@ elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
|
|||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
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
|
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
|
||||||
rm -f /usr/sbin/z-push-{admin,top}
|
rm -f /usr/sbin/z-push-{admin,top}
|
||||||
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
||||||
@@ -42,6 +42,9 @@ fi
|
|||||||
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
|
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
|
||||||
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
|
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
|
||||||
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
|
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
|
||||||
|
sed -i "s/define('LOG_MEMORY_PROFILER', .*/define('LOG_MEMORY_PROFILER', false);/" /usr/local/lib/z-push/config.php
|
||||||
|
sed -i "s/define('BUG68532FIXED', .*/define('BUG68532FIXED', false);/" /usr/local/lib/z-push/config.php
|
||||||
|
sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php
|
||||||
|
|
||||||
# Configure BACKEND
|
# Configure BACKEND
|
||||||
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
rm -f /usr/local/lib/z-push/backend/combined/config.php
|
||||||
@@ -73,6 +76,19 @@ chmod 750 /var/lib/z-push
|
|||||||
chown www-data:www-data /var/log/z-push
|
chown www-data:www-data /var/log/z-push
|
||||||
chown www-data:www-data /var/lib/z-push
|
chown www-data:www-data /var/lib/z-push
|
||||||
|
|
||||||
|
# Add log rotation
|
||||||
|
|
||||||
|
cat > /etc/logrotate.d/z-push <<EOF;
|
||||||
|
/var/log/z-push/*.log {
|
||||||
|
weekly
|
||||||
|
missingok
|
||||||
|
rotate 52
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# Restart service.
|
# Restart service.
|
||||||
|
|
||||||
restart_service php5-fpm
|
restart_service php5-fpm
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
#
|
#
|
||||||
# NAME VALUE
|
# 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
|
# If the -w option is given, then setting lines continue onto following
|
||||||
# lines while the lines start with whitespace, e.g.:
|
# lines while the lines start with whitespace, e.g.:
|
||||||
#
|
#
|
||||||
@@ -24,7 +26,7 @@ import sys, re
|
|||||||
|
|
||||||
# sanity check
|
# sanity check
|
||||||
if len(sys.argv) < 3:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
# parse command line arguments
|
# parse command line arguments
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
|||||||
print(mgmt("/mail/aliases"))
|
print(mgmt("/mail/aliases"))
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
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:
|
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:
|
else:
|
||||||
print("Invalid command-line arguments.")
|
print("Invalid command-line arguments.")
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
#
|
#
|
||||||
# This is a tool Josh uses on his box serving mailinabox.email to parse the nginx
|
# This is a tool Josh uses on his box serving mailinabox.email to parse the nginx
|
||||||
# access log to see how many people are installing Mail-in-a-Box each day, by
|
# access log to see how many people are installing Mail-in-a-Box each day, by
|
||||||
# looking at accesses to the bootstrap.sh script.
|
# looking at accesses to the bootstrap.sh script (which is currently at the URL
|
||||||
|
# .../setup.sh).
|
||||||
|
|
||||||
import re, glob, gzip, os.path, json
|
import re, glob, gzip, os.path, json
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
@@ -24,8 +25,10 @@ for fn in glob.glob("/var/log/nginx/access.log*"):
|
|||||||
# Loop through the lines in the access log.
|
# Loop through the lines in the access log.
|
||||||
with f:
|
with f:
|
||||||
for line in f:
|
for line in f:
|
||||||
# Find lines that are GETs on /bootstrap.sh by either curl or wget.
|
# Find lines that are GETs on the bootstrap script by either curl or wget.
|
||||||
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /bootstrap.sh HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
|
# (Note that we purposely skip ...?ping=1 requests which is the admin panel querying us for updates.)
|
||||||
|
# (Also, the URL changed in January 2016, but we'll accept both.)
|
||||||
|
m = re.match(rb"(?P<ip>\S+) - - \[(?P<date>.*?)\] \"GET /(bootstrap.sh|setup.sh) HTTP/.*\" 200 \d+ .* \"(?:curl|wget)", line, re.I)
|
||||||
if m:
|
if m:
|
||||||
date, time = m.group("date").decode("ascii").split(":", 1)
|
date, time = m.group("date").decode("ascii").split(":", 1)
|
||||||
date = dateutil.parser.parse(date).date().isoformat()
|
date = dateutil.parser.parse(date).date().isoformat()
|
||||||
|
|||||||
@@ -458,7 +458,6 @@ class BashScript(Grammar):
|
|||||||
|
|
||||||
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
|
v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
|
||||||
v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
|
v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
|
||||||
v = re.sub(r"\$CSR_COUNTRY", r"<b>US</b>", v)
|
|
||||||
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
|
v = v.replace("`pwd`", "<code><b>/path/to/mailinabox</b></code>")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|||||||
24
tools/update-subresource-integrity.py
Executable file
24
tools/update-subresource-integrity.py
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# Updates subresource integrity attributes in management/templates/index.html
|
||||||
|
# to prevent CDN-hosted resources from being used as an attack vector. Run this
|
||||||
|
# after updating the Bootstrap and jQuery <link> and <script> to compute the
|
||||||
|
# appropriate hash and insert it into the template.
|
||||||
|
|
||||||
|
import re, urllib.request, hashlib, base64
|
||||||
|
|
||||||
|
fn = "management/templates/index.html"
|
||||||
|
|
||||||
|
with open(fn, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
def make_integrity(url):
|
||||||
|
resource = urllib.request.urlopen(url).read()
|
||||||
|
return "sha256-" + base64.b64encode(hashlib.sha256(resource).digest()).decode('ascii')
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'<(link rel="stylesheet" href|script src)="(.*?)" integrity="(.*?)"',
|
||||||
|
lambda m : '<' + m.group(1) + '="' + m.group(2) + '" integrity="' + make_integrity(m.group(2)) + '"',
|
||||||
|
content)
|
||||||
|
|
||||||
|
with open(fn, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
Reference in New Issue
Block a user