mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-12 17:07:23 +01:00
Compare commits
1079 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3314c4f7de | ||
|
|
1f60236985 | ||
|
|
32c68874c5 | ||
|
|
286a4bd9e7 | ||
|
|
ddf8e857fd | ||
|
|
4d5ff0210b | ||
|
|
89cd9fb611 | ||
|
|
22a6270657 | ||
|
|
0a970f4bb2 | ||
|
|
9b111e2493 | ||
|
|
b8feb77ef4 | ||
|
|
3c44604316 | ||
|
|
1e1a054686 | ||
|
|
d584a41e60 | ||
|
|
56074ae035 | ||
|
|
30631b0fc5 | ||
|
|
84da4e6000 | ||
|
|
58ded74181 | ||
|
|
3fd2e3efa9 | ||
|
|
7cda439c80 | ||
|
|
91fc74b408 | ||
|
|
d7244ed920 | ||
|
|
e0c0b5053c | ||
|
|
268b31685d | ||
|
|
ab71abbc7c | ||
|
|
87e6df9e28 | ||
|
|
558f2db31f | ||
|
|
c23dd701f0 | ||
|
|
0a7b9d5089 | ||
|
|
1eddf9a220 | ||
|
|
78d71498fa | ||
|
|
b41a0ad80e | ||
|
|
78569e9a88 | ||
|
|
8cb360fe36 | ||
|
|
f534a530d4 | ||
|
|
2abcafd670 | ||
|
|
3c3d62ac27 | ||
|
|
d829d74048 | ||
|
|
2aca421415 | ||
|
|
99474b348f | ||
|
|
8bebaf6a48 | ||
|
|
9004bb6e8e | ||
|
|
69d8fdef99 | ||
|
|
eeee712cf3 | ||
|
|
8f42d97b54 | ||
|
|
6e40c69cb5 | ||
|
|
c0e54f87d7 | ||
|
|
3a7de051ee | ||
|
|
f11cb04a72 | ||
|
|
cb564a130a | ||
|
|
d1d6318862 | ||
|
|
34b7a02f4f | ||
|
|
a312acc3bc | ||
|
|
aab1ec691c | ||
|
|
520caf6557 | ||
|
|
c92fd02262 | ||
|
|
a85c429a85 | ||
|
|
50a5cb90bc | ||
|
|
aac878dce5 | ||
|
|
58b0323b36 | ||
|
|
646f971d8b | ||
|
|
86067be142 | ||
|
|
c67ff241c4 | ||
|
|
7b4cd443bf | ||
|
|
34017548d5 | ||
|
|
65861c68b7 | ||
|
|
71a7a3e201 | ||
|
|
1c3bca53bb | ||
|
|
b643cb3478 | ||
|
|
113b7bd827 | ||
|
|
3e19f85fad | ||
|
|
11e84d0d40 | ||
|
|
79966e36e3 | ||
|
|
66b15d42a5 | ||
|
|
df46e1311b | ||
|
|
353084ce67 | ||
|
|
91079ab934 | ||
|
|
e5909a6287 | ||
|
|
26932ecb10 | ||
|
|
e884c4774f | ||
|
|
53ec0f39cb | ||
|
|
700188c443 | ||
|
|
ba80d9e72d | ||
|
|
a71a58e816 | ||
|
|
67b5711c68 | ||
|
|
20ccda8710 | ||
|
|
0ba841c7b6 | ||
|
|
daad122236 | ||
|
|
21ad26e452 | ||
|
|
4cb46ea465 | ||
|
|
35fa3fe891 | ||
|
|
d510c8ae2a | ||
|
|
e283a12047 | ||
|
|
e421addf1c | ||
|
|
354a774989 | ||
|
|
aaa81ec879 | ||
|
|
dbd6dae5ce | ||
|
|
d4c5872547 | ||
|
|
3701e05d92 | ||
|
|
bc4ae51c2d | ||
|
|
12aaebfc54 | ||
|
|
49813534bd | ||
|
|
16e81e1439 | ||
|
|
b7b67e31b7 | ||
|
|
2e7f2835e7 | ||
|
|
8a5f9f464a | ||
|
|
69fc2fdd3a | ||
|
|
9b07d86bf7 | ||
|
|
ae3feebd80 | ||
|
|
2c295bcafd | ||
|
|
8cda58fb22 | ||
|
|
178c587654 | ||
|
|
34569d24a9 | ||
|
|
6653dbb2e2 | ||
|
|
5fc1162355 | ||
|
|
a839602cba | ||
|
|
f21a41dc84 | ||
|
|
055ac07663 | ||
|
|
c7b295f403 | ||
|
|
d36a2cc938 | ||
|
|
82ca54df96 | ||
|
|
af62e7a99b | ||
|
|
90d63fd208 | ||
|
|
e81963e585 | ||
|
|
b1d703a5e7 | ||
|
|
e3d98b781e | ||
|
|
50d50ba653 | ||
|
|
879467d358 | ||
|
|
8025c41ee4 | ||
|
|
7a5d729a53 | ||
|
|
e2f9cd845a | ||
|
|
e26cf4512c | ||
|
|
c7280055a8 | ||
|
|
003e8b7bb1 | ||
|
|
3422cc61ce | ||
|
|
8664afa997 | ||
|
|
82229ce04b | ||
|
|
f66e609d3f | ||
|
|
b85b86e6de | ||
|
|
7fd35bbd11 | ||
|
|
7ce41e3865 | ||
|
|
92221f9efb | ||
|
|
0bd3977cde | ||
|
|
6a979f4f52 | ||
|
|
545e7a52e4 | ||
|
|
48c233ebe5 | ||
|
|
9a588de754 | ||
|
|
ac9ecc3bd3 | ||
|
|
8b166f3041 | ||
|
|
5509420637 | ||
|
|
7d6c7b6610 | ||
|
|
1f0e493b8c | ||
|
|
ada2167d08 | ||
|
|
be5032ffbe | ||
|
|
00b3a3b0a9 | ||
|
|
6d82c0035a | ||
|
|
4dced10a3f | ||
|
|
b80f225691 | ||
|
|
7f0f28f8e3 | ||
|
|
a8ea456b49 | ||
|
|
03bff5292b | ||
|
|
e891a9a3f3 | ||
|
|
51aedcf6c3 | ||
|
|
853008ddcc | ||
|
|
7d6427904f | ||
|
|
dcb93d071c | ||
|
|
2ea97f0643 | ||
|
|
4791c2fc62 | ||
|
|
49c333221a | ||
|
|
481a333dc0 | ||
|
|
b0df35eba0 | ||
|
|
08ae3d2b7f | ||
|
|
7c4eb0fb70 | ||
|
|
ee01eae55e | ||
|
|
89b301afc7 | ||
|
|
ce70f44c58 | ||
|
|
6594e19a1f | ||
|
|
8597646a12 | ||
|
|
f205c48564 | ||
|
|
3c3683429b | ||
|
|
a7a66929aa | ||
|
|
0d72566c99 | ||
|
|
62db58eaaf | ||
|
|
891de8d6c3 | ||
|
|
62b9b1f15f | ||
|
|
94da7bb088 | ||
|
|
65983b8ac7 | ||
|
|
56d0289ed9 | ||
|
|
f253c40012 | ||
|
|
4bbe4af377 | ||
|
|
2c34a6df2b | ||
|
|
1098e2b48e | ||
|
|
c50170b816 | ||
|
|
cd518e6820 | ||
|
|
967409b157 | ||
|
|
1b2711fc42 | ||
|
|
e6102eacfb | ||
|
|
6fd3195275 | ||
|
|
224242dfde | ||
|
|
049bfb6f7f | ||
|
|
12d60d102b | ||
|
|
9db2fc7f05 | ||
|
|
e03a6541ce | ||
|
|
41642f2f59 | ||
|
|
df9bb263dc | ||
|
|
339c330b4f | ||
|
|
cfc8fb484c | ||
|
|
bc1be9d70a | ||
|
|
3a4b8da8fd | ||
|
|
37dad9d4bb | ||
|
|
b805f8695e | ||
|
|
10bedad3a3 | ||
|
|
afc9f9686a | ||
|
|
7de8fc9bc0 | ||
|
|
c87b62b8c2 | ||
|
|
8fe33da85d | ||
|
|
c202a5cbc6 | ||
|
|
1353949e42 | ||
|
|
c19f8c9ee6 | ||
|
|
40b21c466d | ||
|
|
f52749b403 | ||
|
|
d67e09f334 | ||
|
|
e224fc6656 | ||
|
|
5e47677f7a | ||
|
|
db9637ce4f | ||
|
|
f908bc364e | ||
|
|
30c2c60f59 | ||
|
|
ab5ce01bdd | ||
|
|
ddadb6c28a | ||
|
|
23be1031b8 | ||
|
|
faee29ba8b | ||
|
|
b86bf07d57 | ||
|
|
e6294049bc | ||
|
|
30885bcc8a | ||
|
|
a67f90593d | ||
|
|
385340da46 | ||
|
|
0271e549bb | ||
|
|
f53b18ebb9 | ||
|
|
8567a9b719 | ||
|
|
ad9d732608 | ||
|
|
aa15670dc2 | ||
|
|
81176c8e4b | ||
|
|
960b5d5bbd | ||
|
|
802e7a1f4d | ||
|
|
52c68c6510 | ||
|
|
54b1ee9a3d | ||
|
|
6e3dee8b3b | ||
|
|
cd62fd9826 | ||
|
|
91638c7fe0 | ||
|
|
ff8170d5ab | ||
|
|
f6f75f6fab | ||
|
|
2f54f39f31 | ||
|
|
fa792f664e | ||
|
|
b50dfb7f93 | ||
|
|
cde4e0caca | ||
|
|
df80b9fc71 | ||
|
|
7558ffd4f3 | ||
|
|
50e9e8af30 | ||
|
|
ed02e2106b | ||
|
|
24a567c3be | ||
|
|
70f05e9d52 | ||
|
|
889118aeb6 | ||
|
|
a70ba94b0c | ||
|
|
9e29564f48 | ||
|
|
5aeced5c2e | ||
|
|
46f64e0e0a | ||
|
|
4971b63501 | ||
|
|
3ff9817325 | ||
|
|
d6becddbe5 | ||
|
|
1d6793d124 | ||
|
|
c7377e602d | ||
|
|
08021ea19f | ||
|
|
295d481603 | ||
|
|
c4cb828f65 | ||
|
|
0657f9e875 | ||
|
|
e37768ca86 | ||
|
|
bea5eb0dda | ||
|
|
fd5b11823c | ||
|
|
5fc1944f04 | ||
|
|
39fd4ce16c | ||
|
|
c0f4d5479f | ||
|
|
193763f8f0 | ||
|
|
79759ea5a3 | ||
|
|
6e5ceab0f8 | ||
|
|
c6fa0d23df | ||
|
|
85e59245fd | ||
|
|
4232a1205c | ||
|
|
0d4c693792 | ||
|
|
77b2246010 | ||
|
|
aff80ac58c | ||
|
|
25fec63a03 | ||
|
|
9b46637aff | ||
|
|
fb25013334 | ||
|
|
dd7a2aa8a6 | ||
|
|
149552f79b | ||
|
|
adddd95e38 | ||
|
|
bad38840d8 | ||
|
|
10050aa601 | ||
|
|
c60e3dc842 | ||
|
|
c7659d9053 | ||
|
|
cd3fb1b487 | ||
|
|
29e77d25fc | ||
|
|
e56c55efe8 | ||
|
|
8e0d9b9f21 | ||
|
|
6e60b47cb5 | ||
|
|
a3add03706 | ||
|
|
7b592b1e99 | ||
|
|
a67aa4cfd4 | ||
|
|
31b743b164 | ||
|
|
71f1c92b9e | ||
|
|
e80a1dd4b7 | ||
|
|
b7e9a90005 | ||
|
|
0d4565e71d | ||
|
|
703a9376ef | ||
|
|
b3b798adf2 | ||
|
|
bd54b41041 | ||
|
|
a211ad422b | ||
|
|
ef28a1defd | ||
|
|
c5c413b447 | ||
|
|
d2beb3919b | ||
|
|
a7dded8182 | ||
|
|
000363492e | ||
|
|
5be74dec6e | ||
|
|
9ddca42c91 | ||
|
|
ff6d8fc672 | ||
|
|
870b82637a | ||
|
|
dc6458623d | ||
|
|
60f9c9e3b7 | ||
|
|
e5e0c64395 | ||
|
|
aa52f52d02 | ||
|
|
b05b06c74a | ||
|
|
7f8f4518e3 | ||
|
|
86e2cfb6c8 | ||
|
|
0335595e7e | ||
|
|
8d5670068a | ||
|
|
c9b3d88108 | ||
|
|
16f38042ec | ||
|
|
2f494e9a1c | ||
|
|
f739662392 | ||
|
|
6eb9055275 | ||
|
|
3dbd6c994a | ||
|
|
bc4bdca752 | ||
|
|
bbfa01f33a | ||
|
|
f6a641ad23 | ||
|
|
51972fd129 | ||
|
|
bb43a2127c | ||
|
|
d96613b8fe | ||
|
|
504a9b0abc | ||
|
|
842fbb3d72 | ||
|
|
a5d5a073c7 | ||
|
|
d4b122ee94 | ||
|
|
052a1f3b26 | ||
|
|
180b054dbc | ||
|
|
cb162da5fe | ||
|
|
de9c556ad7 | ||
|
|
f420294819 | ||
|
|
738e0a6e17 | ||
|
|
e0d46d1eb5 | ||
|
|
7f37abca05 | ||
|
|
2f467556bd | ||
|
|
15583ec10d | ||
|
|
78d1c9be6e | ||
|
|
b0b5d8e792 | ||
|
|
82844ca651 | ||
|
|
2a72c800f6 | ||
|
|
8be23d5ef6 | ||
|
|
f9a0e39cc9 | ||
|
|
0c0a079354 | ||
|
|
42e86610ba | ||
|
|
7c62f4b8e9 | ||
|
|
1eba7b0616 | ||
|
|
9c7820f422 | ||
|
|
87ec4e9f82 | ||
|
|
08becf7fa3 | ||
|
|
5eb4a53de1 | ||
|
|
598ade3f7a | ||
|
|
8f399df5bb | ||
|
|
ae73dc5d30 | ||
|
|
c409b2efd0 | ||
|
|
6961840c0e | ||
|
|
6162a9637c | ||
|
|
47c968e71b | ||
|
|
ed3e2aa712 | ||
|
|
fe597da7aa | ||
|
|
61e9888a85 | ||
|
|
35fed8606e | ||
|
|
ef6f121491 | ||
|
|
ec3aab0eaa | ||
|
|
8c69b9e261 | ||
|
|
e7150e3bc6 | ||
|
|
8d6d84d87f | ||
|
|
a6a1cc7ae0 | ||
|
|
b5c0736d27 | ||
|
|
8ee7de6ff3 | ||
|
|
0088fb4553 | ||
|
|
b2d103145f | ||
|
|
fc9e279cec | ||
|
|
257983d559 | ||
|
|
e924459140 | ||
|
|
441bd35053 | ||
|
|
a0e603a3c6 | ||
|
|
88604074d6 | ||
|
|
d43111eb48 | ||
|
|
6729588d8c | ||
|
|
5f14eca67f | ||
|
|
8944cd7980 | ||
|
|
2bbbc9dfa3 | ||
|
|
544f155948 | ||
|
|
f080eabb3a | ||
|
|
7bf377eed1 | ||
|
|
cd554cf480 | ||
|
|
e5448405ae | ||
|
|
a7eff8fb35 | ||
|
|
341aa8695a | ||
|
|
5efdd72f41 | ||
|
|
f25801e88d | ||
|
|
cc7be13098 | ||
|
|
2556e3fbc2 | ||
|
|
00898b2ff5 | ||
|
|
35b8a149d8 | ||
|
|
d0423afd18 | ||
|
|
edf42df835 | ||
|
|
734745a4a6 | ||
|
|
dbebaba8b9 | ||
|
|
cb765dfe2a | ||
|
|
81258e2189 | ||
|
|
4dd4b4232a | ||
|
|
48ff664ee9 | ||
|
|
a52c56e571 | ||
|
|
6ace97e482 | ||
|
|
19a928e4ec | ||
|
|
78f2fe213e | ||
|
|
a16855ecf0 | ||
|
|
d773140502 | ||
|
|
2c324d0bc9 | ||
|
|
2bd6cc4d6b | ||
|
|
b11157e0b6 | ||
|
|
46ba62b7b1 | ||
|
|
4c36d6e6c9 | ||
|
|
e49c99890b | ||
|
|
a13fd90347 | ||
|
|
18f1689f45 | ||
|
|
8234a5a9f4 | ||
|
|
1d9f9ea617 | ||
|
|
fbb38c3881 | ||
|
|
2caddb41eb | ||
|
|
d2b7204319 | ||
|
|
68ebca8a15 | ||
|
|
9c9dcdbf0a | ||
|
|
0c4c2e51bb | ||
|
|
828512b95a | ||
|
|
add985ce5d | ||
|
|
416dbebf45 | ||
|
|
2a046a22f4 | ||
|
|
b66f12dd4c | ||
|
|
6e04eb490f | ||
|
|
cd39c2b53f | ||
|
|
5da168466d | ||
|
|
a5f39784dd | ||
|
|
a072730fb8 | ||
|
|
00c61dbcdd | ||
|
|
10bf40250b | ||
|
|
453091f1fb | ||
|
|
48e0f39179 | ||
|
|
bb641cdfba | ||
|
|
255a65ac98 | ||
|
|
c7badb80d1 | ||
|
|
653cb7ce10 | ||
|
|
d7d8964afc | ||
|
|
6c3696a54a | ||
|
|
9c9cae2096 | ||
|
|
423f1907d0 | ||
|
|
86621392f6 | ||
|
|
368b9c50d0 | ||
|
|
3830facf78 | ||
|
|
d4baac2363 | ||
|
|
f88c907a29 | ||
|
|
89222d519a | ||
|
|
36bef2ee16 | ||
|
|
f6b20a810f | ||
|
|
f2ff14100e | ||
|
|
2c86fa3755 | ||
|
|
3c05fc94ff | ||
|
|
2e00530944 | ||
|
|
32d6728dc9 | ||
|
|
a3c71fe14f | ||
|
|
a24977a96e | ||
|
|
e694f57673 | ||
|
|
cd59de6314 | ||
|
|
a081d04082 | ||
|
|
09577816f8 | ||
|
|
2647febbf5 | ||
|
|
bd0635728c | ||
|
|
584cfe42c4 | ||
|
|
41601a592f | ||
|
|
18c253eeda | ||
|
|
34d58fb720 | ||
|
|
99d0afd650 | ||
|
|
cd717ec94e | ||
|
|
0b7f477b96 | ||
|
|
ab2367e98a | ||
|
|
384c3b5e3d | ||
|
|
d91368c478 | ||
|
|
61105b1ec3 | ||
|
|
b6f90e10c1 | ||
|
|
3af5e55035 | ||
|
|
e03b071e8b | ||
|
|
df93d82d0f | ||
|
|
59913a5e4c | ||
|
|
c3605f6211 | ||
|
|
96b3a29800 | ||
|
|
abb6a1a070 | ||
|
|
041b5f883f | ||
|
|
3b78a8d9d6 | ||
|
|
6ea1a06a12 | ||
|
|
2b00478b8b | ||
|
|
155bcfc654 | ||
|
|
4b07a6aa8f | ||
|
|
2151d81453 | ||
|
|
fd6226187a | ||
|
|
bbe27df413 | ||
|
|
a658abc95f | ||
|
|
9331dbc519 | ||
|
|
8b5eba21c0 | ||
|
|
da5497cd1c | ||
|
|
a27ec68467 | ||
|
|
3ac4b8aca8 | ||
|
|
02feeafe6a | ||
|
|
5f0376bfbf | ||
|
|
4e4fe90fc7 | ||
|
|
3cd5a6eee7 | ||
|
|
c26bc841a2 | ||
|
|
163daea41c | ||
|
|
d8316119eb | ||
|
|
102b2d46ab | ||
|
|
58541c467f | ||
|
|
00bd23eb04 | ||
|
|
d73d1c6900 | ||
|
|
fc0abd5b4d | ||
|
|
27b4edfc76 | ||
|
|
ba75ff7820 | ||
|
|
a14b17794b | ||
|
|
35a360ef0b | ||
|
|
86457e5bc4 | ||
|
|
7c9f3e0b23 | ||
|
|
83d8dbca3e | ||
|
|
8cf2e468bd | ||
|
|
440a545010 | ||
|
|
942bcfc7c5 | ||
|
|
4f2d16a31d | ||
|
|
e9368de462 | ||
|
|
cdd0a821eb | ||
|
|
6f165d0aeb | ||
|
|
6c22c0533e | ||
|
|
d38b732b0a | ||
|
|
81b5af6b64 | ||
|
|
fc5cc9753b | ||
|
|
1aca6fe08f | ||
|
|
cf3e1cd595 | ||
|
|
b044dda28f | ||
|
|
f66f39b61d | ||
|
|
6de7d59f14 | ||
|
|
9c8f2e75fc | ||
|
|
cbc4bf553d | ||
|
|
4e3cfead46 | ||
|
|
8844a9185f | ||
|
|
3249a55f3a | ||
|
|
b58fb54725 | ||
|
|
82903cd09e | ||
|
|
fb14e30feb | ||
|
|
d9ac321f25 | ||
|
|
bf5e9200f8 | ||
|
|
5f5f00af4a | ||
|
|
6b73bb5d80 | ||
|
|
3055f9a79c | ||
|
|
1c84e0aeb6 | ||
|
|
ae1b56d23f | ||
|
|
946cd63e8e | ||
|
|
01fa8cf72c | ||
|
|
fac8477ba1 | ||
|
|
61744095a8 | ||
|
|
d5b38a27e6 | ||
|
|
6666d28c44 | ||
|
|
66675ff2e9 | ||
|
|
9ee2d946b7 | ||
|
|
ff7d4196a6 | ||
|
|
490b36d86c | ||
|
|
867d9c4669 | ||
|
|
1ad5892acd | ||
|
|
94b7c80792 | ||
|
|
69bd137b4e | ||
|
|
ae8cd4efdf | ||
|
|
6d259a6e12 | ||
|
|
e7fffc66c7 | ||
|
|
8548ede638 | ||
|
|
6eeb107ee3 | ||
|
|
31eefa18da | ||
|
|
20adbb51cb | ||
|
|
79a39d86f9 | ||
|
|
0ebf33e9df | ||
|
|
d3818d1db6 | ||
|
|
f65d9d3196 | ||
|
|
74fea6b93e | ||
|
|
7a935d8385 | ||
|
|
7e0f534aea | ||
|
|
736b3de221 | ||
|
|
5628f8eecb | ||
|
|
9cc5160c38 | ||
|
|
42f2e983e5 | ||
|
|
bc40134b7b | ||
|
|
3649ba1ce9 | ||
|
|
22395bdb8b | ||
|
|
30c89be982 | ||
|
|
853b641d1b | ||
|
|
703a963ae5 | ||
|
|
c9f30e8059 | ||
|
|
1a1d125b31 | ||
|
|
86881c0107 | ||
|
|
e65c77588e | ||
|
|
3843f63416 | ||
|
|
703e6795e8 | ||
|
|
b3223136f4 | ||
|
|
aa1fdaddaf | ||
|
|
7fa9baf308 | ||
|
|
36d51bbde0 | ||
|
|
eb8cfaab75 | ||
|
|
c5e8a975cd | ||
|
|
3210ccdcac | ||
|
|
252c35c66e | ||
|
|
c910a58f07 | ||
|
|
f292e8fc5b | ||
|
|
4d7229ccb0 | ||
|
|
1e1c3cbd00 | ||
|
|
611e9cc84d | ||
|
|
454a2b167b | ||
|
|
f6e0af124f | ||
|
|
d7d8bda0a4 | ||
|
|
df92a10eba | ||
|
|
74a0359cec | ||
|
|
336b95b3d5 | ||
|
|
56591abbc2 | ||
|
|
313a86d0fa | ||
|
|
083e3cf755 | ||
|
|
696bbe4e82 | ||
|
|
3d4cabbcd5 | ||
|
|
cdedaed3b0 | ||
|
|
c01f903413 | ||
|
|
5edefbec27 | ||
|
|
67555679bd | ||
|
|
546d6f0026 | ||
|
|
bd86d44c8b | ||
|
|
72fcb005b2 | ||
|
|
84638ab11e | ||
|
|
84f4509b48 | ||
|
|
35a593af13 | ||
|
|
f69d6e9015 | ||
|
|
44705a32b7 | ||
|
|
e343061cf4 | ||
|
|
65add24e2a | ||
|
|
33a9fb6aa2 | ||
|
|
0bc5d20e8f | ||
|
|
49ea9cddd1 | ||
|
|
6a48cdcdf3 | ||
|
|
f78f039776 | ||
|
|
d881487d68 | ||
|
|
33d07b2b54 | ||
|
|
3bbec18ac6 | ||
|
|
fc5c198646 | ||
|
|
2be373fd06 | ||
|
|
b71ad85e9f | ||
|
|
86d3e9da86 | ||
|
|
f53d3bc390 | ||
|
|
f9ca440ce8 | ||
|
|
8ea2f5a766 | ||
|
|
6c1357e16c | ||
|
|
d880f088be | ||
|
|
5cabfd591b | ||
|
|
721730f0e8 | ||
|
|
af80849857 | ||
|
|
7a191e67b8 | ||
|
|
4b2e48f2c0 | ||
|
|
eb545d7941 | ||
|
|
a2e6e81697 | ||
|
|
1b24e2cbaf | ||
|
|
0843159fb4 | ||
|
|
a7e60af93f | ||
|
|
42f879687f | ||
|
|
057903a303 | ||
|
|
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 | ||
|
|
6b408ef824 | ||
|
|
62b6117638 | ||
|
|
70111dafbc | ||
|
|
faaa74c3a7 | ||
|
|
8932aaf4ef | ||
|
|
6d6f3ea391 | ||
|
|
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 |
30
.editorconfig
Normal file
30
.editorconfig
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# EditorConfig helps developers define and maintain consistent
|
||||||
|
# coding styles between different editors and IDEs
|
||||||
|
# editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Vagrantfile]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.rb]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.js]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ management/__pycache__/
|
|||||||
tools/__pycache__/
|
tools/__pycache__/
|
||||||
externals/
|
externals/
|
||||||
.env
|
.env
|
||||||
|
.vagrant
|
||||||
|
api/docs/api-docs.html
|
||||||
1006
CHANGELOG.md
1006
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
48
CODE_OF_CONDUCT.md
Normal file
48
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Mail-in-a-Box Code of Conduct
|
||||||
|
|
||||||
|
Mail-in-a-Box is an open source community project about working, as a group, to empower ourselves and others to have control over our own digital communications. Just as we hope to increase technological diversity on the Internet through decentralization, we also believe that diverse viewpoints and voices among our community members foster innovation and creative solutions to the challenges we face.
|
||||||
|
|
||||||
|
We are committed to providing a safe, welcoming, and harrassment-free space for collaboration, for everyone, without regard to age, disability, economic situation, ethnicity, gender identity and expression, language fluency, level of knowledge or experience, nationality, personal appearance, race, religion, sexual identity and orientation, or any other attribute. Community comes first. This policy supersedes all other project goals.
|
||||||
|
|
||||||
|
The maintainers of Mail-in-a-Box share the dual responsibility of leading by example and enforcing these policies as necessary to maintain an open and welcoming environment. All community members should be excellent to each other.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies to all places where Mail-in-a-Box community activity is ocurring, including on GitHub, in discussion forums, on Slack, on social media, and in real life. The Code of Conduct applies not only on websites/at events run by the Mail-in-a-Box community (e.g. our GitHub organization, our Slack team) but also at any other location where the Mail-in-a-Box community is present (e.g. in issues of other GitHub organizations where Mail-in-a-Box community members are discussing problems related to Mail-in-a-Box, or real-life professional conferences), or whenever a Mail-in-a-Box community member is representing Mail-in-a-Box to the public at large or acting on behalf of Mail-in-a-Box.
|
||||||
|
|
||||||
|
This code does not apply to activity on a server running Mail-in-a-Box software, unless your server is hosting a service for the Mail-in-a-Box community at large.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to creating a positive environment include:
|
||||||
|
|
||||||
|
* Using welcoming and inclusive language
|
||||||
|
* Being respectful of differing viewpoints and experiences
|
||||||
|
* Gracefully accepting constructive criticism
|
||||||
|
* Showing empathy towards other community members
|
||||||
|
* Making room for new and quieter voices
|
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||||
|
* Trolling, insulting/derogatory/unwelcome comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||||
|
* Aggressive and micro-aggressive behavior, such as unconstructive criticism, providing corrections that do not improve the conversation (sometimes referred to as "well actually"s), repeatedly interrupting or talking over someone else, feigning surprise at someone's lack of knowledge or awareness about a topic, or subtle prejudice (for example, comments like "That's so easy my grandmother could do it.", which is prejudicial toward grandmothers).
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
* Retaliating against anyone who reports a violation of this code.
|
||||||
|
|
||||||
|
We will not tolerate harassment. Harassment is any unwelcome or hostile behavior towards another person for any reason. This includes, but is not limited to, offensive verbal comments related to personal characteristics or choices, sexual images or comments, deliberate intimidation, bullying, stalking, following, harassing photography or recording, sustained disruption of discussion or events, nonconsensual publication of private comments, inappropriate physical contact, or unwelcome sexual attention. Conduct need not be intentional to be harassment.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
We will remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not consistent with this Code of Conduct. We may ban, temporarily or permanently, any contributor for violating this code, when appropriate.
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project lead, [Joshua Tauberer](https://razor.occams.info/). All reports will be treated confidentially, impartially, consistently, and swiftly.
|
||||||
|
|
||||||
|
Because the need for confidentiality for all parties involved in an enforcement action outweighs the goals of openness, limited information will be shared with the Mail-in-a-Box community regarding enforcement actions that have taken place.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant, version 1.4](http://contributor-covenant.org/version/1/4) and the code of conduct of [Code for DC](http://codefordc.org/resources/codeofconduct.html).
|
||||||
|
|
||||||
@@ -1,3 +1,50 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To start developing Mail-in-a-Box, [clone the repository](https://github.com/mail-in-a-box/mailinabox) and familiarize yourself with the code.
|
||||||
|
|
||||||
|
$ git clone https://github.com/mail-in-a-box/mailinabox
|
||||||
|
|
||||||
|
### Vagrant and VirtualBox
|
||||||
|
|
||||||
|
We recommend you use [Vagrant](https://www.vagrantup.com/intro/getting-started/install.html) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads) for development. Please install them first.
|
||||||
|
|
||||||
|
With Vagrant set up, the following should boot up Mail-in-a-Box inside a virtual machine:
|
||||||
|
|
||||||
|
$ vagrant up --provision
|
||||||
|
|
||||||
|
_If you're seeing an error message about your *IP address being listed in the Spamhaus Block List*, simply uncomment the `export SKIP_NETWORK_CHECKS=1` line in `Vagrantfile`. It's normal, you're probably using a dynamic IP address assigned by your Internet provider–they're almost all listed._
|
||||||
|
|
||||||
|
### Modifying your `hosts` file
|
||||||
|
|
||||||
|
After a while, Mail-in-a-Box will be available at `192.168.56.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file:
|
||||||
|
|
||||||
|
$ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts
|
||||||
|
|
||||||
|
You should now be able to navigate to https://mailinabox.lan/admin using your browser. There should be an initial admin user with the name `me@mailinabox.lan` and the password `12345678`.
|
||||||
|
|
||||||
|
### Making changes
|
||||||
|
|
||||||
|
Your working copy of Mail-in-a-Box will be mounted inside your VM at `/vagrant`. Any change you make locally will appear inside your VM automatically.
|
||||||
|
|
||||||
|
Running `vagrant up --provision` again will repeat the installation with your modifications.
|
||||||
|
|
||||||
|
Alternatively, you can also ssh into the VM using:
|
||||||
|
|
||||||
|
$ vagrant ssh
|
||||||
|
|
||||||
|
Once inside the VM, you can re-run individual parts of the setup like in this example:
|
||||||
|
|
||||||
|
vm$ cd /vagrant
|
||||||
|
vm$ sudo setup/owncloud.sh # replace with script you'd like to re-run
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Mail-in-a-Box needs more tests. If you're still looking for a way to help out, writing and contributing tests would be a great start!
|
||||||
|
|
||||||
## Public domain
|
## Public domain
|
||||||
|
|
||||||
This project is in the public domain. Copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication][CC0]. See the LICENSE file in this directory.
|
This project is in the public domain. Copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication][CC0]. See the LICENSE file in this directory.
|
||||||
@@ -5,3 +52,7 @@ This project is in the public domain. Copyright and related rights in the work w
|
|||||||
All contributions to this project must be released under the same CC0 wavier. By submitting a pull request or patch, you are agreeing to comply with this waiver of copyright interest.
|
All contributions to this project must be released under the same CC0 wavier. By submitting a pull request or patch, you are agreeing to comply with this waiver of copyright interest.
|
||||||
|
|
||||||
[CC0]: http://creativecommons.org/publicdomain/zero/1.0/
|
[CC0]: http://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project has a [Code of Conduct](CODE_OF_CONDUCT.md). Please review it when joining our community.
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -9,70 +9,92 @@ Mail-in-a-Box helps individuals take back control of their email by defining a o
|
|||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|
||||||
I am trying to:
|
Our goals are to:
|
||||||
|
|
||||||
* Make deploying a good mail server easy.
|
* Make deploying a good mail server easy.
|
||||||
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
|
* Promote [decentralization](http://redecentralize.org/), innovation, and privacy on the web.
|
||||||
* Have automated, auditable, and [idempotent](http://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
* Have automated, auditable, and [idempotent](https://web.archive.org/web/20190518072631/https://sharknet.us/2014/02/01/automated-configuration-management-challenges-with-idempotency/) configuration.
|
||||||
* **Not** make a totally unhackable, NSA-proof server.
|
* **Not** make a totally unhackable, NSA-proof server.
|
||||||
* **Not** make something customizable by power users.
|
* **Not** make something customizable by power users.
|
||||||
|
|
||||||
This setup is what has been powering my own personal email since September 2013.
|
Additionally, this project has a [Code of Conduct](CODE_OF_CONDUCT.md), which supersedes the goals above. Please review it when joining our community.
|
||||||
|
|
||||||
The Box
|
|
||||||
-------
|
|
||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
In The Box
|
||||||
|
----------
|
||||||
|
|
||||||
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".
|
Mail-in-a-Box turns a fresh Ubuntu 22.04 LTS 64-bit machine into a working mail server by installing and configuring various components.
|
||||||
|
|
||||||
|
It is a one-click email appliance. There are no user-configurable setup options. It "just works."
|
||||||
|
|
||||||
The components installed are:
|
The components installed are:
|
||||||
|
|
||||||
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib))
|
* SMTP ([postfix](http://www.postfix.org/)), IMAP ([Dovecot](http://dovecot.org/)), CardDAV/CalDAV ([Nextcloud](https://nextcloud.com/)), and Exchange ActiveSync ([z-push](http://z-push.org/)) servers
|
||||||
* Webmail ([Roundcube](http://roundcube.net/)), static website hosting ([nginx](http://nginx.org/))
|
* Webmail ([Roundcube](http://roundcube.net/)), mail filter rules (thanks to Roundcube and Dovecot), and email client autoconfig settings (served by [nginx](http://nginx.org/))
|
||||||
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/))
|
* Spam filtering ([spamassassin](https://spamassassin.apache.org/)) and 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](https://www.nlnetlabs.nl/projects/nsd/)) with [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework), DKIM ([OpenDKIM](http://www.opendkim.org/)), [DMARC](https://en.wikipedia.org/wiki/DMARC), [DNSSEC](https://en.wikipedia.org/wiki/DNSSEC), [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), [MTA-STS](https://tools.ietf.org/html/rfc8461), and [SSHFP](https://tools.ietf.org/html/rfc4255) policy records automatically set
|
||||||
* Firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page))
|
* TLS certificates are automatically provisioned using [Let's Encrypt](https://letsencrypt.org/) for protecting https and all of the other services on the box
|
||||||
* A control panel and API for adding/removing mail users, aliases, custom DNS records, etc. and system monitoring.
|
* Backups ([duplicity](http://duplicity.nongnu.org/)), firewall ([ufw](https://launchpad.net/ufw)), intrusion protection ([fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page)), and basic system monitoring ([munin](http://munin-monitoring.org/))
|
||||||
|
|
||||||
|
It also includes system management tools:
|
||||||
|
|
||||||
|
* Comprehensive health monitoring that checks each day that services are running, ports are open, TLS certificates are valid, and DNS records are correct
|
||||||
|
* A control panel for adding/removing mail users, aliases, custom DNS records, configuring backups, etc.
|
||||||
|
* An API for all of the actions on the control panel
|
||||||
|
|
||||||
|
Internationalized domain names are supported and configured easily (but SMTPUTF8 is not supported, unfortunately).
|
||||||
|
|
||||||
|
It also supports static website hosting since the box is serving HTTPS anyway. (To serve a website for your domains elsewhere, just add a custom DNS "A" record in you Mail-in-a-Box's control panel to point domains to another server.)
|
||||||
|
|
||||||
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 22.04 LTS 64-bit machine. On the machine...
|
||||||
|
|
||||||
# Download my PGP key.
|
Clone this repository and checkout the tag corresponding to the most recent release:
|
||||||
$ 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
|
||||||
|
$ git checkout v60.1
|
||||||
|
|
||||||
# Verify the tag.
|
Begin the installation.
|
||||||
$ git verify-tag v0.10
|
|
||||||
gpg: Signature made ..... using RSA key ID C10BDD81
|
$ sudo setup/start.sh
|
||||||
gpg: Good signature from "Joshua Tauberer <jt@occams.info>"
|
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
The installation will install, uninstall, and configure packages to turn the machine into a working, good mail server.
|
||||||
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
|
For help, DO NOT contact Josh directly --- I don't do tech support by email or tweet (no exceptions).
|
||||||
|
|
||||||
|
Post your question on the [discussion forum](https://discourse.mailinabox.email/) instead, where maintainers and Mail-in-a-Box users may be able to help you.
|
||||||
|
|
||||||
|
Note that while we want everything to "just work," we can't control the rest of the Internet. Other mail services might block or spam-filter email sent from your Mail-in-a-Box.
|
||||||
|
This is a challenge faced by everyone who runs their own mail server, with or without Mail-in-a-Box. See our discussion forum for tips about that.
|
||||||
|
|
||||||
|
|
||||||
|
Contributing and Development
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Mail-in-a-Box is an open source project. Your contributions and pull requests are welcome. See [CONTRIBUTING](CONTRIBUTING.md) to get started.
|
||||||
|
|
||||||
The 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/).
|
|
||||||
|
|
||||||
The Acknowledgements
|
The Acknowledgements
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/al3x/sovereign) by Alex Payne, and conversations with <a href="http://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
|
This project was inspired in part by the ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) blog post by Drew Crawford, [Sovereign](https://github.com/sovereign/sovereign) by Alex Payne, and conversations with <a href="https://twitter.com/shevski" target="_blank">@shevski</a>, <a href="https://github.com/konklone" target="_blank">@konklone</a>, and <a href="https://github.com/gregelin" target="_blank">@GregElin</a>.
|
||||||
|
|
||||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/) and [Modoboa](https://github.com/tonioo/modoboa).
|
||||||
|
|
||||||
|
|
||||||
The History
|
The History
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
* In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf).
|
* In 2007 I wrote a relatively popular Mozilla Thunderbird extension that added client-side SPF and DKIM checks to mail to warn users about possible phishing: [add-on page](https://addons.mozilla.org/en-us/thunderbird/addon/sender-verification-anti-phish/), [source](https://github.com/JoshData/thunderbird-spf).
|
||||||
* In August 2013 I began Mail-in-a-Box by combining my own mail server configuration with the setup in ["NSA-proof your email in 2 hours"](http://sealedabstract.com/code/nsa-proof-your-e-mail-in-2-hours/) and making the setup steps reproducible with bash scripts.
|
* 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, [May](https://news.ycombinator.com/item?id=9624267) 2015, and [November](https://news.ycombinator.com/item?id=13050500) 2016.
|
||||||
|
* FastCompany mentioned Mail-in-a-Box a [roundup of privacy projects](http://www.fastcompany.com/3047645/your-own-private-cloud) on June 26, 2015.
|
||||||
|
|||||||
18
Vagrantfile
vendored
18
Vagrantfile
vendored
@@ -2,27 +2,23 @@
|
|||||||
# vi: set ft=ruby :
|
# vi: set ft=ruby :
|
||||||
|
|
||||||
Vagrant.configure("2") do |config|
|
Vagrant.configure("2") do |config|
|
||||||
config.vm.box = "ubuntu14.04"
|
config.vm.box = "ubuntu/jammy64"
|
||||||
config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box"
|
|
||||||
|
|
||||||
# Network config: Since it's a mail server, the machine must be connected
|
# Network config: Since it's a mail server, the machine must be connected
|
||||||
# to the public web. However, we currently don't want to expose SSH since
|
# to the public web. However, we currently don't want to expose SSH since
|
||||||
# the machine's box will let anyone log into it. So instead we'll put the
|
# the machine's box will let anyone log into it. So instead we'll put the
|
||||||
# machine on a private network.
|
# machine on a private network.
|
||||||
config.vm.hostname = "mailinabox"
|
config.vm.hostname = "mailinabox.lan"
|
||||||
config.vm.network "private_network", ip: "192.168.50.4"
|
config.vm.network "private_network", ip: "192.168.56.4"
|
||||||
|
|
||||||
config.vm.provision :shell, :inline => <<-SH
|
config.vm.provision :shell, :inline => <<-SH
|
||||||
# Set environment variables so that the setup script does
|
# Set environment variables so that the setup script does
|
||||||
# not ask any questions during provisioning. We'll let the
|
# not ask any questions during provisioning. We'll let the
|
||||||
# machine figure out its own public IP and it'll take a
|
# machine figure out its own public IP.
|
||||||
# subdomain on our justtesting.email domain so we can get
|
|
||||||
# started quickly.
|
|
||||||
export NONINTERACTIVE=1
|
export NONINTERACTIVE=1
|
||||||
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
|
||||||
export CSR_COUNTRY=US
|
|
||||||
#export SKIP_NETWORK_CHECKS=1
|
#export SKIP_NETWORK_CHECKS=1
|
||||||
|
|
||||||
# Start the setup script.
|
# Start the setup script.
|
||||||
|
|||||||
23
api/docs/generate-docs.sh
Executable file
23
api/docs/generate-docs.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Requirements:
|
||||||
|
# - Node.js
|
||||||
|
# - redoc-cli (`npm install redoc-cli -g`)
|
||||||
|
|
||||||
|
redoc-cli bundle ../mailinabox.yml \
|
||||||
|
-t template.hbs \
|
||||||
|
-o api-docs.html \
|
||||||
|
--templateOptions.metaDescription="Mail-in-a-Box HTTP API" \
|
||||||
|
--title="Mail-in-a-Box HTTP API" \
|
||||||
|
--options.expandSingleSchemaField \
|
||||||
|
--options.hideSingleRequestSampleTab \
|
||||||
|
--options.jsonSampleExpandLevel=10 \
|
||||||
|
--options.hideDownloadButton \
|
||||||
|
--options.theme.logo.maxHeight=180px \
|
||||||
|
--options.theme.logo.maxWidth=180px \
|
||||||
|
--options.theme.colors.primary.main="#C52" \
|
||||||
|
--options.theme.typography.fontSize=16px \
|
||||||
|
--options.theme.typography.fontFamily="Raleway, sans-serif" \
|
||||||
|
--options.theme.typography.headings.fontFamily="Ubuntu, Arial, sans-serif" \
|
||||||
|
--options.theme.typography.code.fontSize=15px \
|
||||||
|
--options.theme.typography.code.fontFamily='"Source Code Pro", monospace'
|
||||||
31
api/docs/template.hbs
Normal file
31
api/docs/template.hbs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf8" />
|
||||||
|
<title>{{title}}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="{{templateOptions.metaDescription}}" />
|
||||||
|
<link rel="icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
|
||||||
|
<link rel="apple-touch-icon" type="image/png" href="https://mailinabox.email/static/logo_small.png">
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Raleway:400,700" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source+Code+Pro:500" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #000 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{{redocHead}}}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{{{redocHTML}}}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
2732
api/mailinabox.yml
Normal file
2732
api/mailinabox.yml
Normal file
File diff suppressed because it is too large
Load Diff
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,22 +0,0 @@
|
|||||||
# Fail2Ban filter Dovecot authentication and pop3/imap server
|
|
||||||
# For Mail-in-a-Box
|
|
||||||
|
|
||||||
[INCLUDES]
|
|
||||||
|
|
||||||
before = common.conf
|
|
||||||
|
|
||||||
[Definition]
|
|
||||||
|
|
||||||
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
|
||||||
|
|
||||||
failregex = ^%(__prefix_line)s(pop3|imap)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
|
||||||
|
|
||||||
ignoreregex =
|
|
||||||
|
|
||||||
# DEV Notes:
|
|
||||||
# * the first regex is essentially a copy of pam-generic.conf
|
|
||||||
# * Probably doesn't do dovecot sql/ldap backends properly
|
|
||||||
#
|
|
||||||
# Author: Martin Waschbuesch
|
|
||||||
# Daniel Black (rewrote with begin and end anchors)
|
|
||||||
# Mail-in-a-Box (swapped session=...)
|
|
||||||
22
conf/fail2ban/filter.d/dovecotimap.conf
Normal file
22
conf/fail2ban/filter.d/dovecotimap.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Fail2Ban filter Dovecot authentication and pop3/imap/managesieve server
|
||||||
|
# For Mail-in-a-Box
|
||||||
|
|
||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
|
||||||
|
_daemon = (auth|dovecot(-auth)?|auth-worker)
|
||||||
|
|
||||||
|
failregex = ^%(__prefix_line)s(pop3|imap|managesieve)-login: (Info: )?(Aborted login|Disconnected)(: Inactivity)? \(((no auth attempts|auth failed, \d+ attempts)( in \d+ secs)?|tried to use (disabled|disallowed) \S+ auth)\):( user=<\S*>,)?( method=\S+,)? rip=<HOST>, lip=(\d{1,3}\.){3}\d{1,3}(, TLS( handshaking)?(: Disconnected)?)?(, session=<\S+>)?\s*$
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
|
|
||||||
|
# DEV Notes:
|
||||||
|
# * the first regex is essentially a copy of pam-generic.conf
|
||||||
|
# * Probably doesn't do dovecot sql/ldap backends properly
|
||||||
|
#
|
||||||
|
# Author: Martin Waschbuesch
|
||||||
|
# Daniel Black (rewrote with begin and end anchors)
|
||||||
|
# Mail-in-a-Box (swapped session=...)
|
||||||
12
conf/fail2ban/filter.d/miab-management-daemon.conf
Normal file
12
conf/fail2ban/filter.d/miab-management-daemon.conf
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Fail2Ban filter Mail-in-a-Box management daemon
|
||||||
|
|
||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
|
||||||
|
_daemon = mailinabox
|
||||||
|
|
||||||
|
failregex = Mail-in-a-Box Management Daemon: Failed login attempt from ip <HOST> - timestamp .*
|
||||||
|
ignoreregex =
|
||||||
7
conf/fail2ban/filter.d/miab-munin.conf
Normal file
7
conf/fail2ban/filter.d/miab-munin.conf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
failregex=<HOST> - .*GET /admin/munin/.* HTTP/1.1\" 401.*
|
||||||
|
ignoreregex =
|
||||||
8
conf/fail2ban/filter.d/miab-owncloud.conf
Normal file
8
conf/fail2ban/filter.d/miab-owncloud.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
datepattern = %%Y-%%m-%%d %%H:%%M:%%S
|
||||||
|
failregex=Login failed: .*Remote IP: '<HOST>[\)']
|
||||||
|
ignoreregex =
|
||||||
7
conf/fail2ban/filter.d/miab-postfix-submission.conf
Normal file
7
conf/fail2ban/filter.d/miab-postfix-submission.conf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
failregex=postfix/submission/smtpd.*warning.*\[<HOST>\]: .* authentication (failed|aborted)
|
||||||
|
ignoreregex =
|
||||||
9
conf/fail2ban/filter.d/miab-roundcube.conf
Normal file
9
conf/fail2ban/filter.d/miab-roundcube.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[INCLUDES]
|
||||||
|
|
||||||
|
before = common.conf
|
||||||
|
|
||||||
|
[Definition]
|
||||||
|
|
||||||
|
failregex = IMAP Error: Login failed for .*? from <HOST>\. AUTHENTICATE.*
|
||||||
|
|
||||||
|
ignoreregex =
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Fail2Ban configuration file.
|
|
||||||
# For Mail-in-a-Box
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# bantime in seconds
|
|
||||||
bantime = 60
|
|
||||||
|
|
||||||
# This should ban dumb brute-force attacks, not oblivious users.
|
|
||||||
findtime = 30
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
#
|
|
||||||
# JAILS
|
|
||||||
#
|
|
||||||
|
|
||||||
[ssh]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
logpath = /var/log/auth.log
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
[ssh-ddos]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
maxretry = 20
|
|
||||||
|
|
||||||
[sasl]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
[dovecot]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
filter = dovecotimap
|
|
||||||
86
conf/fail2ban/jails.conf
Normal file
86
conf/fail2ban/jails.conf
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Fail2Ban configuration file for Mail-in-a-Box. Do not edit.
|
||||||
|
# This file is re-generated on updates.
|
||||||
|
|
||||||
|
[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 ::1 PUBLIC_IPV6
|
||||||
|
|
||||||
|
[dovecot]
|
||||||
|
enabled = true
|
||||||
|
filter = dovecotimap
|
||||||
|
logpath = /var/log/mail.log
|
||||||
|
findtime = 30
|
||||||
|
maxretry = 20
|
||||||
|
|
||||||
|
[miab-management]
|
||||||
|
enabled = true
|
||||||
|
filter = miab-management-daemon
|
||||||
|
port = http,https
|
||||||
|
logpath = /var/log/syslog
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 30
|
||||||
|
|
||||||
|
[miab-munin]
|
||||||
|
enabled = true
|
||||||
|
port = http,https
|
||||||
|
filter = miab-munin
|
||||||
|
logpath = /var/log/nginx/access.log
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 30
|
||||||
|
|
||||||
|
[miab-owncloud]
|
||||||
|
enabled = true
|
||||||
|
port = http,https
|
||||||
|
filter = miab-owncloud
|
||||||
|
logpath = STORAGE_ROOT/owncloud/nextcloud.log
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 120
|
||||||
|
|
||||||
|
[miab-postfix465]
|
||||||
|
enabled = true
|
||||||
|
port = 465
|
||||||
|
filter = miab-postfix-submission
|
||||||
|
logpath = /var/log/mail.log
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 30
|
||||||
|
|
||||||
|
[miab-postfix587]
|
||||||
|
enabled = true
|
||||||
|
port = 587
|
||||||
|
filter = miab-postfix-submission
|
||||||
|
logpath = /var/log/mail.log
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 30
|
||||||
|
|
||||||
|
[miab-roundcube]
|
||||||
|
enabled = true
|
||||||
|
port = http,https
|
||||||
|
filter = miab-roundcube
|
||||||
|
logpath = /var/log/roundcubemail/errors.log
|
||||||
|
maxretry = 20
|
||||||
|
findtime = 30
|
||||||
|
|
||||||
|
[recidive]
|
||||||
|
enabled = true
|
||||||
|
maxretry = 10
|
||||||
|
action = iptables-allports[name=recidive]
|
||||||
|
# In the recidive section of jail.conf the action contains:
|
||||||
|
#
|
||||||
|
# action = iptables-allports[name=recidive]
|
||||||
|
# sendmail-whois-lines[name=recidive, logpath=/var/log/fail2ban.log]
|
||||||
|
#
|
||||||
|
# The last line on the action will sent an email to the configured address. This mail will
|
||||||
|
# notify the administrator that someone has been repeatedly triggering one of the other jails.
|
||||||
|
# By default we don't configure this address and no action is required from the admin anyway.
|
||||||
|
# So the notification is ommited. This will prevent message appearing in the mail.log that mail
|
||||||
|
# can't be delivered to fail2ban@$HOSTNAME.
|
||||||
|
|
||||||
|
[postfix-sasl]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
maxretry = 7
|
||||||
|
bantime = 3600
|
||||||
@@ -18,8 +18,6 @@
|
|||||||
<string>PRIMARY_HOSTNAME</string>
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
<key>CalDAVPort</key>
|
<key>CalDAVPort</key>
|
||||||
<real>443</real>
|
<real>443</real>
|
||||||
<key>CalDAVPrincipalURL</key>
|
|
||||||
<string>/cloud/remote.php/caldav/calendars/</string>
|
|
||||||
<key>CalDAVUseSSL</key>
|
<key>CalDAVUseSSL</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>PayloadDescription</key>
|
<key>PayloadDescription</key>
|
||||||
@@ -55,7 +53,7 @@
|
|||||||
<key>OutgoingMailServerHostName</key>
|
<key>OutgoingMailServerHostName</key>
|
||||||
<string>PRIMARY_HOSTNAME</string>
|
<string>PRIMARY_HOSTNAME</string>
|
||||||
<key>OutgoingMailServerPortNumber</key>
|
<key>OutgoingMailServerPortNumber</key>
|
||||||
<integer>587</integer>
|
<integer>465</integer>
|
||||||
<key>OutgoingMailServerUseSSL</key>
|
<key>OutgoingMailServerUseSSL</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||||
|
|||||||
11
conf/mailinabox.service
Normal file
11
conf/mailinabox.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mail-in-a-Box System Management Service
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=idle
|
||||||
|
IgnoreSIGPIPE=False
|
||||||
|
ExecStart=/usr/local/lib/mailinabox/start
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
#! /bin/sh
|
|
||||||
### BEGIN INIT INFO
|
|
||||||
# Provides: mailinabox
|
|
||||||
# Required-Start: $all
|
|
||||||
# Required-Stop: $all
|
|
||||||
# Default-Start: 2 3 4 5
|
|
||||||
# Default-Stop: 0 1 6
|
|
||||||
# Short-Description: Start and stop the Mail-in-a-Box management daemon.
|
|
||||||
# Description: Start and stop the Mail-in-a-Box management daemon.
|
|
||||||
### END INIT INFO
|
|
||||||
|
|
||||||
# Adapted from http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/
|
|
||||||
|
|
||||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
|
||||||
DESC="Mail-in-a-Box Management Daemon"
|
|
||||||
NAME=mailinabox
|
|
||||||
DAEMON=/usr/local/bin/mailinabox-daemon
|
|
||||||
PIDFILE=/var/run/$NAME.pid
|
|
||||||
SCRIPTNAME=/etc/init.d/$NAME
|
|
||||||
|
|
||||||
# Exit if the package is not installed
|
|
||||||
[ -x "$DAEMON" ] || exit 0
|
|
||||||
|
|
||||||
# Set defaults.
|
|
||||||
START=yes
|
|
||||||
EXEC_AS_USER=root
|
|
||||||
|
|
||||||
# Ensure Python reads/writes files in UTF-8. If the machine
|
|
||||||
# triggers some other locale in Python, like ASCII encoding,
|
|
||||||
# Python may not be able to read/write files. Here and in
|
|
||||||
# setup/start.sh (where the locale is also installed if not
|
|
||||||
# already present).
|
|
||||||
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
|
|
||||||
|
|
||||||
# Read configuration variable file if it is present
|
|
||||||
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
|
||||||
|
|
||||||
# Load the VERBOSE setting and other rcS variables
|
|
||||||
. /lib/init/vars.sh
|
|
||||||
|
|
||||||
# Define LSB log_* functions.
|
|
||||||
# Depend on lsb-base (>= 3.0-6) to ensure that this file is present.
|
|
||||||
. /lib/lsb/init-functions
|
|
||||||
|
|
||||||
# If the daemon is not enabled, give the user a warning and then exit,
|
|
||||||
# unless we are stopping the daemon
|
|
||||||
if [ "$START" != "yes" -a "$1" != "stop" ]; then
|
|
||||||
log_warning_msg "To enable $NAME, edit /etc/default/$NAME and set START=yes"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Process configuration
|
|
||||||
#export ...
|
|
||||||
DAEMON_ARGS=""
|
|
||||||
|
|
||||||
|
|
||||||
do_start()
|
|
||||||
{
|
|
||||||
# Return
|
|
||||||
# 0 if daemon has been started
|
|
||||||
# 1 if daemon was already running
|
|
||||||
# 2 if daemon could not be started
|
|
||||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
|
|
||||||
|| return 1
|
|
||||||
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON \
|
|
||||||
--background --make-pidfile --chuid $EXEC_AS_USER --startas $DAEMON -- \
|
|
||||||
$DAEMON_ARGS \
|
|
||||||
|| return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
do_stop()
|
|
||||||
{
|
|
||||||
# Return
|
|
||||||
# 0 if daemon has been stopped
|
|
||||||
# 1 if daemon was already stopped
|
|
||||||
# 2 if daemon could not be stopped
|
|
||||||
# other if a failure occurred
|
|
||||||
start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE > /dev/null # --name $DAEMON
|
|
||||||
RETVAL="$?"
|
|
||||||
[ "$RETVAL" = 2 ] && return 2
|
|
||||||
# Wait for children to finish too if this is a daemon that forks
|
|
||||||
# and if the daemon is only ever run from this initscript.
|
|
||||||
# If the above conditions are not satisfied then add some other code
|
|
||||||
# that waits for the process to drop all resources that could be
|
|
||||||
# needed by services started subsequently. A last resort is to
|
|
||||||
# sleep for some time.
|
|
||||||
start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
|
|
||||||
[ "$?" = 2 ] && return 2
|
|
||||||
# Many daemons don't delete their pidfiles when they exit.
|
|
||||||
rm -f $PIDFILE
|
|
||||||
return "$RETVAL"
|
|
||||||
}
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
[ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
|
|
||||||
do_start
|
|
||||||
case "$?" in
|
|
||||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
|
||||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
[ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
|
|
||||||
do_stop
|
|
||||||
case "$?" in
|
|
||||||
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
|
||||||
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
restart|force-reload)
|
|
||||||
log_daemon_msg "Restarting $DESC" "$NAME"
|
|
||||||
do_stop
|
|
||||||
case "$?" in
|
|
||||||
0|1)
|
|
||||||
do_start
|
|
||||||
case "$?" in
|
|
||||||
0) log_end_msg 0 ;;
|
|
||||||
1) log_end_msg 1 ;; # Old process is still running
|
|
||||||
*) log_end_msg 1 ;; # Failed to start
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
# Failed to stop
|
|
||||||
log_end_msg 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
|
|
||||||
exit 3
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
|
|
||||||
<outgoingServer type="smtp">
|
<outgoingServer type="smtp">
|
||||||
<hostname>PRIMARY_HOSTNAME</hostname>
|
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||||
<port>587</port>
|
<port>465</port>
|
||||||
<socketType>STARTTLS</socketType>
|
<socketType>SSL</socketType>
|
||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<addThisServer>true</addThisServer>
|
<addThisServer>true</addThisServer>
|
||||||
<useGlobalPreferredServer>true</useGlobalPreferredServer>
|
<useGlobalPreferredServer>false</useGlobalPreferredServer>
|
||||||
</outgoingServer>
|
</outgoingServer>
|
||||||
|
|
||||||
<documentation url="https://PRIMARY_HOSTNAME/">
|
<documentation url="https://PRIMARY_HOSTNAME/">
|
||||||
|
|||||||
4
conf/mta-sts.txt
Normal file
4
conf/mta-sts.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
version: STSv1
|
||||||
|
mode: MODE
|
||||||
|
mx: PRIMARY_HOSTNAME
|
||||||
|
max_age: 604800
|
||||||
10
conf/munin.service
Normal file
10
conf/munin.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Munin System Monitoring Startup Script
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=idle
|
||||||
|
ExecStart=/usr/local/lib/mailinabox/munin_start.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
85
conf/nginx-alldomains.conf
Normal file
85
conf/nginx-alldomains.conf
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 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;
|
||||||
|
}
|
||||||
|
location = /mail/config-v1.1.xml {
|
||||||
|
alias /var/lib/mailinabox/mozilla-autoconfig.xml;
|
||||||
|
}
|
||||||
|
location = /.well-known/mta-sts.txt {
|
||||||
|
alias /var/lib/mailinabox/mta-sts.txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roundcube Webmail configuration.
|
||||||
|
rewrite ^/mail$ /mail/ redirect;
|
||||||
|
rewrite ^/mail/$ /mail/index.php;
|
||||||
|
location /mail/ {
|
||||||
|
index index.php;
|
||||||
|
alias /usr/local/lib/roundcubemail/;
|
||||||
|
}
|
||||||
|
location ~ /mail/config/.* {
|
||||||
|
# A ~-style location is needed to give this precedence over the next block.
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
location ~ /mail/.*\.php {
|
||||||
|
# note: ~ has precendence over a regular location block
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_split_path_info ^/mail(/.*)()$;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
|
||||||
|
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||||
|
# file upload limit to match the corresponding Postfix limit.
|
||||||
|
client_max_body_size 128M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Z-Push (Microsoft Exchange ActiveSync)
|
||||||
|
location /Microsoft-Server-ActiveSync {
|
||||||
|
include /etc/nginx/fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
|
||||||
|
fastcgi_param PHP_VALUE "include_path=.:/usr/share/php:/usr/share/pear:/usr/share/awl/inc";
|
||||||
|
fastcgi_read_timeout 630;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
|
||||||
|
# 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 Nextcloud (which serves user-uploaded files that might
|
||||||
|
# have this pattern, see #414) or some of the other services.
|
||||||
|
location ~ /\.(ht|svn|git|hg|bzr) {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
@@ -1,22 +1,41 @@
|
|||||||
# Control Panel
|
# Control Panel
|
||||||
# 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'.
|
||||||
|
location /admin/assets {
|
||||||
|
alias /usr/local/lib/mailinabox/vendor/assets;
|
||||||
|
}
|
||||||
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.
|
# Nextcloud configuration.
|
||||||
rewrite ^/cloud$ /cloud/ redirect;
|
rewrite ^/cloud$ /cloud/ redirect;
|
||||||
rewrite ^/cloud/$ /cloud/index.php;
|
rewrite ^/cloud/$ /cloud/index.php;
|
||||||
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
||||||
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
||||||
|
rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;
|
||||||
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;
|
deny all;
|
||||||
}
|
}
|
||||||
|
location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
# Enable paths for service and cloud federation discovery
|
||||||
|
# Resolves warning in Nextcloud Settings panel
|
||||||
|
location ~ ^/cloud/(oc[sm]-provider)?/([^/]+\.php)$ {
|
||||||
|
index index.php;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$1/$2;
|
||||||
|
fastcgi_pass php-fpm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
||||||
# note: ~ has precendence over a regular location block
|
# note: ~ has precendence over a regular location block
|
||||||
@@ -29,32 +48,29 @@
|
|||||||
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 404 /cloud/core/templates/404.php;
|
|
||||||
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. Nextcloud 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
|
||||||
|
|||||||
@@ -1,75 +1,20 @@
|
|||||||
# from: https://gist.github.com/konklone/6532544
|
# We track the Mozilla "intermediate" compatibility TLS recommendations.
|
||||||
###################################################################################
|
# Note that these settings are repeated in the SMTP and IMAP configuration.
|
||||||
|
# ssl_protocols has moved to nginx.conf in bionic, check there for enabled protocols.
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
||||||
|
|
||||||
# Basically the nginx configuration I use at konklone.com.
|
|
||||||
# I check it using https://www.ssllabs.com/ssltest/analyze.html?d=konklone.com
|
|
||||||
#
|
|
||||||
# To provide feedback, please tweet at @konklone or email eric@konklone.com.
|
|
||||||
# Comments on gists don't notify the author.
|
|
||||||
#
|
|
||||||
# Thanks to WubTheCaptain (https://wubthecaptain.eu) for his help and ciphersuites.
|
|
||||||
# Thanks to Ilya Grigorik (https://www.igvita.com) for constant inspiration.
|
|
||||||
|
|
||||||
# Path to certificate and private key.
|
|
||||||
# The .crt may omit the root CA cert, if it's a standard CA that ships with clients.
|
|
||||||
#ssl_certificate /path/to/unified.crt;
|
|
||||||
#ssl_certificate_key /path/to/my-private-decrypted.key;
|
|
||||||
|
|
||||||
# Tell browsers to require SSL (warning: difficult to change your mind)
|
|
||||||
add_header Strict-Transport-Security max-age=31536000;
|
|
||||||
|
|
||||||
# Prefer certain ciphersuites, to enforce Forward Secrecy and avoid known vulnerabilities.
|
|
||||||
#
|
|
||||||
# Forces forward secrecy in all browsers and clients that can use TLS,
|
|
||||||
# but with a small exception (DES-CBC3-SHA) for IE8/XP users.
|
|
||||||
#
|
|
||||||
# Reference client: https://www.ssllabs.com/ssltest/analyze.html
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_ciphers 'kEECDH+ECDSA+AES128 kEECDH+ECDSA+AES256 kEECDH+AES128 kEECDH+AES256 kEDH+AES128 kEDH+AES256 DES-CBC3-SHA +SHA !aNULL !eNULL !LOW !MD5 !EXP !DSS !PSK !SRP !kECDH !CAMELLIA !RC4 !SEED';
|
|
||||||
|
|
||||||
# Cut out (the old, broken) SSLv3 entirely.
|
|
||||||
# This **excludes IE6 users** and (apparently) Yandexbot.
|
|
||||||
# Just comment out if you need to support IE6, bless your soul.
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
|
|
||||||
|
|
||||||
# Turn on session resumption, using a 10 min cache shared across nginx processes,
|
|
||||||
# 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:50m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 1d;
|
||||||
keepalive_timeout 70;
|
|
||||||
|
|
||||||
# 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
|
||||||
#ssl_buffer_size 1400;
|
ssl_buffer_size 1400;
|
||||||
|
|
||||||
# SPDY header compression (0 for none, 9 for slow/heavy compression). Preferred is 6.
|
|
||||||
#
|
|
||||||
# BUT: header compression is flawed and vulnerable in SPDY versions 1 - 3.
|
|
||||||
# Disable with 0, until using a version of nginx with SPDY 4.
|
|
||||||
spdy_headers_comp 0;
|
|
||||||
|
|
||||||
# Now let's really get fancy, and pre-generate a 2048 bit random parameter
|
|
||||||
# for DH elliptic curves. If not created and specified, default is only 1024 bits.
|
|
||||||
#
|
|
||||||
# Generated by OpenSSL with the following command:
|
|
||||||
# openssl dhparam -outform pem -out dhparam2048.pem 2048
|
|
||||||
#
|
|
||||||
# Note: raising the bits to 2048 excludes Java 6 clients. Comment out if a problem.
|
|
||||||
ssl_dhparam STORAGE_ROOT/ssl/dh2048.pem;
|
|
||||||
|
|
||||||
|
|
||||||
# OCSP stapling - means nginx will poll the CA for signed OCSP responses,
|
|
||||||
# and send them to clients so clients don't make their own OCSP calls.
|
|
||||||
# http://en.wikipedia.org/wiki/OCSP_stapling
|
|
||||||
#
|
|
||||||
# while the ssl_certificate above may omit the root cert if the CA is trusted,
|
|
||||||
# ssl_trusted_certificate below must point to a chain of **all** certs
|
|
||||||
# in the trust path - (your cert, intermediary certs, root cert)
|
|
||||||
#
|
|
||||||
# 8.8.8.8 and 8.8.4.4 below are Google's public IPv4 DNS servers.
|
|
||||||
# nginx will use them to talk to the CA.
|
|
||||||
ssl_stapling on;
|
ssl_stapling 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;
|
|
||||||
|
# h/t https://gist.github.com/konklone/6532544
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
## your own --- please do not ask for help from us.
|
## your own --- please do not ask for help from us.
|
||||||
|
|
||||||
upstream php-fpm {
|
upstream php-fpm {
|
||||||
server unix:/var/run/php5-fpm.sock;
|
server unix:/var/run/php/php8.0-fpm.sock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
conf/nginx.conf
105
conf/nginx.conf
@@ -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,16 +14,25 @@ server {
|
|||||||
# error pages and in the "Server" HTTP-Header.
|
# error pages and in the "Server" HTTP-Header.
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
# Redirect using the 'return' directive and the built-in
|
location / {
|
||||||
# variable '$request_uri' to avoid any capturing, matching
|
# Redirect using the 'return' directive and the built-in
|
||||||
# or evaluation of regular expressions.
|
# variable '$request_uri' to avoid any capturing, matching
|
||||||
return 301 https://$HOSTNAME$request_uri;
|
# or evaluation of regular expressions.
|
||||||
|
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/webroot/.well-known/acme-challenge/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# The secure HTTPS server.
|
# The secure HTTPS server.
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
server_name $HOSTNAME;
|
server_name $HOSTNAME;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>this is a mail-in-a-box</title>
|
<title>this is a mail-in-a-box</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>this is a mail-in-a-box</h1>
|
<h1>this is a mail-in-a-box</h1>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
* Descr : Autodiscover configuration file
|
* Descr : Autodiscover configuration file
|
||||||
************************************************/
|
************************************************/
|
||||||
|
|
||||||
|
define('TIMEZONE', '');
|
||||||
|
|
||||||
// Defines the base path on the server
|
// Defines the base path on the server
|
||||||
define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']). '/');
|
define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']). '/');
|
||||||
|
|
||||||
// The Z-Push server location for the autodiscover response
|
define('ZPUSH_HOST', 'PRIMARY_HOSTNAME');
|
||||||
define('SERVERURL', 'https://PRIMARY_HOSTNAME/Microsoft-Server-ActiveSync');
|
|
||||||
|
|
||||||
define('USE_FULLEMAIL_FOR_LOGIN', true);
|
define('USE_FULLEMAIL_FOR_LOGIN', true);
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ define('LOGFILE', LOGFILEDIR . 'autodiscover.log');
|
|||||||
define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');
|
define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');
|
||||||
define('LOGLEVEL', LOGLEVEL_INFO);
|
define('LOGLEVEL', LOGLEVEL_INFO);
|
||||||
define('LOGUSERLEVEL', LOGLEVEL);
|
define('LOGUSERLEVEL', LOGLEVEL);
|
||||||
|
$specialLogUsers = array();
|
||||||
|
|
||||||
// the backend data provider
|
// the backend data provider
|
||||||
define('BACKEND_PROVIDER', 'BackendCombined');
|
define('BACKEND_PROVIDER', 'BackendCombined');
|
||||||
|
|||||||
@@ -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', '127.0.0.1');
|
||||||
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);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
|
|
||||||
define('CARDDAV_PROTOCOL', 'https'); /* http or https */
|
define('CARDDAV_PROTOCOL', 'https'); /* http or https */
|
||||||
define('CARDDAV_SERVER', 'localhost');
|
define('CARDDAV_SERVER', '127.0.0.1');
|
||||||
define('CARDDAV_PORT', '443');
|
define('CARDDAV_PORT', '443');
|
||||||
define('CARDDAV_PATH', '/carddav/addressbooks/%u/');
|
define('CARDDAV_PATH', '/carddav/addressbooks/%u/');
|
||||||
define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdirectory of the main path */
|
define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdirectory of the main path */
|
||||||
@@ -17,7 +17,7 @@ define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
|||||||
define('CARDDAV_SUPPORTS_SYNC', false);
|
define('CARDDAV_SUPPORTS_SYNC', false);
|
||||||
|
|
||||||
// If the CardDAV server supports the FN attribute for searches
|
// If the CardDAV server supports the FN attribute for searches
|
||||||
// DAViCal supports it, but SabreDav, Owncloud and SOGo don't
|
// DAViCal supports it, but SabreDav, Nextcloud and SOGo don't
|
||||||
// Setting this to true will search by FN. If false will search by sn, givenName and email
|
// Setting this to true will search by FN. If false will search by sn, givenName and email
|
||||||
// It's safe to leave it as false
|
// It's safe to leave it as false
|
||||||
define('CARDDAV_SUPPORTS_FN_SEARCH', false);
|
define('CARDDAV_SUPPORTS_FN_SEARCH', false);
|
||||||
|
|||||||
@@ -5,19 +5,37 @@
|
|||||||
* Descr : IMAP backend configuration file
|
* Descr : IMAP backend configuration file
|
||||||
************************************************/
|
************************************************/
|
||||||
|
|
||||||
define('IMAP_SERVER', 'localhost');
|
define('IMAP_SERVER', '127.0.0.1');
|
||||||
define('IMAP_PORT', 993);
|
define('IMAP_PORT', 993);
|
||||||
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
||||||
define('IMAP_DEFAULTFROM', '');
|
define('IMAP_DEFAULTFROM', 'sql');
|
||||||
|
|
||||||
// not used
|
define('SYSTEM_MIME_TYPES_MAPPING', '/etc/mime.types');
|
||||||
define('IMAP_FROM_SQL_DSN', '');
|
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');
|
||||||
|
|
||||||
|
define('IMAP_INLINE_FORWARD', true);
|
||||||
|
define('IMAP_EXCLUDED_FOLDERS', '');
|
||||||
|
|
||||||
|
define('IMAP_FROM_SQL_DSN', 'sqlite:STORAGE_ROOT/mail/roundcube/roundcube.sqlite');
|
||||||
define('IMAP_FROM_SQL_USER', '');
|
define('IMAP_FROM_SQL_USER', '');
|
||||||
define('IMAP_FROM_SQL_PASSWORD', '');
|
define('IMAP_FROM_SQL_PASSWORD', '');
|
||||||
define('IMAP_FROM_SQL_OPTIONS', serialize(array(PDO::ATTR_PERSISTENT => true)));
|
define('IMAP_FROM_SQL_OPTIONS', serialize(array(PDO::ATTR_PERSISTENT => true)));
|
||||||
define('IMAP_FROM_SQL_QUERY', "select first_name, last_name, mail_address from users where mail_address = '#username@#domain'");
|
define('IMAP_FROM_SQL_QUERY', "SELECT name, email FROM identities i INNER JOIN users u ON i.user_id = u.user_id WHERE u.username = '#username' AND i.standard = 1 AND i.del = 0 AND i.name <> ''");
|
||||||
define('IMAP_FROM_SQL_FIELDS', serialize(array('first_name', 'last_name', 'mail_address')));
|
define('IMAP_FROM_SQL_FIELDS', serialize(array('name', 'email')));
|
||||||
define('IMAP_FROM_SQL_FROM', '#first_name #last_name <#mail_address>');
|
define('IMAP_FROM_SQL_FROM', '#name <#email>');
|
||||||
|
define('IMAP_FROM_SQL_FULLNAME', '#name');
|
||||||
|
|
||||||
|
// not used
|
||||||
define('IMAP_FROM_LDAP_SERVER', '');
|
define('IMAP_FROM_LDAP_SERVER', '');
|
||||||
define('IMAP_FROM_LDAP_SERVER_PORT', '389');
|
define('IMAP_FROM_LDAP_SERVER_PORT', '389');
|
||||||
define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org');
|
define('IMAP_FROM_LDAP_USER', 'cn=zpush,ou=servers,dc=zpush,dc=org');
|
||||||
@@ -26,17 +44,14 @@ define('IMAP_FROM_LDAP_BASE', 'dc=zpush,dc=org');
|
|||||||
define('IMAP_FROM_LDAP_QUERY', '(mail=#username@#domain)');
|
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>');
|
||||||
|
define('IMAP_FROM_LDAP_FULLNAME', '#givenname #sn');
|
||||||
|
|
||||||
|
|
||||||
// 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;
|
||||||
$imap_smtp_params = array('host' => 'ssl://localhost', 'port' => 587, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
|
$imap_smtp_params = array('host' => 'ssl://127.0.0.1', 'port' => 465, 'auth' => true, 'username' => 'imap_username', 'password' => 'imap_password');
|
||||||
|
|
||||||
define('MAIL_MIMEPART_CRLF', "\r\n");
|
define('MAIL_MIMEPART_CRLF', "\r\n");
|
||||||
|
define('IMAP_MEETING_USE_CALDAV', true);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -1,129 +1,154 @@
|
|||||||
import base64, os, os.path, hmac
|
import base64, os, os.path, hmac, json, secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from flask import make_response
|
from expiringdict import ExpiringDict
|
||||||
|
|
||||||
import utils
|
import utils
|
||||||
from mailconfig import get_mail_password, get_mail_user_privileges
|
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||||
|
from mfa import get_hash_mfa_state, validate_auth_mfa
|
||||||
|
|
||||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||||
|
|
||||||
class KeyAuthService:
|
class AuthService:
|
||||||
"""Generate an API key for authenticating clients
|
|
||||||
|
|
||||||
Clients must read the key from the key file and send the key with all HTTP
|
|
||||||
requests. The key is passed as the username field in the standard HTTP
|
|
||||||
Basic Auth header.
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.auth_realm = DEFAULT_AUTH_REALM
|
self.auth_realm = DEFAULT_AUTH_REALM
|
||||||
self.key = self._generate_key()
|
|
||||||
self.key_path = DEFAULT_KEY_PATH
|
self.key_path = DEFAULT_KEY_PATH
|
||||||
|
self.max_session_duration = timedelta(days=2)
|
||||||
|
|
||||||
def write_key(self):
|
self.init_system_api_key()
|
||||||
"""Write key to file so authorized clients can get the key
|
self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds())
|
||||||
|
|
||||||
The key file is created with mode 0640 so that additional users can be
|
def init_system_api_key(self):
|
||||||
authorized to access the API by granting group/ACL read permissions on
|
"""Write an API key to a local file so local processes can use the API"""
|
||||||
the key file.
|
|
||||||
"""
|
|
||||||
def create_file_with_mode(path, mode):
|
|
||||||
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
|
|
||||||
old_umask = os.umask(0)
|
|
||||||
try:
|
|
||||||
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
|
|
||||||
finally:
|
|
||||||
os.umask(old_umask)
|
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
|
with open(self.key_path, 'r') as file:
|
||||||
|
self.key = file.read()
|
||||||
|
|
||||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
def authenticate(self, request, env, login_only=False, logout=False):
|
||||||
key_file.write(self.key + '\n')
|
"""Test if the HTTP Authorization header's username matches the system key, a session key,
|
||||||
|
or if the username/password passed in the header matches a local user.
|
||||||
def authenticate(self, request, env):
|
|
||||||
"""Test if the client key passed in HTTP Authorization header matches the service key
|
|
||||||
or if the or username/password passed in the header matches an administrator user.
|
|
||||||
Returns a tuple of the user's email address and list of user privileges (e.g.
|
Returns a tuple of the user's email address and list of user privileges (e.g.
|
||||||
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
|
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
|
||||||
If the user used an API key, the user's email is returned as None."""
|
If the user used the system API key, the user's email is returned as None since
|
||||||
|
this key is not associated with a user."""
|
||||||
|
|
||||||
def decode(s):
|
def parse_http_authorization_basic(header):
|
||||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
def decode(s):
|
||||||
|
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||||
def parse_basic_auth(header):
|
|
||||||
if " " not in header:
|
if " " not in header:
|
||||||
return None, None
|
return None, None
|
||||||
scheme, credentials = header.split(maxsplit=1)
|
scheme, credentials = header.split(maxsplit=1)
|
||||||
if scheme != 'Basic':
|
if scheme != 'Basic':
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
credentials = decode(credentials)
|
credentials = decode(credentials)
|
||||||
if ":" not in credentials:
|
if ":" not in credentials:
|
||||||
return None, None
|
return None, None
|
||||||
username, password = credentials.split(':', maxsplit=1)
|
username, password = credentials.split(':', maxsplit=1)
|
||||||
return username, password
|
return username, password
|
||||||
|
|
||||||
header = request.headers.get('Authorization')
|
username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
|
||||||
if not header:
|
|
||||||
raise ValueError("No authorization header provided.")
|
|
||||||
|
|
||||||
username, password = parse_basic_auth(header)
|
|
||||||
|
|
||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
raise ValueError("Authorization header invalid.")
|
raise ValueError("Authorization header invalid.")
|
||||||
elif username == self.key:
|
|
||||||
# The user passed the API key which grants administrative privs.
|
if username.strip() == "" and password.strip() == "":
|
||||||
|
raise ValueError("No email address, password, session key, or API key provided.")
|
||||||
|
|
||||||
|
# If user passed the system API key, grant administrative privs. This key
|
||||||
|
# is not associated with a user.
|
||||||
|
if username == self.key and not login_only:
|
||||||
return (None, ["admin"])
|
return (None, ["admin"])
|
||||||
|
|
||||||
|
# If the password corresponds with a session token for the user, grant access for that user.
|
||||||
|
if self.get_session(username, password, "login", env) and not login_only:
|
||||||
|
sessionid = password
|
||||||
|
session = self.sessions[sessionid]
|
||||||
|
if logout:
|
||||||
|
# Clear the session.
|
||||||
|
del self.sessions[sessionid]
|
||||||
|
else:
|
||||||
|
# Re-up the session so that it does not expire.
|
||||||
|
self.sessions[sessionid] = session
|
||||||
|
|
||||||
|
# If no password was given, but a username was given, we're missing some information.
|
||||||
|
elif password.strip() == "":
|
||||||
|
raise ValueError("Enter a password.")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# The user is trying to log in with a username and user-specific
|
# The user is trying to log in with a username and a password
|
||||||
# API key or password. Raises or returns privs.
|
# (and possibly a MFA token). On failure, an exception is raised.
|
||||||
return (username, self.get_user_credentials(username, password, env))
|
self.check_user_auth(username, password, request, env)
|
||||||
|
|
||||||
def get_user_credentials(self, email, pw, env):
|
# Get privileges for authorization. This call should never fail because by this
|
||||||
# Validate a user's credentials. On success returns a list of
|
# point we know the email address is a valid user --- unless the user has been
|
||||||
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
|
# deleted after the session was granted. On error the call will return a tuple
|
||||||
# with a login error message.
|
# of an error message and an HTTP status code.
|
||||||
|
privs = get_mail_user_privileges(username, env)
|
||||||
# Sanity check.
|
|
||||||
if email == "" or pw == "":
|
|
||||||
raise ValueError("Enter an email address and password.")
|
|
||||||
|
|
||||||
# The password might be a user-specific API key.
|
|
||||||
if hmac.compare_digest(self.create_user_key(email), pw):
|
|
||||||
# OK.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Get the hashed password of the user. Raise a ValueError if the
|
|
||||||
# email address does not correspond to a user.
|
|
||||||
pw_hash = get_mail_password(email, env)
|
|
||||||
|
|
||||||
# Authenticate.
|
|
||||||
try:
|
|
||||||
# Use 'doveadm pw' to check credentials. doveadm will return
|
|
||||||
# a non-zero exit status if the credentials are no good,
|
|
||||||
# and check_call will raise an exception in that case.
|
|
||||||
utils.shell('check_call', [
|
|
||||||
"/usr/bin/doveadm", "pw",
|
|
||||||
"-p", pw,
|
|
||||||
"-t", pw_hash,
|
|
||||||
])
|
|
||||||
except:
|
|
||||||
# Login failed.
|
|
||||||
raise ValueError("Invalid password.")
|
|
||||||
|
|
||||||
# Get privileges for authorization. This call should never fail on a valid user,
|
|
||||||
# but if the caller passed a user-specific API key then the user may no longer
|
|
||||||
# exist --- in that case, get_mail_user_privileges will return a tuple of an
|
|
||||||
# error message and an HTTP status code.
|
|
||||||
privs = get_mail_user_privileges(email, env)
|
|
||||||
if isinstance(privs, tuple): raise ValueError(privs[0])
|
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||||
|
|
||||||
# Return a list of privileges.
|
# Return the authorization information.
|
||||||
return privs
|
return (username, privs)
|
||||||
|
|
||||||
def create_user_key(self, email):
|
def check_user_auth(self, email, pw, request, env):
|
||||||
return hmac.new(self.key.encode('ascii'), b"AUTH:" + email.encode("utf8"), digestmod="sha1").hexdigest()
|
# Validate a user's login email address and password. If MFA is enabled,
|
||||||
|
# check the MFA token in the X-Auth-Token header.
|
||||||
|
#
|
||||||
|
# On login failure, raises a ValueError with a login error message. On
|
||||||
|
# success, nothing is returned.
|
||||||
|
|
||||||
def _generate_key(self):
|
# Authenticate.
|
||||||
raw_key = os.urandom(32)
|
try:
|
||||||
return base64.b64encode(raw_key).decode('ascii')
|
# Get the hashed password of the user. Raise a ValueError if the
|
||||||
|
# email address does not correspond to a user. But wrap it in the
|
||||||
|
# same exception as if a password fails so we don't easily reveal
|
||||||
|
# if an email address is valid.
|
||||||
|
pw_hash = get_mail_password(email, env)
|
||||||
|
|
||||||
|
# Use 'doveadm pw' to check credentials. doveadm will return
|
||||||
|
# a non-zero exit status if the credentials are no good,
|
||||||
|
# and check_call will raise an exception in that case.
|
||||||
|
utils.shell('check_call', [
|
||||||
|
"/usr/bin/doveadm", "pw",
|
||||||
|
"-p", pw,
|
||||||
|
"-t", pw_hash,
|
||||||
|
])
|
||||||
|
except:
|
||||||
|
# Login failed.
|
||||||
|
raise ValueError("Incorrect email address or password.")
|
||||||
|
|
||||||
|
# If MFA is enabled, check that MFA passes.
|
||||||
|
status, hints = validate_auth_mfa(email, request, env)
|
||||||
|
if not status:
|
||||||
|
# Login valid. Hints may have more info.
|
||||||
|
raise ValueError(",".join(hints))
|
||||||
|
|
||||||
|
def create_user_password_state_token(self, email, env):
|
||||||
|
# Create a token that changes if the user's password or MFA options change
|
||||||
|
# so that sessions become invalid if any of that information changes.
|
||||||
|
msg = get_mail_password(email, env).encode("utf8")
|
||||||
|
|
||||||
|
# Add to the message the current MFA state, which is a list of MFA information.
|
||||||
|
# Turn it into a string stably.
|
||||||
|
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
|
||||||
|
|
||||||
|
# Make a HMAC using the system API key as a hash key.
|
||||||
|
hash_key = self.key.encode('ascii')
|
||||||
|
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
|
||||||
|
|
||||||
|
def create_session_key(self, username, env, type=None):
|
||||||
|
# Create a new session.
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
self.sessions[token] = {
|
||||||
|
"email": username,
|
||||||
|
"password_token": self.create_user_password_state_token(username, env),
|
||||||
|
"type": type,
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
|
||||||
|
def get_session(self, user_email, session_key, session_type, env):
|
||||||
|
if session_key not in self.sessions: return None
|
||||||
|
session = self.sessions[session_key]
|
||||||
|
if session_type == "login" and session["email"] != user_email: return None
|
||||||
|
if session["type"] != session_type: return None
|
||||||
|
if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None
|
||||||
|
return session
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
|
||||||
# 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) STORAGE_ROOT/backup/before-backup is executed if it exists.
|
||||||
# directory STORAGE_ROOT/backup/encrypted. The password used for
|
# 3) An incremental encrypted backup is made using duplicity.
|
||||||
# encryption is stored in backup/secret_key.txt.
|
# 4) The stopped services are restarted.
|
||||||
# 3) The stopped services are restarted.
|
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
|
||||||
# 5) 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 exclusiveprocess import Lock
|
||||||
|
|
||||||
from utils import exclusive_process, load_environment, shell, wait_for_service
|
from utils import load_environment, shell, wait_for_service
|
||||||
|
|
||||||
# Destroy backups when the most recent increment in the chain
|
|
||||||
# that depends on it is this many days old.
|
|
||||||
keep_backups_for_days = 3
|
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
# What is the current status of backups?
|
# If backups are dissbled, return no status.
|
||||||
# Loop through all of the files in STORAGE_ROOT/backup/encrypted to
|
config = get_backup_config(env)
|
||||||
# get a list of all of the backups taken and sum up file sizes to
|
if config["target"] == "off":
|
||||||
# see how large the storage is.
|
return { }
|
||||||
|
|
||||||
|
# Query duplicity to get a list of all full and incremental
|
||||||
|
# backups available.
|
||||||
|
|
||||||
|
backups = { }
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
|
||||||
def reldate(date, ref, clip):
|
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)
|
||||||
|
if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months)
|
||||||
|
if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months)
|
||||||
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
||||||
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
|
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
|
||||||
if rd.days >= 7: return "%d days" % rd.days
|
if rd.days >= 7: return "%d days" % rd.days
|
||||||
@@ -34,78 +40,118 @@ 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 {
|
||||||
|
"date": keys[1],
|
||||||
|
"date_str": date.strftime("%Y-%m-%d %X") + " " + now.tzname(),
|
||||||
|
"date_delta": reldate(date, now, "the future?"),
|
||||||
|
"full": keys[0] == "full",
|
||||||
|
"size": 0, # collection-status doesn't give us the size
|
||||||
|
"volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, collection_status = shell('check_output', [
|
||||||
|
"/usr/bin/duplicity",
|
||||||
|
"collection-status",
|
||||||
|
"--archive-dir", backup_cache_dir,
|
||||||
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
|
"--log-fd", "1",
|
||||||
|
get_duplicity_target_url(config),
|
||||||
|
] + get_duplicity_additional_args(env),
|
||||||
|
get_duplicity_env_vars(env),
|
||||||
|
trap=True)
|
||||||
|
if code != 0:
|
||||||
|
# Command failed. This is likely due to an improperly configured remote
|
||||||
|
# destination for the backups or the last backup job terminated unexpectedly.
|
||||||
|
raise Exception("Something is wrong with the backup: " + collection_status)
|
||||||
|
for line in collection_status.split('\n'):
|
||||||
|
if line.startswith(" full") or line.startswith(" inc"):
|
||||||
|
backup = parse_line(line)
|
||||||
|
backups[backup["date"]] = backup
|
||||||
|
|
||||||
|
# Look at the target directly to get the sizes of each of the backups. There is more than one file per backup.
|
||||||
|
# Starting with duplicity in Ubuntu 18.04, "signatures" files have dates in their
|
||||||
|
# filenames that are a few seconds off the backup date and so don't line up
|
||||||
|
# with the list of backups we have. Track unmatched files so we know how much other
|
||||||
|
# space is used for those.
|
||||||
|
unmatched_file_size = 0
|
||||||
|
for fn, size in list_target_files(config):
|
||||||
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
||||||
if not m: raise ValueError(fn)
|
if not m: continue # not a part of a current backup chain
|
||||||
|
|
||||||
key = m.group("date")
|
key = m.group("date")
|
||||||
if key not in backups:
|
if key in backups:
|
||||||
date = dateutil.parser.parse(m.group("date"))
|
backups[key]["size"] += size
|
||||||
backups[key] = {
|
else:
|
||||||
"date": m.group("date"),
|
unmatched_file_size += size
|
||||||
"date_str": date.strftime("%x %X"),
|
|
||||||
"date_delta": reldate(date, now, "the future?"),
|
|
||||||
"full": m.group("incbase") is None,
|
|
||||||
"previous": m.group("incbase"),
|
|
||||||
"size": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
backups[key]["size"] += os.path.getsize(os.path.join(backup_dir, fn))
|
|
||||||
|
|
||||||
# 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 incremental_size > 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,
|
||||||
|
"unmatched_file_size": unmatched_file_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
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,68 +163,134 @@ 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 perform_backup(full_backup):
|
def get_passphrase(env):
|
||||||
env = load_environment()
|
|
||||||
|
|
||||||
exclusive_process("backup")
|
|
||||||
|
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
|
||||||
backup_dir = os.path.join(backup_root, 'encrypted')
|
|
||||||
|
|
||||||
# In an older version of this script, duplicity was called
|
|
||||||
# such that it did not encrypt the backups it created (in
|
|
||||||
# backup/duplicity), and instead openssl was called separately
|
|
||||||
# after each backup run, creating AES256 encrypted copies of
|
|
||||||
# each file created by duplicity in backup/encrypted.
|
|
||||||
#
|
|
||||||
# We detect the transition by the presence of backup/duplicity
|
|
||||||
# and handle it by 'dupliception': we move all the old *un*encrypted
|
|
||||||
# duplicity files up out of the backup/duplicity directory (as
|
|
||||||
# backup/ is excluded from duplicity runs) in order that it is
|
|
||||||
# included in the next run, and we delete backup/encrypted (which
|
|
||||||
# duplicity will output files directly to, post-transition).
|
|
||||||
old_backup_dir = os.path.join(backup_root, 'duplicity')
|
|
||||||
migrated_unencrypted_backup_dir = os.path.join(env["STORAGE_ROOT"], "migrated_unencrypted_backup")
|
|
||||||
if os.path.isdir(old_backup_dir):
|
|
||||||
# Move the old unencrypted files to a new location outside of
|
|
||||||
# the backup root so they get included in the next (new) backup.
|
|
||||||
# Then we'll delete them. Also so that they do not get in the
|
|
||||||
# way of duplicity doing a full backup on the first run after
|
|
||||||
# we take care of this.
|
|
||||||
shutil.move(old_backup_dir, migrated_unencrypted_backup_dir)
|
|
||||||
|
|
||||||
# The backup_dir (backup/encrypted) now has a new purpose.
|
|
||||||
# Clear it out.
|
|
||||||
shutil.rmtree(backup_dir)
|
|
||||||
|
|
||||||
# On the first run, always do a full backup. Incremental
|
|
||||||
# will fail. Otherwise do a full backup when the size of
|
|
||||||
# the increments since the most recent full backup are
|
|
||||||
# large.
|
|
||||||
full_backup = full_backup or should_force_full(env)
|
|
||||||
|
|
||||||
# Stop services.
|
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
|
||||||
|
|
||||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||||
# bits base64-encoded and with line breaks every 65 characters.
|
# bits base64-encoded and with line breaks every 65 characters.
|
||||||
# gpg will only take the first line of text, so sanity check that
|
# gpg will only take the first line of text, so sanity check that
|
||||||
# that line is long enough to be a reasonable passphrase. It
|
# that line is long enough to be a reasonable passphrase. It
|
||||||
# only needs to be 43 base64-characters to match AES256's key
|
# only needs to be 43 base64-characters to match AES256's key
|
||||||
# length of 32 bytes.
|
# 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:
|
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||||
passphrase = f.readline().strip()
|
passphrase = f.readline().strip()
|
||||||
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
||||||
env_with_passphrase = { "PASSPHRASE" : passphrase }
|
|
||||||
|
return passphrase
|
||||||
|
|
||||||
|
def get_duplicity_target_url(config):
|
||||||
|
target = config["target"]
|
||||||
|
|
||||||
|
if get_target_type(config) == "s3":
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
target = list(urlsplit(target))
|
||||||
|
|
||||||
|
# Although we store the S3 hostname in the target URL,
|
||||||
|
# duplicity no longer accepts it in the target URL. The hostname in
|
||||||
|
# the target URL must be the bucket name. The hostname is passed
|
||||||
|
# via get_duplicity_additional_args. Move the first part of the
|
||||||
|
# path (the bucket name) into the hostname URL component, and leave
|
||||||
|
# the rest for the path.
|
||||||
|
target[1], target[2] = target[2].lstrip('/').split('/', 1)
|
||||||
|
|
||||||
|
target = urlunsplit(target)
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
def get_duplicity_additional_args(env):
|
||||||
|
config = get_backup_config(env)
|
||||||
|
|
||||||
|
if get_target_type(config) == 'rsync':
|
||||||
|
return [
|
||||||
|
"--ssh-options= -i /root/.ssh/id_rsa_miab",
|
||||||
|
"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p 22 -i /root/.ssh/id_rsa_miab\"",
|
||||||
|
]
|
||||||
|
elif get_target_type(config) == 's3':
|
||||||
|
# See note about hostname in get_duplicity_target_url.
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
target = urlsplit(config["target"])
|
||||||
|
endpoint_url = urlunsplit(("https", target.netloc, '', '', ''))
|
||||||
|
return ["--s3-endpoint-url", endpoint_url]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_duplicity_env_vars(env):
|
||||||
|
config = get_backup_config(env)
|
||||||
|
|
||||||
|
env = { "PASSPHRASE" : get_passphrase(env) }
|
||||||
|
|
||||||
|
if get_target_type(config) == 's3':
|
||||||
|
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
|
||||||
|
env["AWS_SECRET_ACCESS_KEY"] = config["target_pass"]
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
def get_target_type(config):
|
||||||
|
protocol = config["target"].split(":")[0]
|
||||||
|
return protocol
|
||||||
|
|
||||||
|
def perform_backup(full_backup):
|
||||||
|
env = load_environment()
|
||||||
|
|
||||||
|
# Create an global exclusive lock so that the backup script
|
||||||
|
# cannot be run more than one.
|
||||||
|
Lock(die=True).forever()
|
||||||
|
|
||||||
|
config = get_backup_config(env)
|
||||||
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
backup_dir = os.path.join(backup_root, 'encrypted')
|
||||||
|
|
||||||
|
# Are backups disabled?
|
||||||
|
if config["target"] == "off":
|
||||||
|
return
|
||||||
|
|
||||||
|
# On the first run, always do a full backup. Incremental
|
||||||
|
# will fail. Otherwise do a full backup when the size of
|
||||||
|
# the increments since the most recent full backup are
|
||||||
|
# large.
|
||||||
|
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.
|
||||||
|
def service_command(service, command, quit=None):
|
||||||
|
# Execute silently, but if there is an error then display the output & exit.
|
||||||
|
code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
|
||||||
|
if code != 0:
|
||||||
|
print(ret)
|
||||||
|
if quit:
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
|
service_command("php8.0-fpm", "stop", quit=True)
|
||||||
|
service_command("postfix", "stop", quit=True)
|
||||||
|
service_command("dovecot", "stop", quit=True)
|
||||||
|
service_command("postgrey", "stop", quit=True)
|
||||||
|
|
||||||
|
# Execute a pre-backup script that copies files outside the homedir.
|
||||||
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
|
# environment variables so the script has access to STORAGE_ROOT.
|
||||||
|
pre_script = os.path.join(backup_root, 'before-backup')
|
||||||
|
if os.path.exists(pre_script):
|
||||||
|
shell('check_call',
|
||||||
|
['su', env['STORAGE_USER'], '-c', pre_script, config["target"]],
|
||||||
|
env=env)
|
||||||
|
|
||||||
# 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,35 +299,35 @@ 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,
|
get_duplicity_target_url(config),
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
],
|
] + get_duplicity_additional_args(env),
|
||||||
env_with_passphrase)
|
get_duplicity_env_vars(env))
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
service_command("postgrey", "start", quit=False)
|
||||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
service_command("dovecot", "start", quit=False)
|
||||||
|
service_command("postfix", "start", quit=False)
|
||||||
# Once the migrated backup is included in a new backup, it can be deleted.
|
service_command("php8.0-fpm", "start", quit=False)
|
||||||
if os.path.isdir(migrated_unencrypted_backup_dir):
|
|
||||||
shutil.rmtree(migrated_unencrypted_backup_dir)
|
|
||||||
|
|
||||||
# Remove old backups. This deletes all backup data no longer needed
|
# Remove old backups. This deletes all backup data no longer needed
|
||||||
# from more than 3 days ago.
|
# from more than 3 days ago.
|
||||||
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
|
get_duplicity_target_url(config)
|
||||||
],
|
] + get_duplicity_additional_args(env),
|
||||||
env_with_passphrase)
|
get_duplicity_env_vars(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,15 +337,17 @@ 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
|
get_duplicity_target_url(config)
|
||||||
],
|
] + get_duplicity_additional_args(env),
|
||||||
env_with_passphrase)
|
get_duplicity_env_vars(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.
|
||||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
if get_target_type(config) == 'file':
|
||||||
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# Execute a post-backup script that does the copying to a remote server.
|
||||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
@@ -241,7 +355,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 +368,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 +378,192 @@ 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,
|
get_duplicity_target_url(config),
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
], env_with_passphrase)
|
] + get_duplicity_additional_args(env), get_duplicity_env_vars(env))
|
||||||
|
|
||||||
|
def run_duplicity_restore(args):
|
||||||
|
env = load_environment()
|
||||||
|
config = get_backup_config(env)
|
||||||
|
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
|
||||||
|
shell('check_call', [
|
||||||
|
"/usr/bin/duplicity",
|
||||||
|
"restore",
|
||||||
|
"--archive-dir", backup_cache_dir,
|
||||||
|
get_duplicity_target_url(config),
|
||||||
|
] + get_duplicity_additional_args(env) + args,
|
||||||
|
get_duplicity_env_vars(env))
|
||||||
|
|
||||||
|
def list_target_files(config):
|
||||||
|
import urllib.parse
|
||||||
|
try:
|
||||||
|
target = urllib.parse.urlparse(config["target"])
|
||||||
|
except ValueError:
|
||||||
|
return "invalid target"
|
||||||
|
|
||||||
|
if target.scheme == "file":
|
||||||
|
return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]
|
||||||
|
|
||||||
|
elif target.scheme == "rsync":
|
||||||
|
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
||||||
|
rsync_target = '{host}:{path}'
|
||||||
|
|
||||||
|
target_path = target.path
|
||||||
|
if not target_path.endswith('/'):
|
||||||
|
target_path = target_path + '/'
|
||||||
|
if target_path.startswith('/'):
|
||||||
|
target_path = target_path[1:]
|
||||||
|
|
||||||
|
rsync_command = [ 'rsync',
|
||||||
|
'-e',
|
||||||
|
'/usr/bin/ssh -i /root/.ssh/id_rsa_miab -oStrictHostKeyChecking=no -oBatchMode=yes',
|
||||||
|
'--list-only',
|
||||||
|
'-r',
|
||||||
|
rsync_target.format(
|
||||||
|
host=target.netloc,
|
||||||
|
path=target_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
code, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True)
|
||||||
|
if code == 0:
|
||||||
|
ret = []
|
||||||
|
for l in listing.split('\n'):
|
||||||
|
match = rsync_fn_size_re.match(l)
|
||||||
|
if match:
|
||||||
|
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
|
||||||
|
return ret
|
||||||
|
else:
|
||||||
|
if 'Permission denied (publickey).' in listing:
|
||||||
|
reason = "Invalid user or check you correctly copied the SSH key."
|
||||||
|
elif 'No such file or directory' in listing:
|
||||||
|
reason = "Provided path {} is invalid.".format(target_path)
|
||||||
|
elif 'Network is unreachable' in listing:
|
||||||
|
reason = "The IP address {} is unreachable.".format(target.hostname)
|
||||||
|
elif 'Could not resolve hostname' in listing:
|
||||||
|
reason = "The hostname {} cannot be resolved.".format(target.hostname)
|
||||||
|
else:
|
||||||
|
reason = "Unknown error." \
|
||||||
|
"Please check running 'management/backup.py --verify'" \
|
||||||
|
"from mailinabox sources to debug the issue."
|
||||||
|
raise ValueError("Connection to rsync host failed: {}".format(reason))
|
||||||
|
|
||||||
|
elif target.scheme == "s3":
|
||||||
|
import boto3.s3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
# separate bucket from path in target
|
||||||
|
bucket = target.path[1:].split('/')[0]
|
||||||
|
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
s3 = boto3.client('s3', \
|
||||||
|
endpoint_url=f'https://{target.hostname}', \
|
||||||
|
aws_access_key_id=config['target_user'], \
|
||||||
|
aws_secret_access_key=config['target_pass'])
|
||||||
|
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path)['Contents']
|
||||||
|
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
|
||||||
|
except ClientError as e:
|
||||||
|
raise ValueError(e)
|
||||||
|
return backup_list
|
||||||
|
elif target.scheme == 'b2':
|
||||||
|
from b2sdk.v1 import InMemoryAccountInfo, B2Api
|
||||||
|
from b2sdk.v1.exception import NonExistentBucket
|
||||||
|
info = InMemoryAccountInfo()
|
||||||
|
b2_api = B2Api(info)
|
||||||
|
|
||||||
|
# Extract information from target
|
||||||
|
b2_application_keyid = target.netloc[:target.netloc.index(':')]
|
||||||
|
b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]
|
||||||
|
b2_bucket = target.netloc[target.netloc.index('@')+1:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
b2_api.authorize_account("production", b2_application_keyid, b2_application_key)
|
||||||
|
bucket = b2_api.get_bucket_by_name(b2_bucket)
|
||||||
|
except NonExistentBucket as e:
|
||||||
|
raise ValueError("B2 Bucket does not exist. Please double check your information!")
|
||||||
|
return [(key.file_name, key.size) for key, _ in bucket.ls()]
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(config["target"])
|
||||||
|
|
||||||
|
|
||||||
|
def backup_set_custom(env, target, target_user, target_pass, min_age):
|
||||||
|
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"]
|
||||||
|
ssh_pub_key = os.path.join('/root', '.ssh', 'id_rsa_miab.pub')
|
||||||
|
if os.path.exists(ssh_pub_key):
|
||||||
|
config["ssh_pub_key"] = open(ssh_pub_key, 'r').read()
|
||||||
|
|
||||||
|
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 +571,23 @@ 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] == "--list":
|
||||||
|
# List the saved backup files.
|
||||||
|
for fn, size in list_target_files(get_backup_config(load_environment())):
|
||||||
|
print("{}\t{}".format(fn, size))
|
||||||
|
|
||||||
|
elif sys.argv[-1] == "--status":
|
||||||
|
# Show backup status.
|
||||||
|
ret = backup_status(load_environment())
|
||||||
|
print(rtyaml.dump(ret["backups"]))
|
||||||
|
print("Storage for unmatched files:", ret["unmatched_file_size"])
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
150
management/cli.py
Executable file
150
management/cli.py
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
#
|
||||||
|
# This is a command-line script for calling management APIs
|
||||||
|
# on the Mail-in-a-Box control panel backend. The script
|
||||||
|
# reads /var/lib/mailinabox/api.key for the backend's
|
||||||
|
# root API key. This file is readable only by root, so this
|
||||||
|
# tool can only be used as root.
|
||||||
|
|
||||||
|
import sys, getpass, urllib.request, urllib.error, json, re, csv
|
||||||
|
|
||||||
|
def mgmt(cmd, data=None, is_json=False):
|
||||||
|
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||||
|
mgmt_uri = 'http://127.0.0.1:10222'
|
||||||
|
|
||||||
|
setup_key_auth(mgmt_uri)
|
||||||
|
|
||||||
|
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
try:
|
||||||
|
print(e.read().decode("utf8"))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
|
||||||
|
elif hasattr(e, 'read'):
|
||||||
|
print(e.read().decode('utf8'), file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
resp = response.read().decode('utf8')
|
||||||
|
if is_json: resp = json.loads(resp)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def read_password():
|
||||||
|
while True:
|
||||||
|
first = getpass.getpass('password: ')
|
||||||
|
if len(first) < 8:
|
||||||
|
print("Passwords must be at least eight characters.")
|
||||||
|
continue
|
||||||
|
second = getpass.getpass(' (again): ')
|
||||||
|
if first != second:
|
||||||
|
print("Passwords not the same. Try again.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return first
|
||||||
|
|
||||||
|
def setup_key_auth(mgmt_uri):
|
||||||
|
key = open('/var/lib/mailinabox/api.key').read().strip()
|
||||||
|
|
||||||
|
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||||
|
auth_handler.add_password(
|
||||||
|
realm='Mail-in-a-Box Management Server',
|
||||||
|
uri=mgmt_uri,
|
||||||
|
user=key,
|
||||||
|
passwd='')
|
||||||
|
opener = urllib.request.build_opener(auth_handler)
|
||||||
|
urllib.request.install_opener(opener)
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("""Usage:
|
||||||
|
{cli} user (lists users)
|
||||||
|
{cli} user add user@domain.com [password]
|
||||||
|
{cli} user password user@domain.com [password]
|
||||||
|
{cli} user remove user@domain.com
|
||||||
|
{cli} user make-admin user@domain.com
|
||||||
|
{cli} user remove-admin user@domain.com
|
||||||
|
{cli} user admins (lists admins)
|
||||||
|
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
|
||||||
|
{cli} user mfa disable user@domain.com [id] (disables MFA for user)
|
||||||
|
{cli} alias (lists aliases)
|
||||||
|
{cli} alias add incoming.name@domain.com sent.to@other.domain.com
|
||||||
|
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
|
||||||
|
{cli} alias remove incoming.name@domain.com
|
||||||
|
|
||||||
|
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
|
||||||
|
""".format(
|
||||||
|
cli="management/cli.py"
|
||||||
|
))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||||
|
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||||
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
|
if user['status'] == 'inactive': continue
|
||||||
|
print(user['email'], end='')
|
||||||
|
if "admin" in user['privileges']:
|
||||||
|
print("*", end='')
|
||||||
|
print()
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||||
|
if len(sys.argv) < 5:
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
email = input("email: ")
|
||||||
|
else:
|
||||||
|
email = sys.argv[3]
|
||||||
|
pw = read_password()
|
||||||
|
else:
|
||||||
|
email, pw = sys.argv[3:5]
|
||||||
|
|
||||||
|
if sys.argv[2] == "add":
|
||||||
|
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
|
||||||
|
elif sys.argv[2] == "password":
|
||||||
|
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
|
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
|
||||||
|
if sys.argv[2] == "make-admin":
|
||||||
|
action = "add"
|
||||||
|
else:
|
||||||
|
action = "remove"
|
||||||
|
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||||
|
# Dump a list of admin users.
|
||||||
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
|
for domain in users:
|
||||||
|
for user in domain["users"]:
|
||||||
|
if "admin" in user['privileges']:
|
||||||
|
print(user['email'])
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
|
||||||
|
# Show MFA status for a user.
|
||||||
|
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
|
||||||
|
W = csv.writer(sys.stdout)
|
||||||
|
W.writerow(["id", "type", "label"])
|
||||||
|
for mfa in status["enabled_mfa"]:
|
||||||
|
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
||||||
|
|
||||||
|
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||||
|
# Disable MFA (all or a particular device) for a user.
|
||||||
|
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||||
|
print(mgmt("/mail/aliases"))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||||
|
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
||||||
|
|
||||||
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
|
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Invalid command-line arguments.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
@@ -1,25 +1,31 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/local/lib/mailinabox/env/bin/python3
|
||||||
|
#
|
||||||
|
# The API can be accessed on the command line, e.g. use `curl` like so:
|
||||||
|
# curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
|
||||||
|
#
|
||||||
|
# During development, you can start the Mail-in-a-Box control panel
|
||||||
|
# by running this script, e.g.:
|
||||||
|
#
|
||||||
|
# service mailinabox stop # stop the system process
|
||||||
|
# DEBUG=1 management/daemon.py
|
||||||
|
# service mailinabox start # when done debugging, start it up again
|
||||||
|
|
||||||
import os, os.path, re, json
|
import os, os.path, re, json, time
|
||||||
|
import multiprocessing.pool, subprocess
|
||||||
|
|
||||||
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, make_response
|
||||||
|
|
||||||
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
|
||||||
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
|
||||||
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||||
|
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
|
||||||
# Create a worker pool for the status checks. The pool should
|
|
||||||
# live across http requests so we don't baloon the system with
|
|
||||||
# processes.
|
|
||||||
import multiprocessing.pool
|
|
||||||
pool = multiprocessing.pool.Pool(processes=10)
|
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
auth_service = auth.KeyAuthService()
|
auth_service = auth.AuthService()
|
||||||
|
|
||||||
# We may deploy via a symbolic link, which confuses flask's template finding.
|
# We may deploy via a symbolic link, which confuses flask's template finding.
|
||||||
me = __file__
|
me = __file__
|
||||||
@@ -28,26 +34,47 @@ 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.
|
||||||
def authorized_personnel_only(viewfunc):
|
def authorized_personnel_only(viewfunc):
|
||||||
@wraps(viewfunc)
|
@wraps(viewfunc)
|
||||||
def newview(*args, **kwargs):
|
def newview(*args, **kwargs):
|
||||||
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
# Authenticate the passed credentials, which is either the API key or a username:password pair
|
||||||
|
# and an optional X-Auth-Token token.
|
||||||
error = None
|
error = None
|
||||||
|
privs = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
email, privs = auth_service.authenticate(request, env)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
# Write a line in the log recording the failed login, unless no authorization header
|
||||||
|
# was given which can happen on an initial request before a 403 response.
|
||||||
|
if "Authorization" in request.headers:
|
||||||
|
log_failed_login(request)
|
||||||
|
|
||||||
# Authentication failed.
|
# Authentication failed.
|
||||||
privs = []
|
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
# Authorized to access an API view?
|
# Authorized to access an API view?
|
||||||
if "admin" in privs:
|
if "admin" in privs:
|
||||||
# Call view func.
|
# Store the email address of the logged in user so it can be accessed
|
||||||
|
# from the API methods that affect the calling user.
|
||||||
|
request.user_email = email
|
||||||
|
request.user_privs = privs
|
||||||
|
|
||||||
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
elif not error:
|
|
||||||
|
if not error:
|
||||||
error = "You are not an administrator."
|
error = "You are not an administrator."
|
||||||
|
|
||||||
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
|
||||||
@@ -79,8 +106,8 @@ def authorized_personnel_only(viewfunc):
|
|||||||
def unauthorized(error):
|
def unauthorized(error):
|
||||||
return auth_service.make_unauthorized_response()
|
return auth_service.make_unauthorized_response()
|
||||||
|
|
||||||
def json_response(data):
|
def json_response(data, status=200):
|
||||||
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
|
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
|
||||||
|
|
||||||
###################################
|
###################################
|
||||||
|
|
||||||
@@ -90,46 +117,75 @@ 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)
|
||||||
|
|
||||||
|
import boto3.s3
|
||||||
|
backup_s3_hosts = [(r, f"s3.{r}.amazonaws.com") for r in boto3.session.Session().get_available_regions('s3')]
|
||||||
|
|
||||||
|
|
||||||
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')
|
# Create a session key by checking the username/password in the Authorization header.
|
||||||
def me():
|
@app.route('/login', methods=["POST"])
|
||||||
|
def login():
|
||||||
# Is the caller authorized?
|
# Is the caller authorized?
|
||||||
try:
|
try:
|
||||||
email, privs = auth_service.authenticate(request, env)
|
email, privs = auth_service.authenticate(request, env, login_only=True)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json_response({
|
if "missing-totp-token" in str(e):
|
||||||
"status": "invalid",
|
return json_response({
|
||||||
"reason": str(e),
|
"status": "missing-totp-token",
|
||||||
|
"reason": str(e),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Log the failed login
|
||||||
|
log_failed_login(request)
|
||||||
|
return json_response({
|
||||||
|
"status": "invalid",
|
||||||
|
"reason": str(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Return a new session for the user.
|
||||||
resp = {
|
resp = {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": privs,
|
"privileges": privs,
|
||||||
|
"api_key": auth_service.create_session_key(email, env, type='login'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Is authorized as admin? Return an API key for future use.
|
app.logger.info("New login session created for {}".format(email))
|
||||||
if "admin" in privs:
|
|
||||||
resp["api_key"] = auth_service.create_user_key(email)
|
|
||||||
|
|
||||||
# Return.
|
# Return.
|
||||||
return json_response(resp)
|
return json_response(resp)
|
||||||
|
|
||||||
|
@app.route('/logout', methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
try:
|
||||||
|
email, _ = auth_service.authenticate(request, env, logout=True)
|
||||||
|
app.logger.info("{} logged out".format(email))
|
||||||
|
except ValueError as e:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
return json_response({ "status": "ok" })
|
||||||
|
|
||||||
# MAIL
|
# MAIL
|
||||||
|
|
||||||
@app.route('/mail/users')
|
@app.route('/mail/users')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def mail_users():
|
def mail_users():
|
||||||
if request.args.get("format", "") == "json":
|
if request.args.get("format", "") == "json":
|
||||||
return json_response(get_mail_users_ex(env, with_archived=True, with_slow_info=True))
|
return json_response(get_mail_users_ex(env, with_archived=True))
|
||||||
else:
|
else:
|
||||||
return "".join(x+"\n" for x in get_mail_users(env))
|
return "".join(x+"\n" for x in get_mail_users(env))
|
||||||
|
|
||||||
@@ -179,14 +235,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, auto 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 +251,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,31 +279,64 @@ 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)
|
||||||
|
|
||||||
@app.route('/dns/custom')
|
@app.route('/dns/custom')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def dns_get_records(qname=None, rtype=None):
|
def dns_get_records(qname=None, rtype=None):
|
||||||
from dns_update import get_custom_dns_config
|
# Get the current set of custom DNS records.
|
||||||
return json_response([
|
from dns_update import get_custom_dns_config, get_dns_zones
|
||||||
{
|
records = get_custom_dns_config(env, only_real_records=True)
|
||||||
"qname": r[0],
|
|
||||||
"rtype": r[1],
|
# Filter per the arguments for the more complex GET routes below.
|
||||||
"value": r[2],
|
records = [r for r in records
|
||||||
}
|
if (not qname or r[0] == qname)
|
||||||
for r in get_custom_dns_config(env)
|
and (not rtype or r[1] == rtype) ]
|
||||||
if r[0] != "_secondary_nameserver"
|
|
||||||
and (not qname or r[0] == qname)
|
# Make a better data structure.
|
||||||
and (not rtype or r[1] == rtype) ])
|
records = [
|
||||||
|
{
|
||||||
|
"qname": r[0],
|
||||||
|
"rtype": r[1],
|
||||||
|
"value": r[2],
|
||||||
|
"sort-order": { },
|
||||||
|
} for r in records ]
|
||||||
|
|
||||||
|
# To help with grouping by zone in qname sorting, label each record with which zone it is in.
|
||||||
|
# There's an inconsistency in how we handle zones in get_dns_zones and in sort_domains, so
|
||||||
|
# do this first before sorting the domains within the zones.
|
||||||
|
zones = utils.sort_domains([z[0] for z in get_dns_zones(env)], env)
|
||||||
|
for r in records:
|
||||||
|
for z in zones:
|
||||||
|
if r["qname"] == z or r["qname"].endswith("." + z):
|
||||||
|
r["zone"] = z
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add sorting information. The 'created' order follows the order in the YAML file on disk,
|
||||||
|
# which tracs the order entries were added in the control panel since we append to the end.
|
||||||
|
# The 'qname' sort order sorts by our standard domain name sort (by zone then by qname),
|
||||||
|
# then by rtype, and last by the original order in the YAML file (since sorting by value
|
||||||
|
# may not make sense, unless we parse IP addresses, for example).
|
||||||
|
for i, r in enumerate(records):
|
||||||
|
r["sort-order"]["created"] = i
|
||||||
|
domain_sort_order = utils.sort_domains([r["qname"] for r in records], env)
|
||||||
|
for i, r in enumerate(sorted(records, key = lambda r : (
|
||||||
|
zones.index(r["zone"]) if r.get("zone") else 0, # record is not within a zone managed by the box
|
||||||
|
domain_sort_order.index(r["qname"]),
|
||||||
|
r["rtype"]))):
|
||||||
|
r["sort-order"]["qname"] = i
|
||||||
|
|
||||||
|
# Return.
|
||||||
|
return json_response(records)
|
||||||
|
|
||||||
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
@app.route('/dns/custom/<qname>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
@app.route('/dns/custom/<qname>/<rtype>', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
@@ -283,7 +373,7 @@ def dns_set_record(qname, rtype="A"):
|
|||||||
# make this action set (replace all records for this
|
# make this action set (replace all records for this
|
||||||
# qname-rtype pair) rather than add (add a new record).
|
# qname-rtype pair) rather than add (add a new record).
|
||||||
action = "set"
|
action = "set"
|
||||||
|
|
||||||
elif request.method == "DELETE":
|
elif request.method == "DELETE":
|
||||||
if value == '':
|
if value == '':
|
||||||
# Delete all records for this qname-type pair.
|
# Delete all records for this qname-type pair.
|
||||||
@@ -306,24 +396,126 @@ def dns_get_dump():
|
|||||||
from dns_update import build_recommended_dns
|
from dns_update import build_recommended_dns
|
||||||
return json_response(build_recommended_dns(env))
|
return json_response(build_recommended_dns(env))
|
||||||
|
|
||||||
|
@app.route('/dns/zonefile/<zone>')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def dns_get_zonefile(zone):
|
||||||
|
from dns_update import get_dns_zonefile
|
||||||
|
return Response(get_dns_zonefile(zone, env), status=200, mimetype='text/plain')
|
||||||
|
|
||||||
# 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_valid_certs=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] + ((" " + cant_provision[d["domain"]] if d["domain"] in cant_provision else ""))
|
||||||
|
} 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),
|
||||||
|
"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
|
||||||
|
requests = provision_certificates(env, limit_domains=None)
|
||||||
|
return json_response({ "requests": requests })
|
||||||
|
|
||||||
|
# multi-factor auth
|
||||||
|
|
||||||
|
@app.route('/mfa/status', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def mfa_get_status():
|
||||||
|
# Anyone accessing this route is an admin, and we permit them to
|
||||||
|
# see the MFA status for any user if they submit a 'user' form
|
||||||
|
# field. But we don't include provisioning info since a user can
|
||||||
|
# only provision for themselves.
|
||||||
|
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
|
||||||
|
try:
|
||||||
|
resp = {
|
||||||
|
"enabled_mfa": get_public_mfa_state(email, env)
|
||||||
|
}
|
||||||
|
if email == request.user_email:
|
||||||
|
resp.update({
|
||||||
|
"new_mfa": {
|
||||||
|
"totp": provision_totp(email, env)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
return json_response(resp)
|
||||||
|
|
||||||
|
@app.route('/mfa/totp/enable', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def totp_post_enable():
|
||||||
|
secret = request.form.get('secret')
|
||||||
|
token = request.form.get('token')
|
||||||
|
label = request.form.get('label')
|
||||||
|
if type(token) != str:
|
||||||
|
return ("Bad Input", 400)
|
||||||
|
try:
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
enable_mfa(request.user_email, "totp", secret, token, label, env)
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
@app.route('/mfa/disable', methods=['POST'])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def totp_post_disable():
|
||||||
|
# Anyone accessing this route is an admin, and we permit them to
|
||||||
|
# disable the MFA status for any user if they submit a 'user' form
|
||||||
|
# field.
|
||||||
|
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
|
||||||
|
try:
|
||||||
|
result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None
|
||||||
|
except ValueError as e:
|
||||||
|
return (str(e), 400)
|
||||||
|
if result: # success
|
||||||
|
return "OK"
|
||||||
|
else: # error
|
||||||
|
return ("Invalid user or MFA id.", 400)
|
||||||
|
|
||||||
# WEB
|
# WEB
|
||||||
|
|
||||||
@app.route('/web/domains')
|
@app.route('/web/domains')
|
||||||
@@ -340,6 +532,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():
|
||||||
@@ -358,7 +568,11 @@ def system_status():
|
|||||||
def print_line(self, message, monospace=False):
|
def print_line(self, message, monospace=False):
|
||||||
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
self.items[-1]["extra"].append({ "text": message, "monospace": monospace })
|
||||||
output = WebOutput()
|
output = WebOutput()
|
||||||
run_checks(False, env, output, pool)
|
# Create a temporary pool of processes for the status checks
|
||||||
|
with multiprocessing.pool.Pool(processes=5) as pool:
|
||||||
|
run_checks(False, env, output, pool)
|
||||||
|
pool.close()
|
||||||
|
pool.join()
|
||||||
return json_response(output.items)
|
return json_response(output.items)
|
||||||
|
|
||||||
@app.route('/system/updates')
|
@app.route('/system/updates')
|
||||||
@@ -378,29 +592,186 @@ def do_updates():
|
|||||||
"DEBIAN_FRONTEND": "noninteractive"
|
"DEBIAN_FRONTEND": "noninteractive"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/system/reboot', methods=["GET"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def needs_reboot():
|
||||||
|
from status_checks import is_reboot_needed_due_to_package_installation
|
||||||
|
if is_reboot_needed_due_to_package_installation():
|
||||||
|
return json_response(True)
|
||||||
|
else:
|
||||||
|
return json_response(False)
|
||||||
|
|
||||||
|
@app.route('/system/reboot', methods=["POST"])
|
||||||
|
@authorized_personnel_only
|
||||||
|
def do_reboot():
|
||||||
|
# To keep the attack surface low, we don't allow a remote reboot if one isn't necessary.
|
||||||
|
from status_checks import is_reboot_needed_due_to_package_installation
|
||||||
|
if is_reboot_needed_due_to_package_installation():
|
||||||
|
return utils.shell("check_output", ["/sbin/shutdown", "-r", "now"], capture_stderr=True)
|
||||||
|
else:
|
||||||
|
return "No reboot is required, so it is not allowed."
|
||||||
|
|
||||||
|
|
||||||
@app.route('/system/backup/status')
|
@app.route('/system/backup/status')
|
||||||
@authorized_personnel_only
|
@authorized_personnel_only
|
||||||
def backup_status():
|
def backup_status():
|
||||||
from backup import backup_status
|
from backup import backup_status
|
||||||
return json_response(backup_status(env))
|
try:
|
||||||
|
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/')
|
||||||
|
@authorized_personnel_only
|
||||||
|
def munin_start():
|
||||||
|
# Munin pages, static images, and dynamically generated images are served
|
||||||
|
# outside of the AJAX API. We'll start with a 'start' API that sets a cookie
|
||||||
|
# that subsequent requests will read for authorization. (We don't use cookies
|
||||||
|
# for the API to avoid CSRF vulnerabilities.)
|
||||||
|
response = make_response("OK")
|
||||||
|
response.set_cookie("session", auth_service.create_session_key(request.user_email, env, type='cookie'),
|
||||||
|
max_age=60*30, secure=True, httponly=True, samesite="Strict") # 30 minute duration
|
||||||
|
return response
|
||||||
|
|
||||||
|
def check_request_cookie_for_admin_access():
|
||||||
|
session = auth_service.get_session(None, request.cookies.get("session", ""), "cookie", env)
|
||||||
|
if not session: return False
|
||||||
|
privs = get_mail_user_privileges(session["email"], env)
|
||||||
|
if not isinstance(privs, list): return False
|
||||||
|
if "admin" not in privs: return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def authorized_personnel_only_via_cookie(f):
|
||||||
|
@wraps(f)
|
||||||
|
def g(*args, **kwargs):
|
||||||
|
if not check_request_cookie_for_admin_access():
|
||||||
|
return Response("Unauthorized", status=403, mimetype='text/plain', headers={})
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return g
|
||||||
|
|
||||||
|
@app.route('/munin/<path:filename>')
|
||||||
|
@authorized_personnel_only_via_cookie
|
||||||
|
def munin_static_file(filename=""):
|
||||||
|
# Proxy the request to static files.
|
||||||
|
if filename == "": filename = "index.html"
|
||||||
|
return send_from_directory("/var/cache/munin/www", filename)
|
||||||
|
|
||||||
|
@app.route('/munin/cgi-graph/<path:filename>')
|
||||||
|
@authorized_personnel_only_via_cookie
|
||||||
|
def munin_cgi(filename):
|
||||||
|
""" Relay munin cgi dynazoom requests
|
||||||
|
/usr/lib/munin/cgi/munin-cgi-graph is a perl cgi script in the munin package
|
||||||
|
that is responsible for generating binary png images _and_ associated HTTP
|
||||||
|
headers based on parameters in the requesting URL. All output is written
|
||||||
|
to stdout which munin_cgi splits into response headers and binary response
|
||||||
|
data.
|
||||||
|
munin-cgi-graph reads environment variables to determine
|
||||||
|
what it should do. It expects a path to be in the env-var PATH_INFO, and a
|
||||||
|
querystring to be in the env-var QUERY_STRING.
|
||||||
|
munin-cgi-graph has several failure modes. Some write HTTP Status headers and
|
||||||
|
others return nonzero exit codes.
|
||||||
|
Situating munin_cgi between the user-agent and munin-cgi-graph enables keeping
|
||||||
|
the cgi script behind mailinabox's auth mechanisms and avoids additional
|
||||||
|
support infrastructure like spawn-fcgi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMMAND = 'su - munin --preserve-environment --shell=/bin/bash -c /usr/lib/munin/cgi/munin-cgi-graph'
|
||||||
|
# su changes user, we use the munin user here
|
||||||
|
# --preserve-environment retains the environment, which is where Popen's `env` data is
|
||||||
|
# --shell=/bin/bash ensures the shell used is bash
|
||||||
|
# -c "/usr/lib/munin/cgi/munin-cgi-graph" passes the command to run as munin
|
||||||
|
# "%s" is a placeholder for where the request's querystring will be added
|
||||||
|
|
||||||
|
if filename == "":
|
||||||
|
return ("a path must be specified", 404)
|
||||||
|
|
||||||
|
query_str = request.query_string.decode("utf-8", 'ignore')
|
||||||
|
|
||||||
|
env = {'PATH_INFO': '/%s/' % filename, 'REQUEST_METHOD': 'GET', 'QUERY_STRING': query_str}
|
||||||
|
code, binout = utils.shell('check_output',
|
||||||
|
COMMAND.split(" ", 5),
|
||||||
|
# Using a maxsplit of 5 keeps the last arguments together
|
||||||
|
env=env,
|
||||||
|
return_bytes=True,
|
||||||
|
trap=True)
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
# nonzero returncode indicates error
|
||||||
|
app.logger.error("munin_cgi: munin-cgi-graph returned nonzero exit code, %s", code)
|
||||||
|
return ("error processing graph image", 500)
|
||||||
|
|
||||||
|
# /usr/lib/munin/cgi/munin-cgi-graph returns both headers and binary png when successful.
|
||||||
|
# A double-Windows-style-newline always indicates the end of HTTP headers.
|
||||||
|
headers, image_bytes = binout.split(b'\r\n\r\n', 1)
|
||||||
|
response = make_response(image_bytes)
|
||||||
|
for line in headers.splitlines():
|
||||||
|
name, value = line.decode("utf8").split(':', 1)
|
||||||
|
response.headers[name] = value
|
||||||
|
if 'Status' in response.headers and '404' in response.headers['Status']:
|
||||||
|
app.logger.warning("munin_cgi: munin-cgi-graph returned 404 status code. PATH_INFO=%s", env['PATH_INFO'])
|
||||||
|
return response
|
||||||
|
|
||||||
|
def log_failed_login(request):
|
||||||
|
# We need to figure out the ip to list in the message, all our calls are routed
|
||||||
|
# through nginx who will put the original ip in X-Forwarded-For.
|
||||||
|
# During setup we call the management interface directly to determine the user
|
||||||
|
# status. So we can't always use X-Forwarded-For because during setup that header
|
||||||
|
# will not be present.
|
||||||
|
if request.headers.getlist("X-Forwarded-For"):
|
||||||
|
ip = request.headers.getlist("X-Forwarded-For")[0]
|
||||||
|
else:
|
||||||
|
ip = request.remote_addr
|
||||||
|
|
||||||
|
# We need to add a timestamp to the log message, otherwise /dev/log will eat the "duplicate"
|
||||||
|
# message.
|
||||||
|
app.logger.warning( "Mail-in-a-Box Management Daemon: Failed login attempt from ip %s - timestamp %s" % (ip, time.time()))
|
||||||
|
|
||||||
|
|
||||||
# APP
|
# APP
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
if "DEBUG" in os.environ: app.debug = True
|
if "DEBUG" in os.environ:
|
||||||
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
|
# Turn on Flask debugging.
|
||||||
|
app.debug = True
|
||||||
|
|
||||||
if not app.debug:
|
if not app.debug:
|
||||||
app.logger.addHandler(utils.create_syslog_handler())
|
app.logger.addHandler(utils.create_syslog_handler())
|
||||||
|
|
||||||
# For testing on the command line, you can use `curl` like so:
|
#app.logger.info('API key: ' + auth_service.key)
|
||||||
# curl --user $(</var/lib/mailinabox/api.key): http://localhost:10222/mail/users
|
|
||||||
auth_service.write_key()
|
|
||||||
|
|
||||||
# For testing in the browser, you can copy the API key that's output to the
|
|
||||||
# debug console and enter that as the username
|
|
||||||
app.logger.info('API key: ' + auth_service.key)
|
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
|
|||||||
25
management/daily_tasks.sh
Executable file
25
management/daily_tasks.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# On Mondays, i.e. once a week, send the administrator a report of total emails
|
||||||
|
# sent and received so the admin might notice server abuse.
|
||||||
|
if [ `date "+%u"` -eq 1 ]; then
|
||||||
|
management/mail_log.py -t week | management/email_administrator.py "Mail-in-a-Box Usage Report"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Take a backup.
|
||||||
|
management/backup.py 2>&1 | management/email_administrator.py "Backup Status"
|
||||||
|
|
||||||
|
# Provision any new certificates for new domains or domains with expiring certificates.
|
||||||
|
management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result"
|
||||||
|
|
||||||
|
# Run status checks and email the administrator if anything changed.
|
||||||
|
management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
|
||||||
# Creates DNS zone files for all of the domains of all of the mail users
|
# Creates DNS zone files for all of the domains of all of the mail users
|
||||||
# and mail aliases and restarts nsd.
|
# and mail aliases and restarts nsd.
|
||||||
@@ -9,14 +9,24 @@ import ipaddress
|
|||||||
import rtyaml
|
import rtyaml
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
|
||||||
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
|
from utils import shell, load_env_vars_from_file, safe_domain_name, sort_domains
|
||||||
|
from ssl_certificates import get_ssl_certificates, check_certificate
|
||||||
|
|
||||||
|
# From https://stackoverflow.com/questions/3026957/how-to-validate-a-domain-name-using-regex-php/16491074#16491074
|
||||||
|
# This regular expression matches domain names according to RFCs, it also accepts fqdn with an leading dot,
|
||||||
|
# underscores, as well as asteriks which are allowed in domain names but not hostnames (i.e. allowed in
|
||||||
|
# DNS but not in URLs), which are common in certain record types like for DKIM.
|
||||||
|
DOMAIN_RE = "^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$"
|
||||||
|
|
||||||
def get_dns_domains(env):
|
def get_dns_domains(env):
|
||||||
# Add all domain names in use by email users and mail aliases and ensure
|
# Add all domain names in use by email users and mail aliases, any
|
||||||
# PRIMARY_HOSTNAME is in the list.
|
# domains we serve web for (except www redirects because that would
|
||||||
|
# lead to infinite recursion here) and ensure PRIMARY_HOSTNAME is in the list.
|
||||||
|
from mailconfig import get_mail_domains
|
||||||
|
from web_update import get_web_domains
|
||||||
domains = set()
|
domains = set()
|
||||||
domains |= get_mail_domains(env)
|
domains |= set(get_mail_domains(env))
|
||||||
|
domains |= set(get_web_domains(env, include_www_redirects=False))
|
||||||
domains.add(env['PRIMARY_HOSTNAME'])
|
domains.add(env['PRIMARY_HOSTNAME'])
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
@@ -51,19 +61,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 +75,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,31 +89,39 @@ 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:
|
||||||
updated_domains.append("DNS configuration")
|
updated_domains.append("DNS configuration")
|
||||||
|
|
||||||
# Kick nsd if anything changed.
|
# Tell nsd to reload changed zone files.
|
||||||
if len(updated_domains) > 0:
|
if len(updated_domains) > 0:
|
||||||
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
# 'reconfig' is needed if there are added or removed zones, but
|
||||||
|
# it may not reload existing zones, so we call 'reload' too. If
|
||||||
|
# nsd isn't running, nsd-control fails, so in that case revert
|
||||||
|
# to restarting nsd to make sure it is running. Restarting nsd
|
||||||
|
# should also refresh everything.
|
||||||
|
try:
|
||||||
|
shell('check_call', ["/usr/sbin/nsd-control", "reconfig"])
|
||||||
|
shell('check_call', ["/usr/sbin/nsd-control", "reload"])
|
||||||
|
except:
|
||||||
|
shell('check_call', ["/usr/sbin/service", "nsd", "restart"])
|
||||||
|
|
||||||
# Write the OpenDKIM configuration tables.
|
# Write the OpenDKIM configuration tables for all of the mail domains.
|
||||||
if write_opendkim_tables(domains, env):
|
from mailconfig import get_mail_domains
|
||||||
|
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 +130,52 @@ 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)
|
||||||
|
|
||||||
|
# Create a dictionary of domains to a set of attributes for each
|
||||||
|
# domain, such as whether there are mail users at the domain.
|
||||||
|
from mailconfig import get_mail_domains
|
||||||
|
from web_update import get_web_domains
|
||||||
|
mail_domains = set(get_mail_domains(env))
|
||||||
|
mail_user_domains = set(get_mail_domains(env, users_only=True)) # i.e. will log in for mail, Nextcloud
|
||||||
|
web_domains = set(get_web_domains(env))
|
||||||
|
auto_domains = web_domains - set(get_web_domains(env, include_auto=False))
|
||||||
|
domains |= auto_domains # www redirects not included in the initial list, see above
|
||||||
|
|
||||||
|
# Add ns1/ns2+PRIMARY_HOSTNAME which must also have A/AAAA records
|
||||||
|
# when the box is acting as authoritative DNS server for its domains.
|
||||||
|
for ns in ("ns1", "ns2"):
|
||||||
|
d = ns + "." + env["PRIMARY_HOSTNAME"]
|
||||||
|
domains.add(d)
|
||||||
|
auto_domains.add(d)
|
||||||
|
|
||||||
|
domains = {
|
||||||
|
domain: {
|
||||||
|
"user": domain in mail_user_domains,
|
||||||
|
"mail": domain in mail_domains,
|
||||||
|
"web": domain in web_domains,
|
||||||
|
"auto": domain in auto_domains,
|
||||||
|
}
|
||||||
|
for domain in domains
|
||||||
|
}
|
||||||
|
|
||||||
|
# For MTA-STS, we'll need to check if the PRIMARY_HOSTNAME certificate is
|
||||||
|
# singned and valid. Check that now rather than repeatedly for each domain.
|
||||||
|
domains[env["PRIMARY_HOSTNAME"]]["certificate-is-valid"] = is_domain_cert_signed_and_valid(env["PRIMARY_HOSTNAME"], env)
|
||||||
|
|
||||||
|
# Load custom records to add to zones.
|
||||||
|
additional_records = list(get_custom_dns_config(env))
|
||||||
|
|
||||||
|
# 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, env)
|
||||||
|
yield (domain, zonefile, records)
|
||||||
|
|
||||||
|
def build_zone(domain, domain_properties, additional_records, env, is_zone=True):
|
||||||
records = []
|
records = []
|
||||||
|
|
||||||
# For top-level zones, define the authoritative name servers.
|
# For top-level zones, define the authoritative name servers.
|
||||||
@@ -138,25 +187,19 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
# 'False' in the tuple indicates these records would not be used if the zone
|
# 'False' in the tuple indicates these records would not be used if the zone
|
||||||
# is managed outside of the box.
|
# is managed outside of the box.
|
||||||
if is_zone:
|
if is_zone:
|
||||||
# Obligatory definition of ns1.PRIMARY_HOSTNAME.
|
# Obligatory NS record to ns1.PRIMARY_HOSTNAME.
|
||||||
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.
|
# NS record to 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
|
||||||
records.append((None, "NS", secondary_ns+'.', False))
|
secondary_ns_list = get_secondary_dns(additional_records, mode="NS") \
|
||||||
|
or ["ns2." + env["PRIMARY_HOSTNAME"]]
|
||||||
|
for secondary_ns in secondary_ns_list:
|
||||||
|
records.append((None, "NS", secondary_ns+'.', False))
|
||||||
|
|
||||||
|
|
||||||
# In PRIMARY_HOSTNAME...
|
# In PRIMARY_HOSTNAME...
|
||||||
if domain == env["PRIMARY_HOSTNAME"]:
|
if domain == env["PRIMARY_HOSTNAME"]:
|
||||||
# Define ns1 and ns2.
|
|
||||||
# 'False' in the tuple indicates these records would not be used if the zone
|
|
||||||
# is managed outside of the box.
|
|
||||||
records.append(("ns1", "A", env["PUBLIC_IP"], False))
|
|
||||||
records.append(("ns2", "A", env["PUBLIC_IP"], False))
|
|
||||||
if env.get('PUBLIC_IPV6'):
|
|
||||||
records.append(("ns1", "AAAA", env["PUBLIC_IPV6"], False))
|
|
||||||
records.append(("ns2", "AAAA", env["PUBLIC_IPV6"], False))
|
|
||||||
|
|
||||||
# Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them
|
# Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them
|
||||||
# and we can provide different explanatory text.
|
# and we can provide different explanatory text.
|
||||||
records.append((None, "A", env["PUBLIC_IP"], "Required. Sets the IP address of the box."))
|
records.append((None, "A", env["PUBLIC_IP"], "Required. Sets the IP address of the box."))
|
||||||
@@ -165,25 +208,26 @@ 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."))
|
||||||
|
|
||||||
# The MX record says where email for the domain should be delivered: Here!
|
|
||||||
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
|
|
||||||
|
|
||||||
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
||||||
# both a domain and one of its subdomains.
|
# both a domain and one of its subdomains.
|
||||||
subdomains = [d for d in all_domains if d.endswith("." + domain)]
|
if is_zone: # don't recurse when we're just loading data for a subdomain
|
||||||
for subdomain in subdomains:
|
subdomains = [d for d in domain_properties if d.endswith("." + domain)]
|
||||||
subdomain_qname = subdomain[0:-len("." + domain)]
|
for subdomain in subdomains:
|
||||||
subzone = build_zone(subdomain, [], additional_records, env, is_zone=False)
|
subdomain_qname = subdomain[0:-len("." + domain)]
|
||||||
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False)
|
||||||
if child_qname == None:
|
for child_qname, child_rtype, child_value, child_explanation in subzone:
|
||||||
child_qname = subdomain_qname
|
if child_qname == None:
|
||||||
else:
|
child_qname = subdomain_qname
|
||||||
child_qname += "." + subdomain_qname
|
else:
|
||||||
records.append((child_qname, child_rtype, child_value, child_explanation))
|
child_qname += "." + subdomain_qname
|
||||||
|
records.append((child_qname, child_rtype, child_value, child_explanation))
|
||||||
|
|
||||||
has_rec_base = list(records) # clone current state
|
has_rec_base = list(records) # clone current state
|
||||||
def has_rec(qname, rtype, prefix=None):
|
def has_rec(qname, rtype, prefix=None):
|
||||||
@@ -210,14 +254,22 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True):
|
|||||||
continue
|
continue
|
||||||
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 A/AAAA 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)
|
||||||
|
a_expl = "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
|
||||||
|
if domain_properties[domain]["auto"]:
|
||||||
|
if domain.startswith("ns1.") or domain.startswith("ns2."): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server
|
||||||
|
if domain.startswith("www."): a_expl = "Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain." % domain
|
||||||
|
if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."
|
||||||
|
if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig."
|
||||||
|
if domain.startswith("autodiscover."): a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."
|
||||||
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"], a_expl),
|
||||||
("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),
|
|
||||||
]
|
]
|
||||||
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,65 +281,152 @@ 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))
|
||||||
|
|
||||||
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
# Don't pin the list of records that has_rec checks against anymore.
|
||||||
# the domain, and no one else.
|
has_rec_base = records
|
||||||
# Skip if the user has set a custom SPF record.
|
|
||||||
if not has_rec(None, "TXT", prefix="v=spf1 "):
|
|
||||||
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
|
|
||||||
|
|
||||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
if domain_properties[domain]["mail"]:
|
||||||
# Skip if the user has set a DKIM record already.
|
# The MX record says where email for the domain should be delivered: Here!
|
||||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
if not has_rec(None, "MX", prefix="10 "):
|
||||||
with open(opendkim_record_file) as orf:
|
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain))
|
||||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( "([^"]+)"\s+"([^"]+)"\s*\)', orf.read(), re.S)
|
|
||||||
val = m.group(2) + m.group(3)
|
|
||||||
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))
|
|
||||||
|
|
||||||
# Append a DMARC record.
|
# SPF record: Permit the box ('mx', see above) to send mail on behalf of
|
||||||
# Skip if the user has set a DMARC record already.
|
# the domain, and no one else.
|
||||||
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
|
# Skip if the user has set a custom SPF record.
|
||||||
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain))
|
if not has_rec(None, "TXT", prefix="v=spf1 "):
|
||||||
|
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain))
|
||||||
|
|
||||||
# For any subdomain with an A record but no SPF or DMARC record, add strict policy records.
|
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
||||||
all_resolvable_qnames = set(r[0] for r in records if r[1] in ("A", "AAAA"))
|
# Skip if the user has set a DKIM record already.
|
||||||
for qname in all_resolvable_qnames:
|
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||||
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
with open(opendkim_record_file) as orf:
|
||||||
records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % (qname + "." + domain)))
|
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
|
||||||
dmarc_qname = "_dmarc" + ("" if qname is None else "." + qname)
|
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
|
||||||
if not has_rec(dmarc_qname, "TXT", prefix="v=DMARC1; "):
|
if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "):
|
||||||
records.append((dmarc_qname, "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % (qname + "." + domain)))
|
records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain))
|
||||||
|
|
||||||
|
# Append a DMARC record.
|
||||||
|
# Skip if the user has set a DMARC record already.
|
||||||
|
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
|
||||||
|
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine;', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain))
|
||||||
|
|
||||||
|
if domain_properties[domain]["user"]:
|
||||||
|
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
|
||||||
|
# for autoconfiguration of mail clients (so only domains hosting user accounts need it).
|
||||||
|
# The SRV record format is priority (0, whatever), weight (0, whatever), port, service provider hostname (w/ trailing dot).
|
||||||
|
if domain != env["PRIMARY_HOSTNAME"]:
|
||||||
|
for dav in ("card", "cal"):
|
||||||
|
qname = "_" + dav + "davs._tcp"
|
||||||
|
if not has_rec(qname, "SRV"):
|
||||||
|
records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain."))
|
||||||
|
|
||||||
|
# If this is a domain name that there are email addresses configured for, i.e. "something@"
|
||||||
|
# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)
|
||||||
|
# Policy Domain.
|
||||||
|
#
|
||||||
|
# A "_mta-sts" TXT record signals the presence of a MTA-STS policy. The id field helps clients
|
||||||
|
# cache the policy. It should be stable so we don't update DNS unnecessarily but change when
|
||||||
|
# the policy changes. It must be at most 32 letters and numbers, so we compute a hash of the
|
||||||
|
# policy file.
|
||||||
|
#
|
||||||
|
# The policy itself is served at the "mta-sts" (no underscore) subdomain over HTTPS. Therefore
|
||||||
|
# the TLS certificate used by Postfix for STARTTLS must be a valid certificate for the MX
|
||||||
|
# domain name (PRIMARY_HOSTNAME) *and* the TLS certificate used by nginx for HTTPS on the mta-sts
|
||||||
|
# subdomain must be valid certificate for that domain. Do not set an MTA-STS policy if either
|
||||||
|
# certificate in use is not valid (e.g. because it is self-signed and a valid certificate has not
|
||||||
|
# yet been provisioned). Since we cannot provision a certificate without A/AAAA records, we
|
||||||
|
# always set them (by including them in the www domains) --- only the TXT records depend on there
|
||||||
|
# being valid certificates.
|
||||||
|
mta_sts_records = [ ]
|
||||||
|
if domain_properties[domain]["mail"] \
|
||||||
|
and domain_properties[env["PRIMARY_HOSTNAME"]]["certificate-is-valid"] \
|
||||||
|
and is_domain_cert_signed_and_valid("mta-sts." + domain, env):
|
||||||
|
# Compute an up-to-32-character hash of the policy file. We'll take a SHA-1 hash of the policy
|
||||||
|
# file (20 bytes) and encode it as base-64 (28 bytes, using alphanumeric alternate characters
|
||||||
|
# instead of '+' and '/' which are not allowed in an MTA-STS policy id) but then just take its
|
||||||
|
# first 20 characters, which is more than sufficient to change whenever the policy file changes
|
||||||
|
# (and ensures any '=' padding at the end of the base64 encoding is dropped).
|
||||||
|
with open("/var/lib/mailinabox/mta-sts.txt", "rb") as f:
|
||||||
|
mta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b"AA").decode("ascii")[0:20]
|
||||||
|
mta_sts_records.extend([
|
||||||
|
("_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id, "Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published.")
|
||||||
|
])
|
||||||
|
|
||||||
|
# Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option.
|
||||||
|
# Skip if the rules below if the user has set a custom _smtp._tls record.
|
||||||
|
if env.get("MTA_STS_TLSRPT_RUA") and not has_rec("_smtp._tls", "TXT", prefix="v=TLSRPTv1;"):
|
||||||
|
mta_sts_records.append(("_smtp._tls", "TXT", "v=TLSRPTv1; rua=" + env["MTA_STS_TLSRPT_RUA"], "Optional. Enables MTA-STS reporting."))
|
||||||
|
for qname, rtype, value, explanation in mta_sts_records:
|
||||||
|
if not has_rec(qname, rtype):
|
||||||
|
records.append((qname, rtype, value, explanation))
|
||||||
|
|
||||||
|
# Add no-mail-here records for any qname that has an A or AAAA record
|
||||||
|
# but no MX record. This would include domain itself if domain is a
|
||||||
|
# non-mail domain and also may include qnames from custom DNS records.
|
||||||
|
# Do this once at the end of generating a zone.
|
||||||
|
if is_zone:
|
||||||
|
qnames_with_a = set(qname for (qname, rtype, value, explanation) in records if rtype in ("A", "AAAA"))
|
||||||
|
qnames_with_mx = set(qname for (qname, rtype, value, explanation) in records if rtype == "MX")
|
||||||
|
for qname in qnames_with_a - qnames_with_mx:
|
||||||
|
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
|
||||||
|
d = (qname+"." if qname else "") + domain
|
||||||
|
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
||||||
|
records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % d))
|
||||||
|
if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "):
|
||||||
|
records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject;', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d))
|
||||||
|
|
||||||
|
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
|
||||||
|
if not has_rec(qname, "MX"):
|
||||||
|
records.append((qname, "MX", '0 .', "Recommended. Prevents use of this domain name for incoming mail."))
|
||||||
|
|
||||||
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
||||||
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||||
|
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
def is_domain_cert_signed_and_valid(domain, env):
|
||||||
|
cert = get_ssl_certificates(env).get(domain)
|
||||||
|
if not cert: return False # no certificate provisioned
|
||||||
|
cert_status = check_certificate(domain, cert['certificate'], cert['private-key'])
|
||||||
|
return cert_status[0] == 'OK'
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
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
|
||||||
@@ -305,14 +444,31 @@ def build_sshfp_records():
|
|||||||
"ssh-rsa": 1,
|
"ssh-rsa": 1,
|
||||||
"ssh-dss": 2,
|
"ssh-dss": 2,
|
||||||
"ecdsa-sha2-nistp256": 3,
|
"ecdsa-sha2-nistp256": 3,
|
||||||
|
"ssh-ed25519": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get our local fingerprints by running ssh-keyscan. The output looks
|
# Get our local fingerprints by running ssh-keyscan. The output looks
|
||||||
# like the known_hosts file: hostname, keytype, fingerprint. The order
|
# like the known_hosts file: hostname, keytype, fingerprint. The order
|
||||||
# of the output is arbitrary, so sort it to prevent spurrious updates
|
# of the output is arbitrary, so sort it to prevent spurrious updates
|
||||||
# to the zone file (that trigger bumping the serial number).
|
# to the zone file (that trigger bumping the serial number). However,
|
||||||
keys = shell("check_output", ["ssh-keyscan", "localhost"])
|
# if SSH has been configured to listen on a nonstandard port, we must
|
||||||
for key in sorted(keys.split("\n")):
|
# specify that port to sshkeyscan.
|
||||||
|
|
||||||
|
port = 22
|
||||||
|
with open('/etc/ssh/sshd_config', 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
s = line.rstrip().split()
|
||||||
|
if len(s) == 2 and s[0] == 'Port':
|
||||||
|
try:
|
||||||
|
port = int(s[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
keys = shell("check_output", ["ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"])
|
||||||
|
keys = sorted(keys.split("\n"))
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
if key.strip() == "" or key[0] == "#": continue
|
if key.strip() == "" or key[0] == "#": continue
|
||||||
try:
|
try:
|
||||||
host, keytype, pubkey = key.split(" ")
|
host, keytype, pubkey = key.split(" ")
|
||||||
@@ -332,24 +488,27 @@ def write_nsd_zone(domain, zonefile, records, env, force):
|
|||||||
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
|
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
|
||||||
# what the $ORIGIN line does. Any further data after the domain confuses
|
# what the $ORIGIN line does. Any further data after the domain confuses
|
||||||
# ldns-signzone, however. It used to say '; default zone domain'.
|
# ldns-signzone, however. It used to say '; default zone domain'.
|
||||||
|
#
|
||||||
# The SOA contact address for all of the domains on this system is hostmaster
|
# The SOA contact address for all of the domains on this system is hostmaster
|
||||||
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
|
# @ the PRIMARY_HOSTNAME. Hopefully that's legit.
|
||||||
|
#
|
||||||
# For the refresh through TTL fields, a good reference is:
|
# For the refresh through TTL fields, a good reference is:
|
||||||
# http://www.peerwisdom.org/2013/05/15/dns-understanding-the-soa-record/
|
# https://www.ripe.net/publications/docs/ripe-203
|
||||||
|
#
|
||||||
|
# A hash of the available DNSSEC keys are added in a comment so that when
|
||||||
|
# the keys change we force a re-generation of the zone which triggers
|
||||||
|
# re-signing it.
|
||||||
|
|
||||||
zone = """
|
zone = """
|
||||||
$ORIGIN {domain}.
|
$ORIGIN {domain}.
|
||||||
$TTL 1800 ; default time to live
|
$TTL 86400 ; default time to live
|
||||||
|
|
||||||
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
||||||
__SERIAL__ ; serial number
|
__SERIAL__ ; serial number
|
||||||
7200 ; Refresh (secondary nameserver update interval)
|
7200 ; Refresh (secondary nameserver update interval)
|
||||||
1800 ; Retry (when refresh fails, how often to try again)
|
3600 ; Retry (when refresh fails, how often to try again, should be lower than the refresh)
|
||||||
1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
|
1209600 ; Expire (when refresh fails, how long secondary nameserver will keep records around anyway)
|
||||||
1800 ; Negative TTL (how long negative responses are cached)
|
86400 ; Negative TTL (how long negative responses are cached)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -362,11 +521,21 @@ $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"
|
||||||
|
|
||||||
|
# Append a stable hash of DNSSEC signing keys in a comment.
|
||||||
|
zone += "\n; DNSSEC signing keys hash: {}\n".format(hash_dnssec_keys(domain, env))
|
||||||
|
|
||||||
# DNSSEC requires re-signing a zone periodically. That requires
|
# DNSSEC requires re-signing a zone periodically. That requires
|
||||||
# bumping the serial number even if no other records have changed.
|
# bumping the serial number even if no other records have changed.
|
||||||
# We don't see the DNSSEC records yet, so we have to figure out
|
# We don't see the DNSSEC records yet, so we have to figure out
|
||||||
@@ -429,11 +598,22 @@ $TTL 1800 ; default time to live
|
|||||||
|
|
||||||
return True # file is updated
|
return True # file is updated
|
||||||
|
|
||||||
|
def get_dns_zonefile(zone, env):
|
||||||
|
for domain, fn in get_dns_zones(env):
|
||||||
|
if zone == domain:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("%s is not a domain name that corresponds to a zone." % zone)
|
||||||
|
|
||||||
|
nsd_zonefile = "/etc/nsd/zones/" + fn
|
||||||
|
with open(nsd_zonefile, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def write_nsd_conf(zonefiles, additional_records, env):
|
def write_nsd_conf(zonefiles, additional_records, env):
|
||||||
# Write the list of zones to a configuration file.
|
# Write the list of zones to a configuration file.
|
||||||
nsd_conf_file = "/etc/nsd/zones.conf"
|
nsd_conf_file = "/etc/nsd/nsd.conf.d/zones.conf"
|
||||||
nsdconf = ""
|
nsdconf = ""
|
||||||
|
|
||||||
# Append the zones.
|
# Append the zones.
|
||||||
@@ -444,17 +624,12 @@ 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, if not a subnet, 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.
|
if "/" not in ipaddr:
|
||||||
hostname = get_secondary_dns(additional_records)
|
nsdconf += "\n\tnotify: %s NOKEY" % (ipaddr)
|
||||||
resolver = dns.resolver.get_default_resolver()
|
nsdconf += "\n\tprovide-xfr: %s NOKEY\n" % (ipaddr)
|
||||||
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.
|
||||||
@@ -471,51 +646,77 @@ zone:
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def dnssec_choose_algo(domain, env):
|
def find_dnssec_signing_keys(domain, env):
|
||||||
if '.' in domain and domain.rsplit('.')[-1] in \
|
# For key that we generated (one per algorithm)...
|
||||||
("email", "guide", "fund"):
|
d = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec')
|
||||||
# At GoDaddy, RSASHA256 is the only algorithm supported
|
keyconfs = [f for f in os.listdir(d) if f.endswith(".conf")]
|
||||||
# for .email and .guide.
|
for keyconf in keyconfs:
|
||||||
# A variety of algorithms are supported for .fund. This
|
# Load the file holding the KSK and ZSK key filenames.
|
||||||
# is preferred.
|
keyconf_fn = os.path.join(d, keyconf)
|
||||||
return "RSASHA256"
|
keyinfo = load_env_vars_from_file(keyconf_fn)
|
||||||
|
|
||||||
# For any domain we were able to sign before, don't change the algorithm
|
# Skip this key if the conf file has a setting named DOMAINS,
|
||||||
# on existing users. We'll probably want to migrate to SHA256 later.
|
# holding a comma-separated list of domain names, and if this
|
||||||
return "RSASHA1-NSEC3-SHA1"
|
# domain is not in the list. This allows easily disabling a
|
||||||
|
# key by setting "DOMAINS=" or "DOMAINS=none", other than
|
||||||
|
# deleting the key's .conf file, which might result in the key
|
||||||
|
# being regenerated next upgrade. Keys should be disabled if
|
||||||
|
# they are not needed to reduce the DNSSEC query response size.
|
||||||
|
if "DOMAINS" in keyinfo and domain not in [dd.strip() for dd in keyinfo["DOMAINS"].split(",")]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for keytype in ("KSK", "ZSK"):
|
||||||
|
yield keytype, keyinfo[keytype]
|
||||||
|
|
||||||
|
def hash_dnssec_keys(domain, env):
|
||||||
|
# Create a stable (by sorting the items) hash of all of the private keys
|
||||||
|
# that will be used to sign this domain.
|
||||||
|
keydata = []
|
||||||
|
for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):
|
||||||
|
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private")
|
||||||
|
keydata.append(keytype)
|
||||||
|
keydata.append(keyfn)
|
||||||
|
with open(oldkeyfn, "r") as fr:
|
||||||
|
keydata.append( fr.read() )
|
||||||
|
keydata = "".join(keydata).encode("utf8")
|
||||||
|
return hashlib.sha1(keydata).hexdigest()
|
||||||
|
|
||||||
def sign_zone(domain, zonefile, env):
|
def sign_zone(domain, zonefile, env):
|
||||||
algo = dnssec_choose_algo(domain, env)
|
# Sign the zone with all of the keys that were generated during
|
||||||
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
|
# setup so that the user can choose which to use in their DS record at
|
||||||
|
# their registrar, and also to support migration to newer algorithms.
|
||||||
|
|
||||||
# In order to use the same keys for all domains, we have to generate
|
# In order to use the key files generated at setup which are for
|
||||||
# a new .key file with a DNSSEC record for the specific domain. We
|
# the domain _domain_, we have to re-write the files and place
|
||||||
# can reuse the same key, but it won't validate without a DNSSEC
|
# the actual domain name in it, so that ldns-signzone works.
|
||||||
# record specifically for the domain.
|
|
||||||
#
|
#
|
||||||
# Copy the .key and .private files to /tmp to patch them up.
|
# Patch each key, storing the patched version in /tmp for now.
|
||||||
#
|
# Each key has a .key and .private file. Collect a list of filenames
|
||||||
# Use os.umask and open().write() to securely create a copy that only
|
# for all of the keys (and separately just the key-signing keys).
|
||||||
# we (root) can read.
|
all_keys = []
|
||||||
files_to_kill = []
|
ksk_keys = []
|
||||||
for key in ("KSK", "ZSK"):
|
for keytype, keyfn in find_dnssec_signing_keys(domain, env):
|
||||||
if dnssec_keys.get(key, "").strip() == "": raise Exception("DNSSEC is not properly set up.")
|
newkeyfn = '/tmp/' + keyfn.replace("_domain_", domain)
|
||||||
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/' + dnssec_keys[key])
|
|
||||||
newkeyfn = '/tmp/' + dnssec_keys[key].replace("_domain_", domain)
|
|
||||||
dnssec_keys[key] = newkeyfn
|
|
||||||
for ext in (".private", ".key"):
|
for ext in (".private", ".key"):
|
||||||
if not os.path.exists(oldkeyfn + ext): raise Exception("DNSSEC is not properly set up.")
|
# Copy the .key and .private files to /tmp to patch them up.
|
||||||
with open(oldkeyfn + ext, "r") as fr:
|
#
|
||||||
|
# Use os.umask and open().write() to securely create a copy that only
|
||||||
|
# we (root) can read.
|
||||||
|
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)
|
||||||
|
with open(oldkeyfn, "r") as fr:
|
||||||
keydata = fr.read()
|
keydata = fr.read()
|
||||||
keydata = keydata.replace("_domain_", domain) # trick ldns-signkey into letting our generic key be used by this zone
|
keydata = keydata.replace("_domain_", domain)
|
||||||
fn = newkeyfn + ext
|
|
||||||
prev_umask = os.umask(0o77) # ensure written file is not world-readable
|
prev_umask = os.umask(0o77) # ensure written file is not world-readable
|
||||||
try:
|
try:
|
||||||
with open(fn, "w") as fw:
|
with open(newkeyfn + ext, "w") as fw:
|
||||||
fw.write(keydata)
|
fw.write(keydata)
|
||||||
finally:
|
finally:
|
||||||
os.umask(prev_umask) # other files we write should be world-readable
|
os.umask(prev_umask) # other files we write should be world-readable
|
||||||
files_to_kill.append(fn)
|
|
||||||
|
# Put the patched key filename base (without extension) into the list of keys we'll sign with.
|
||||||
|
all_keys.append(newkeyfn)
|
||||||
|
if keytype == "KSK": ksk_keys.append(newkeyfn)
|
||||||
|
|
||||||
# Do the signing.
|
# Do the signing.
|
||||||
expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d")
|
expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d")
|
||||||
@@ -528,32 +729,34 @@ def sign_zone(domain, zonefile, env):
|
|||||||
|
|
||||||
# zonefile to sign
|
# zonefile to sign
|
||||||
"/etc/nsd/zones/" + zonefile,
|
"/etc/nsd/zones/" + zonefile,
|
||||||
|
]
|
||||||
# keys to sign with (order doesn't matter -- it'll figure it out)
|
# keys to sign with (order doesn't matter -- it'll figure it out)
|
||||||
dnssec_keys["KSK"],
|
+ all_keys
|
||||||
dnssec_keys["ZSK"],
|
)
|
||||||
])
|
|
||||||
|
|
||||||
# Create a DS record based on the patched-up key files. The DS record is specific to the
|
# Create a DS record based on the patched-up key files. The DS record is specific to the
|
||||||
# zone being signed, so we can't use the .ds files generated when we created the keys.
|
# zone being signed, so we can't use the .ds files generated when we created the keys.
|
||||||
# The DS record points to the KSK only. Write this next to the zone file so we can
|
# The DS record points to the KSK only. Write this next to the zone file so we can
|
||||||
# get it later to give to the user with instructions on what to do with it.
|
# get it later to give to the user with instructions on what to do with it.
|
||||||
#
|
#
|
||||||
# We want to be able to validate DS records too, but multiple forms may be valid depending
|
# Generate a DS record for each key. There are also several possible hash algorithms that may
|
||||||
# on the digest type. So we'll write all (both) valid records. Only one DS record should
|
# be used, so we'll pre-generate all for each key. One DS record per line. Only one
|
||||||
# actually be deployed. Preferebly the first.
|
# needs to actually be deployed at the registrar. We'll select the preferred one
|
||||||
|
# in the status checks.
|
||||||
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
|
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
|
||||||
for digest_type in ('2', '1'):
|
for key in ksk_keys:
|
||||||
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
for digest_type in ('1', '2', '4'):
|
||||||
"-n", # output to stdout
|
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
||||||
"-" + digest_type, # 1=SHA1, 2=SHA256
|
"-n", # output to stdout
|
||||||
dnssec_keys["KSK"] + ".key"
|
"-" + digest_type, # 1=SHA1, 2=SHA256, 4=SHA384
|
||||||
])
|
key + ".key"
|
||||||
f.write(rr_ds)
|
])
|
||||||
|
f.write(rr_ds)
|
||||||
|
|
||||||
# Remove our temporary file.
|
# Remove the temporary patched key files.
|
||||||
for fn in files_to_kill:
|
for fn in all_keys:
|
||||||
os.unlink(fn)
|
os.unlink(fn + ".private")
|
||||||
|
os.unlink(fn + ".key")
|
||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
@@ -610,7 +813,7 @@ def write_opendkim_tables(domains, env):
|
|||||||
|
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
def get_custom_dns_config(env):
|
def get_custom_dns_config(env, only_real_records=False):
|
||||||
try:
|
try:
|
||||||
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
||||||
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
||||||
@@ -618,6 +821,8 @@ def get_custom_dns_config(env):
|
|||||||
return [ ]
|
return [ ]
|
||||||
|
|
||||||
for qname, value in custom_dns.items():
|
for qname, value in custom_dns.items():
|
||||||
|
if qname == "_secondary_nameserver" and only_real_records: continue # skip fake record
|
||||||
|
|
||||||
# Short form. Mapping a domain name to a string is short-hand
|
# Short form. Mapping a domain name to a string is short-hand
|
||||||
# for creating A records.
|
# for creating A records.
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -712,12 +917,25 @@ def set_custom_dns_record(qname, rtype, value, action, env):
|
|||||||
# validate rtype
|
# validate rtype
|
||||||
rtype = rtype.upper()
|
rtype = rtype.upper()
|
||||||
if value is not None and qname != "_secondary_nameserver":
|
if value is not None and qname != "_secondary_nameserver":
|
||||||
|
if not re.search(DOMAIN_RE, qname):
|
||||||
|
raise ValueError("Invalid name.")
|
||||||
|
|
||||||
if rtype in ("A", "AAAA"):
|
if rtype in ("A", "AAAA"):
|
||||||
if value != "local": # "local" is a special flag for us
|
if value != "local": # "local" is a special flag for us
|
||||||
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
|
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
|
||||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
||||||
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
||||||
elif rtype in ("CNAME", "TXT", "SRV", "MX"):
|
elif rtype in ("CNAME", "NS"):
|
||||||
|
if rtype == "NS" and qname == zone:
|
||||||
|
raise ValueError("NS records can only be set for subdomains.")
|
||||||
|
|
||||||
|
# ensure value has a trailing dot
|
||||||
|
if not value.endswith("."):
|
||||||
|
value = value + "."
|
||||||
|
|
||||||
|
if not re.search(DOMAIN_RE, value):
|
||||||
|
raise ValueError("Invalid value.")
|
||||||
|
elif rtype in ("CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"):
|
||||||
# anything goes
|
# anything goes
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -767,89 +985,92 @@ 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):
|
||||||
|
resolver = dns.resolver.get_default_resolver()
|
||||||
|
resolver.timeout = 10
|
||||||
|
|
||||||
|
values = []
|
||||||
for qname, rtype, value in custom_dns:
|
for qname, rtype, value in custom_dns:
|
||||||
if qname == "_secondary_nameserver":
|
if qname != '_secondary_nameserver': continue
|
||||||
return value
|
for hostname in value.split(" "):
|
||||||
return None
|
hostname = hostname.strip()
|
||||||
|
if mode == None:
|
||||||
|
# Just return the setting.
|
||||||
|
values.append(hostname)
|
||||||
|
continue
|
||||||
|
|
||||||
def set_secondary_dns(hostname, env):
|
# This is a hostname. Before including in zone xfr lines,
|
||||||
|
# resolve to an IP address. Otherwise just return the hostname.
|
||||||
|
# It may not resolve to IPv6, so don't throw an exception if it
|
||||||
|
# doesn't.
|
||||||
|
if not hostname.startswith("xfr:"):
|
||||||
|
if mode == "xfr":
|
||||||
|
response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False)
|
||||||
|
values.extend(map(str, response))
|
||||||
|
response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False)
|
||||||
|
values.extend(map(str, response))
|
||||||
|
continue
|
||||||
|
values.append(hostname)
|
||||||
|
|
||||||
if hostname in (None, ""):
|
# This is a zone-xfer-only IP address. Do not return if
|
||||||
# Clear.
|
# we're querying for NS record hostnames. Only return if
|
||||||
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
# we're querying for zone xfer IP addresses - return the
|
||||||
else:
|
# IP address.
|
||||||
# Validate.
|
elif mode == "xfr":
|
||||||
hostname = hostname.strip().lower()
|
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 = dns.resolver.get_default_resolver()
|
||||||
try:
|
resolver.timeout = 5
|
||||||
response = dns.resolver.query(hostname, "A")
|
for item in hostnames:
|
||||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
if not item.startswith("xfr:"):
|
||||||
raise ValueError("Could not resolve the IP address of %s." % hostname)
|
# Resolve hostname.
|
||||||
|
try:
|
||||||
|
response = resolver.resolve(item, "A")
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
|
try:
|
||||||
|
response = resolver.resolve(item, "AAAA")
|
||||||
|
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||||
|
raise ValueError("Could not resolve the IP address of %s." % item)
|
||||||
|
else:
|
||||||
|
# Validate IP address.
|
||||||
|
try:
|
||||||
|
if "/" in item[4:]:
|
||||||
|
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
|
||||||
|
else:
|
||||||
|
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("'%s' is not an IPv4 or IPv6 address or subnet." % 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_records(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,
|
yield 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]
|
||||||
|
|
||||||
|
|||||||
60
management/email_administrator.py
Executable file
60
management/email_administrator.py
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
|
||||||
|
# Reads in STDIN. If the stream is not empty, mail it to the system administrator.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import html
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
# In Python 3.6:
|
||||||
|
#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 = MIMEMultipart('alternative')
|
||||||
|
|
||||||
|
# In Python 3.6:
|
||||||
|
#msg = Message()
|
||||||
|
|
||||||
|
msg['From'] = "\"%s\" <%s>" % (env['PRIMARY_HOSTNAME'], admin_addr)
|
||||||
|
msg['To'] = admin_addr
|
||||||
|
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
||||||
|
|
||||||
|
content_html = "<html><body><pre>{}</pre></body></html>".format(html.escape(content))
|
||||||
|
|
||||||
|
msg.attach(MIMEText(content, 'plain'))
|
||||||
|
msg.attach(MIMEText(content_html, 'html'))
|
||||||
|
|
||||||
|
# In Python 3.6:
|
||||||
|
#msg.set_content(content)
|
||||||
|
#msg.add_alternative(content_html, "html")
|
||||||
|
|
||||||
|
# send
|
||||||
|
smtpclient = smtplib.SMTP('127.0.0.1', 25)
|
||||||
|
smtpclient.ehlo()
|
||||||
|
smtpclient.sendmail(
|
||||||
|
admin_addr, # MAIL FROM
|
||||||
|
admin_addr, # RCPT TO
|
||||||
|
msg.as_string())
|
||||||
|
smtpclient.quit()
|
||||||
@@ -1,121 +1,876 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import gzip
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
from collections import defaultdict, OrderedDict
|
||||||
|
|
||||||
import re, os.path
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
import time
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
import mailconfig
|
|
||||||
import utils
|
import utils
|
||||||
|
|
||||||
def scan_mail_log(logger, env):
|
|
||||||
collector = {
|
|
||||||
"other-services": set(),
|
|
||||||
"imap-logins": { },
|
|
||||||
"postgrey": { },
|
|
||||||
"rejected-mail": { },
|
|
||||||
}
|
|
||||||
|
|
||||||
collector["real_mail_addresses"] = set(mailconfig.get_mail_users(env)) | set(alias[0] for alias in mailconfig.get_mail_aliases(env))
|
LOG_FILES = (
|
||||||
|
'/var/log/mail.log.6.gz',
|
||||||
|
'/var/log/mail.log.5.gz',
|
||||||
|
'/var/log/mail.log.4.gz',
|
||||||
|
'/var/log/mail.log.3.gz',
|
||||||
|
'/var/log/mail.log.2.gz',
|
||||||
|
'/var/log/mail.log.1',
|
||||||
|
'/var/log/mail.log',
|
||||||
|
)
|
||||||
|
|
||||||
for fn in ('/var/log/mail.log.1', '/var/log/mail.log'):
|
TIME_DELTAS = OrderedDict([
|
||||||
if not os.path.exists(fn): continue
|
('all', datetime.timedelta(weeks=52)),
|
||||||
with open(fn, 'rb') as log:
|
('month', datetime.timedelta(weeks=4)),
|
||||||
for line in log:
|
('2weeks', datetime.timedelta(days=14)),
|
||||||
line = line.decode("utf8", errors='replace')
|
('week', datetime.timedelta(days=7)),
|
||||||
scan_mail_log_line(line.strip(), collector)
|
('2days', datetime.timedelta(days=2)),
|
||||||
|
('day', datetime.timedelta(days=1)),
|
||||||
|
('12hours', datetime.timedelta(hours=12)),
|
||||||
|
('6hours', datetime.timedelta(hours=6)),
|
||||||
|
('hour', datetime.timedelta(hours=1)),
|
||||||
|
('30min', datetime.timedelta(minutes=30)),
|
||||||
|
('10min', datetime.timedelta(minutes=10)),
|
||||||
|
('5min', datetime.timedelta(minutes=5)),
|
||||||
|
('min', datetime.timedelta(minutes=1)),
|
||||||
|
('today', datetime.datetime.now() - datetime.datetime.now().replace(hour=0, minute=0, second=0))
|
||||||
|
])
|
||||||
|
|
||||||
if collector["imap-logins"]:
|
END_DATE = NOW = datetime.datetime.now()
|
||||||
logger.add_heading("Recent IMAP Logins")
|
START_DATE = None
|
||||||
logger.print_block("The most recent login from each remote IP adddress is show.")
|
|
||||||
for k in utils.sort_email_addresses(collector["imap-logins"], env):
|
|
||||||
for ip, date in sorted(collector["imap-logins"][k].items(), key = lambda kv : kv[1]):
|
|
||||||
logger.print_line(k + "\t" + str(date) + "\t" + ip)
|
|
||||||
|
|
||||||
if collector["postgrey"]:
|
VERBOSE = False
|
||||||
logger.add_heading("Greylisted Mail")
|
|
||||||
logger.print_block("The following mail was greylisted, meaning the emails were temporarily rejected. Legitimate senders will try again within ten minutes.")
|
|
||||||
logger.print_line("recipient" + "\t" + "received" + "\t" + "sender" + "\t" + "delivered")
|
|
||||||
for recipient in utils.sort_email_addresses(collector["postgrey"], env):
|
|
||||||
for (client_address, sender), (first_date, delivered_date) in sorted(collector["postgrey"][recipient].items(), key = lambda kv : kv[1][0]):
|
|
||||||
logger.print_line(recipient + "\t" + str(first_date) + "\t" + sender + "\t" + (("delivered " + str(delivered_date)) if delivered_date else "no retry yet"))
|
|
||||||
|
|
||||||
if collector["rejected-mail"]:
|
# List of strings to filter users with
|
||||||
logger.add_heading("Rejected Mail")
|
FILTERS = None
|
||||||
logger.print_block("The following incoming mail was rejected.")
|
|
||||||
for k in utils.sort_email_addresses(collector["rejected-mail"], env):
|
# What to show (with defaults)
|
||||||
for date, sender, message in collector["rejected-mail"][k]:
|
SCAN_OUT = True # Outgoing email
|
||||||
logger.print_line(k + "\t" + str(date) + "\t" + sender + "\t" + message)
|
SCAN_IN = True # Incoming email
|
||||||
|
SCAN_DOVECOT_LOGIN = True # Dovecot Logins
|
||||||
|
SCAN_GREY = False # Greylisted email
|
||||||
|
SCAN_BLOCKED = False # Rejected email
|
||||||
|
|
||||||
|
|
||||||
|
def scan_files(collector):
|
||||||
|
""" Scan files until they run out or the earliest date is reached """
|
||||||
|
|
||||||
|
stop_scan = False
|
||||||
|
|
||||||
|
for fn in LOG_FILES:
|
||||||
|
|
||||||
|
tmp_file = None
|
||||||
|
|
||||||
|
if not os.path.exists(fn):
|
||||||
|
continue
|
||||||
|
elif fn[-3:] == '.gz':
|
||||||
|
tmp_file = tempfile.NamedTemporaryFile()
|
||||||
|
shutil.copyfileobj(gzip.open(fn), tmp_file)
|
||||||
|
|
||||||
|
if VERBOSE:
|
||||||
|
print("Processing file", fn, "...")
|
||||||
|
fn = tmp_file.name if tmp_file else fn
|
||||||
|
|
||||||
|
for line in readline(fn):
|
||||||
|
if scan_mail_log_line(line.strip(), collector) is False:
|
||||||
|
if stop_scan:
|
||||||
|
return
|
||||||
|
stop_scan = True
|
||||||
|
else:
|
||||||
|
stop_scan = False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def scan_mail_log(env):
|
||||||
|
""" Scan the system's mail log files and collect interesting data
|
||||||
|
|
||||||
|
This function scans the 2 most recent mail log files in /var/log/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env (dict): Dictionary containing MiaB settings
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
collector = {
|
||||||
|
"scan_count": 0, # Number of lines scanned
|
||||||
|
"parse_count": 0, # Number of lines parsed (i.e. that had their contents examined)
|
||||||
|
"scan_time": time.time(), # The time in seconds the scan took
|
||||||
|
"sent_mail": OrderedDict(), # Data about email sent by users
|
||||||
|
"received_mail": OrderedDict(), # Data about email received by users
|
||||||
|
"logins": OrderedDict(), # Data about login activity
|
||||||
|
"postgrey": {}, # Data about greylisting of email addresses
|
||||||
|
"rejected": OrderedDict(), # Emails that were blocked
|
||||||
|
"known_addresses": None, # Addresses handled by the Miab installation
|
||||||
|
"other-services": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mailconfig
|
||||||
|
collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) |
|
||||||
|
set(alias[0] for alias in mailconfig.get_mail_aliases(env)))
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".format(
|
||||||
|
START_DATE, END_DATE)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scan the lines in the log files until the date goes out of range
|
||||||
|
scan_files(collector)
|
||||||
|
|
||||||
|
if not collector["scan_count"]:
|
||||||
|
print("No log lines scanned...")
|
||||||
|
return
|
||||||
|
|
||||||
|
collector["scan_time"] = time.time() - collector["scan_time"]
|
||||||
|
|
||||||
|
print("{scan_count} Log lines scanned, {parse_count} lines parsed in {scan_time:.2f} "
|
||||||
|
"seconds\n".format(**collector))
|
||||||
|
|
||||||
|
# Print Sent Mail report
|
||||||
|
|
||||||
|
if collector["sent_mail"]:
|
||||||
|
msg = "Sent email"
|
||||||
|
print_header(msg)
|
||||||
|
|
||||||
|
data = OrderedDict(sorted(collector["sent_mail"].items(), key=email_sort))
|
||||||
|
|
||||||
|
print_user_table(
|
||||||
|
data.keys(),
|
||||||
|
data=[
|
||||||
|
("sent", [u["sent_count"] for u in data.values()]),
|
||||||
|
("hosts", [len(u["hosts"]) for u in data.values()]),
|
||||||
|
],
|
||||||
|
sub_data=[
|
||||||
|
("sending hosts", [u["hosts"] for u in data.values()]),
|
||||||
|
],
|
||||||
|
activity=[
|
||||||
|
("sent", [u["activity-by-hour"] for u in data.values()]),
|
||||||
|
],
|
||||||
|
earliest=[u["earliest"] for u in data.values()],
|
||||||
|
latest=[u["latest"] for u in data.values()],
|
||||||
|
)
|
||||||
|
|
||||||
|
accum = defaultdict(int)
|
||||||
|
data = collector["sent_mail"].values()
|
||||||
|
|
||||||
|
for h in range(24):
|
||||||
|
accum[h] = sum(d["activity-by-hour"][h] for d in data)
|
||||||
|
|
||||||
|
print_time_table(
|
||||||
|
["sent"],
|
||||||
|
[accum]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print Received Mail report
|
||||||
|
|
||||||
|
if collector["received_mail"]:
|
||||||
|
msg = "Received email"
|
||||||
|
print_header(msg)
|
||||||
|
|
||||||
|
data = OrderedDict(sorted(collector["received_mail"].items(), key=email_sort))
|
||||||
|
|
||||||
|
print_user_table(
|
||||||
|
data.keys(),
|
||||||
|
data=[
|
||||||
|
("received", [u["received_count"] for u in data.values()]),
|
||||||
|
],
|
||||||
|
activity=[
|
||||||
|
("sent", [u["activity-by-hour"] for u in data.values()]),
|
||||||
|
],
|
||||||
|
earliest=[u["earliest"] for u in data.values()],
|
||||||
|
latest=[u["latest"] for u in data.values()],
|
||||||
|
)
|
||||||
|
|
||||||
|
accum = defaultdict(int)
|
||||||
|
for h in range(24):
|
||||||
|
accum[h] = sum(d["activity-by-hour"][h] for d in data.values())
|
||||||
|
|
||||||
|
print_time_table(
|
||||||
|
["received"],
|
||||||
|
[accum]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print login report
|
||||||
|
|
||||||
|
if collector["logins"]:
|
||||||
|
msg = "User logins per hour"
|
||||||
|
print_header(msg)
|
||||||
|
|
||||||
|
data = OrderedDict(sorted(collector["logins"].items(), key=email_sort))
|
||||||
|
|
||||||
|
# Get a list of all of the protocols seen in the logs in reverse count order.
|
||||||
|
all_protocols = defaultdict(int)
|
||||||
|
for u in data.values():
|
||||||
|
for protocol_name, count in u["totals_by_protocol"].items():
|
||||||
|
all_protocols[protocol_name] += count
|
||||||
|
all_protocols = [k for k, v in sorted(all_protocols.items(), key=lambda kv : -kv[1])]
|
||||||
|
|
||||||
|
print_user_table(
|
||||||
|
data.keys(),
|
||||||
|
data=[
|
||||||
|
(protocol_name, [
|
||||||
|
round(u["totals_by_protocol"][protocol_name] / (u["latest"]-u["earliest"]).total_seconds() * 60*60, 1)
|
||||||
|
if (u["latest"]-u["earliest"]).total_seconds() > 0
|
||||||
|
else 0 # prevent division by zero
|
||||||
|
for u in data.values()])
|
||||||
|
for protocol_name in all_protocols
|
||||||
|
],
|
||||||
|
sub_data=[
|
||||||
|
("Protocol and Source", [[
|
||||||
|
"{} {}: {} times".format(protocol_name, host, count)
|
||||||
|
for (protocol_name, host), count
|
||||||
|
in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1])
|
||||||
|
] for u in data.values()])
|
||||||
|
],
|
||||||
|
activity=[
|
||||||
|
(protocol_name, [u["activity-by-hour"][protocol_name] for u in data.values()])
|
||||||
|
for protocol_name in all_protocols
|
||||||
|
],
|
||||||
|
earliest=[u["earliest"] for u in data.values()],
|
||||||
|
latest=[u["latest"] for u in data.values()],
|
||||||
|
numstr=lambda n : str(round(n, 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
accum = { protocol_name: defaultdict(int) for protocol_name in all_protocols }
|
||||||
|
for h in range(24):
|
||||||
|
for protocol_name in all_protocols:
|
||||||
|
accum[protocol_name][h] = sum(d["activity-by-hour"][protocol_name][h] for d in data.values())
|
||||||
|
|
||||||
|
print_time_table(
|
||||||
|
all_protocols,
|
||||||
|
[accum[protocol_name] for protocol_name in all_protocols]
|
||||||
|
)
|
||||||
|
|
||||||
|
if collector["postgrey"]:
|
||||||
|
msg = "Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}"
|
||||||
|
print_header(msg.format(START_DATE, END_DATE))
|
||||||
|
|
||||||
|
print(textwrap.fill(
|
||||||
|
"The following mail was greylisted, meaning the emails were temporarily rejected. "
|
||||||
|
"Legitimate senders must try again after three minutes.",
|
||||||
|
width=80, initial_indent=" ", subsequent_indent=" "
|
||||||
|
), end='\n\n')
|
||||||
|
|
||||||
|
data = OrderedDict(sorted(collector["postgrey"].items(), key=email_sort))
|
||||||
|
users = []
|
||||||
|
received = []
|
||||||
|
senders = []
|
||||||
|
sender_clients = []
|
||||||
|
delivered_dates = []
|
||||||
|
|
||||||
|
for recipient in data:
|
||||||
|
sorted_recipients = sorted(data[recipient].items(), key=lambda kv: kv[1][0] or kv[1][1])
|
||||||
|
for (client_address, sender), (first_date, delivered_date) in sorted_recipients:
|
||||||
|
if first_date:
|
||||||
|
users.append(recipient)
|
||||||
|
received.append(first_date)
|
||||||
|
senders.append(sender)
|
||||||
|
delivered_dates.append(delivered_date)
|
||||||
|
sender_clients.append(client_address)
|
||||||
|
|
||||||
|
print_user_table(
|
||||||
|
users,
|
||||||
|
data=[
|
||||||
|
("received", received),
|
||||||
|
("sender", senders),
|
||||||
|
("delivered", [str(d) or "no retry yet" for d in delivered_dates]),
|
||||||
|
("sending host", sender_clients)
|
||||||
|
],
|
||||||
|
delimit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if collector["rejected"]:
|
||||||
|
msg = "Blocked Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}"
|
||||||
|
print_header(msg.format(START_DATE, END_DATE))
|
||||||
|
|
||||||
|
data = OrderedDict(sorted(collector["rejected"].items(), key=email_sort))
|
||||||
|
|
||||||
|
rejects = []
|
||||||
|
|
||||||
|
if VERBOSE:
|
||||||
|
for user_data in data.values():
|
||||||
|
user_rejects = []
|
||||||
|
for date, sender, message in user_data["blocked"]:
|
||||||
|
if len(sender) > 64:
|
||||||
|
sender = sender[:32] + "…" + sender[-32:]
|
||||||
|
user_rejects.append("%s - %s " % (date, sender))
|
||||||
|
user_rejects.append(" %s" % message)
|
||||||
|
rejects.append(user_rejects)
|
||||||
|
|
||||||
|
print_user_table(
|
||||||
|
data.keys(),
|
||||||
|
data=[
|
||||||
|
("blocked", [len(u["blocked"]) for u in data.values()]),
|
||||||
|
],
|
||||||
|
sub_data=[
|
||||||
|
("blocked emails", rejects),
|
||||||
|
],
|
||||||
|
earliest=[u["earliest"] for u in data.values()],
|
||||||
|
latest=[u["latest"] for u in data.values()],
|
||||||
|
)
|
||||||
|
|
||||||
|
if collector["other-services"] and VERBOSE and False:
|
||||||
|
print_header("Other services")
|
||||||
|
print("The following unkown services were found in the log file.")
|
||||||
|
print(" ", *sorted(list(collector["other-services"])), sep='\n│ ')
|
||||||
|
|
||||||
if len(collector["other-services"]) > 0:
|
|
||||||
logger.add_heading("Other")
|
|
||||||
logger.print_block("Unrecognized services in the log: " + ", ".join(collector["other-services"]))
|
|
||||||
|
|
||||||
def scan_mail_log_line(line, collector):
|
def scan_mail_log_line(line, collector):
|
||||||
m = re.match(r"(\S+ \d+ \d+:\d+:\d+) (\S+) (\S+?)(\[\d+\])?: (.*)", line)
|
""" Scan a log line and extract interesting data """
|
||||||
if not m: return
|
|
||||||
|
|
||||||
date, system, service, pid, log = m.groups()
|
m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)", line)
|
||||||
date = dateutil.parser.parse(date)
|
|
||||||
|
|
||||||
if service == "dovecot":
|
|
||||||
scan_dovecot_line(date, log, collector)
|
|
||||||
|
|
||||||
elif service == "postgrey":
|
if not m:
|
||||||
scan_postgrey_line(date, log, collector)
|
return True
|
||||||
|
|
||||||
elif service == "postfix/smtpd":
|
date, system, service, log = m.groups()
|
||||||
scan_postfix_smtpd_line(date, log, collector)
|
collector["scan_count"] += 1
|
||||||
|
|
||||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
# print()
|
||||||
"postfix/scache", "spampd", "postfix/anvil", "postfix/master",
|
# print("date:", date)
|
||||||
"opendkim", "postfix/lmtp", "postfix/tlsmgr"):
|
# print("host:", system)
|
||||||
# nothing to look at
|
# print("service:", service)
|
||||||
pass
|
# print("log:", log)
|
||||||
|
|
||||||
else:
|
# Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster.
|
||||||
collector["other-services"].add(service)
|
# date = dateutil.parser.parse(date)
|
||||||
|
|
||||||
|
# strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided.
|
||||||
|
# See https://bugs.python.org/issue26460
|
||||||
|
date = datetime.datetime.strptime(str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S')
|
||||||
|
# if log date in future, step back a year
|
||||||
|
if date > NOW:
|
||||||
|
date = date.replace(year = NOW.year - 1)
|
||||||
|
#print("date:", date)
|
||||||
|
|
||||||
|
# Check if the found date is within the time span we are scanning
|
||||||
|
if date > END_DATE:
|
||||||
|
# Don't process, and halt
|
||||||
|
return False
|
||||||
|
elif date < START_DATE:
|
||||||
|
# Don't process, but continue
|
||||||
|
return True
|
||||||
|
|
||||||
|
if service == "postfix/submission/smtpd":
|
||||||
|
if SCAN_OUT:
|
||||||
|
scan_postfix_submission_line(date, log, collector)
|
||||||
|
elif service == "postfix/lmtp":
|
||||||
|
if SCAN_IN:
|
||||||
|
scan_postfix_lmtp_line(date, log, collector)
|
||||||
|
elif service.endswith("-login"):
|
||||||
|
if SCAN_DOVECOT_LOGIN:
|
||||||
|
scan_dovecot_login_line(date, log, collector, service[:4])
|
||||||
|
elif service == "postgrey":
|
||||||
|
if SCAN_GREY:
|
||||||
|
scan_postgrey_line(date, log, collector)
|
||||||
|
elif service == "postfix/smtpd":
|
||||||
|
if SCAN_BLOCKED:
|
||||||
|
scan_postfix_smtpd_line(date, log, collector)
|
||||||
|
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache",
|
||||||
|
"spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp",
|
||||||
|
"postfix/tlsmgr", "anvil"):
|
||||||
|
# nothing to look at
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
collector["other-services"].add(service)
|
||||||
|
return True
|
||||||
|
|
||||||
|
collector["parse_count"] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
def scan_dovecot_line(date, log, collector):
|
|
||||||
m = re.match("imap-login: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log)
|
|
||||||
if m:
|
|
||||||
login, ip = m.group(1), m.group(2)
|
|
||||||
if ip != "127.0.0.1": # local login from webmail/zpush
|
|
||||||
collector["imap-logins"].setdefault(login, {})[ip] = date
|
|
||||||
|
|
||||||
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)
|
""" Scan a postgrey log line and extract interesting data """
|
||||||
if m:
|
|
||||||
action, reason, client_name, client_address, sender, recipient = m.groups()
|
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
|
||||||
key = (client_address, sender)
|
"client_address=(.*), sender=(.*), recipient=(.*)",
|
||||||
if action == "greylist" and reason == "new":
|
log)
|
||||||
collector["postgrey"].setdefault(recipient, {})[key] = (date, None)
|
|
||||||
elif action == "pass" and reason == "triplet found" and key in collector["postgrey"].get(recipient, {}):
|
if m:
|
||||||
collector["postgrey"][recipient][key] = (collector["postgrey"][recipient][key][0], date)
|
|
||||||
|
action, reason, client_name, client_address, sender, user = m.groups()
|
||||||
|
|
||||||
|
if user_match(user):
|
||||||
|
|
||||||
|
# Might be useful to group services that use a lot of mail different servers on sub
|
||||||
|
# domains like <sub>1.domein.com
|
||||||
|
|
||||||
|
# if '.' in client_name:
|
||||||
|
# addr = client_name.split('.')
|
||||||
|
# if len(addr) > 2:
|
||||||
|
# client_name = '.'.join(addr[1:])
|
||||||
|
|
||||||
|
key = (client_address if client_name == 'unknown' else client_name, sender)
|
||||||
|
|
||||||
|
rep = collector["postgrey"].setdefault(user, {})
|
||||||
|
|
||||||
|
if action == "greylist" and reason == "new":
|
||||||
|
rep[key] = (date, rep[key][1] if key in rep else None)
|
||||||
|
elif action == "pass":
|
||||||
|
rep[key] = (rep[key][0] if key in rep else None, date)
|
||||||
|
|
||||||
|
|
||||||
def scan_postfix_smtpd_line(date, log, collector):
|
def scan_postfix_smtpd_line(date, log, collector):
|
||||||
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
|
""" Scan a postfix smtpd log line and extract interesting data """
|
||||||
if m:
|
|
||||||
message, sender, recipient = m.groups()
|
|
||||||
if recipient in collector["real_mail_addresses"]:
|
|
||||||
# only log mail to real recipients
|
|
||||||
|
|
||||||
# skip this, is reported in the greylisting report
|
# Check if the incoming mail was rejected
|
||||||
if "Recipient address rejected: Greylisted" in message:
|
|
||||||
return
|
|
||||||
|
|
||||||
# simplify this one
|
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
|
||||||
m = re.search(r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message)
|
|
||||||
if m:
|
|
||||||
message = "ip blocked: " + m.group(2)
|
|
||||||
|
|
||||||
# simplify this one too
|
if m:
|
||||||
m = re.search(r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message)
|
message, sender, user = m.groups()
|
||||||
if m:
|
|
||||||
message = "domain blocked: " + m.group(2)
|
|
||||||
|
|
||||||
collector["rejected-mail"].setdefault(recipient, []).append( (date, sender, message) )
|
# skip this, if reported in the greylisting report
|
||||||
|
if "Recipient address rejected: Greylisted" in message:
|
||||||
|
return
|
||||||
|
|
||||||
|
# only log mail to known recipients
|
||||||
|
if user_match(user):
|
||||||
|
if collector["known_addresses"] is None or user in collector["known_addresses"]:
|
||||||
|
data = collector["rejected"].get(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
"blocked": [],
|
||||||
|
"earliest": None,
|
||||||
|
"latest": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# simplify this one
|
||||||
|
m = re.search(
|
||||||
|
r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
message = "ip blocked: " + m.group(2)
|
||||||
|
else:
|
||||||
|
# simplify this one too
|
||||||
|
m = re.search(
|
||||||
|
r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
message = "domain blocked: " + m.group(2)
|
||||||
|
|
||||||
|
if data["earliest"] is None:
|
||||||
|
data["earliest"] = date
|
||||||
|
data["latest"] = date
|
||||||
|
data["blocked"].append((date, sender, message))
|
||||||
|
|
||||||
|
collector["rejected"][user] = data
|
||||||
|
|
||||||
|
|
||||||
|
def scan_dovecot_login_line(date, log, collector, protocol_name):
|
||||||
|
""" Scan a dovecot login log line and extract interesting data """
|
||||||
|
|
||||||
|
m = re.match("Info: Login: user=<(.*?)>, method=PLAIN, rip=(.*?),", log)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
# TODO: CHECK DIT
|
||||||
|
user, host = m.groups()
|
||||||
|
|
||||||
|
if user_match(user):
|
||||||
|
add_login(user, date, protocol_name, host, collector)
|
||||||
|
|
||||||
|
|
||||||
|
def add_login(user, date, protocol_name, host, collector):
|
||||||
|
# Get the user data, or create it if the user is new
|
||||||
|
data = collector["logins"].get(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
"earliest": None,
|
||||||
|
"latest": None,
|
||||||
|
"totals_by_protocol": defaultdict(int),
|
||||||
|
"totals_by_protocol_and_host": defaultdict(int),
|
||||||
|
"activity-by-hour": defaultdict(lambda : defaultdict(int)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data["earliest"] is None:
|
||||||
|
data["earliest"] = date
|
||||||
|
data["latest"] = date
|
||||||
|
|
||||||
|
data["totals_by_protocol"][protocol_name] += 1
|
||||||
|
data["totals_by_protocol_and_host"][(protocol_name, host)] += 1
|
||||||
|
|
||||||
|
if host not in ("127.0.0.1", "::1") or True:
|
||||||
|
data["activity-by-hour"][protocol_name][date.hour] += 1
|
||||||
|
|
||||||
|
collector["logins"][user] = data
|
||||||
|
|
||||||
|
|
||||||
|
def scan_postfix_lmtp_line(date, log, collector):
|
||||||
|
""" Scan a postfix lmtp log line and extract interesting data
|
||||||
|
|
||||||
|
It is assumed that every log of postfix/lmtp indicates an email that was successfully
|
||||||
|
received by Postfix.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
m = re.match("([A-Z0-9]+): to=<(\S+)>, .* Saved", log)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
_, user = m.groups()
|
||||||
|
|
||||||
|
if user_match(user):
|
||||||
|
# Get the user data, or create it if the user is new
|
||||||
|
data = collector["received_mail"].get(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
"received_count": 0,
|
||||||
|
"earliest": None,
|
||||||
|
"latest": None,
|
||||||
|
"activity-by-hour": defaultdict(int),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data["received_count"] += 1
|
||||||
|
data["activity-by-hour"][date.hour] += 1
|
||||||
|
|
||||||
|
if data["earliest"] is None:
|
||||||
|
data["earliest"] = date
|
||||||
|
data["latest"] = date
|
||||||
|
|
||||||
|
collector["received_mail"][user] = data
|
||||||
|
|
||||||
|
|
||||||
|
def scan_postfix_submission_line(date, log, collector):
|
||||||
|
""" Scan a postfix submission log line and extract interesting data
|
||||||
|
|
||||||
|
Lines containing a sasl_method with the values PLAIN or LOGIN are assumed to indicate a sent
|
||||||
|
email.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Match both the 'plain' and 'login' sasl methods, since both authentication methods are
|
||||||
|
# allowed by Dovecot. Exclude trailing comma after the username when additional fields
|
||||||
|
# follow after.
|
||||||
|
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log)
|
||||||
|
|
||||||
|
if m:
|
||||||
|
_, client, method, user = m.groups()
|
||||||
|
|
||||||
|
if user_match(user):
|
||||||
|
# Get the user data, or create it if the user is new
|
||||||
|
data = collector["sent_mail"].get(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
"sent_count": 0,
|
||||||
|
"hosts": set(),
|
||||||
|
"earliest": None,
|
||||||
|
"latest": None,
|
||||||
|
"activity-by-hour": defaultdict(int),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data["sent_count"] += 1
|
||||||
|
data["hosts"].add(client)
|
||||||
|
data["activity-by-hour"][date.hour] += 1
|
||||||
|
|
||||||
|
if data["earliest"] is None:
|
||||||
|
data["earliest"] = date
|
||||||
|
data["latest"] = date
|
||||||
|
|
||||||
|
collector["sent_mail"][user] = data
|
||||||
|
|
||||||
|
# Also log this as a login.
|
||||||
|
add_login(user, date, "smtp", client, collector)
|
||||||
|
|
||||||
|
# Utility functions
|
||||||
|
|
||||||
|
def readline(filename):
|
||||||
|
""" A generator that returns the lines of a file
|
||||||
|
"""
|
||||||
|
with open(filename, errors='replace') as file:
|
||||||
|
while True:
|
||||||
|
line = file.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
def user_match(user):
|
||||||
|
""" Check if the given user matches any of the filters """
|
||||||
|
return FILTERS is None or any(u in user for u in FILTERS)
|
||||||
|
|
||||||
|
|
||||||
|
def email_sort(email):
|
||||||
|
""" Split the given email address into a reverse order tuple, for sorting i.e (domain, name) """
|
||||||
|
return tuple(reversed(email[0].split('@')))
|
||||||
|
|
||||||
|
|
||||||
|
def valid_date(string):
|
||||||
|
""" Validate the given date string fetched from the --enddate argument """
|
||||||
|
try:
|
||||||
|
date = dateutil.parser.parse(string)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" % string)
|
||||||
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
# Print functions
|
||||||
|
|
||||||
|
def print_time_table(labels, data, do_print=True):
|
||||||
|
labels.insert(0, "hour")
|
||||||
|
data.insert(0, [str(h) for h in range(24)])
|
||||||
|
|
||||||
|
temp = "│ {:<%d} " % max(len(l) for l in labels)
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
lines.append(temp.format(label))
|
||||||
|
|
||||||
|
for h in range(24):
|
||||||
|
max_len = max(len(str(d[h])) for d in data)
|
||||||
|
base = "{:>%d} " % max(2, max_len)
|
||||||
|
|
||||||
|
for i, d in enumerate(data):
|
||||||
|
lines[i] += base.format(d[h])
|
||||||
|
|
||||||
|
lines.insert(0, "┬ totals by time of day:")
|
||||||
|
lines.append("└" + (len(lines[-1]) - 2) * "─")
|
||||||
|
|
||||||
|
if do_print:
|
||||||
|
print("\n".join(lines))
|
||||||
|
else:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None,
|
||||||
|
delimit=False, numstr=str):
|
||||||
|
str_temp = "{:<32} "
|
||||||
|
lines = []
|
||||||
|
data = data or []
|
||||||
|
|
||||||
|
col_widths = len(data) * [0]
|
||||||
|
col_left = len(data) * [False]
|
||||||
|
vert_pos = 0
|
||||||
|
|
||||||
|
do_accum = all(isinstance(n, (int, float)) for _, d in data for n in d)
|
||||||
|
data_accum = len(data) * ([0] if do_accum else [" "])
|
||||||
|
|
||||||
|
last_user = None
|
||||||
|
|
||||||
|
for row, user in enumerate(users):
|
||||||
|
|
||||||
|
if delimit:
|
||||||
|
if last_user and last_user != user:
|
||||||
|
lines.append(len(lines[-1]) * "…")
|
||||||
|
last_user = user
|
||||||
|
|
||||||
|
line = "{:<32} ".format(user[:31] + "…" if len(user) > 32 else user)
|
||||||
|
|
||||||
|
for col, (l, d) in enumerate(data):
|
||||||
|
if isinstance(d[row], str):
|
||||||
|
col_str = str_temp.format(d[row][:31] + "…" if len(d[row]) > 32 else d[row])
|
||||||
|
col_left[col] = True
|
||||||
|
elif isinstance(d[row], datetime.datetime):
|
||||||
|
col_str = "{:<20}".format(str(d[row]))
|
||||||
|
col_left[col] = True
|
||||||
|
else:
|
||||||
|
temp = "{:>%s}" % max(5, len(l) + 1, len(str(d[row])) + 1)
|
||||||
|
col_str = temp.format(str(d[row]))
|
||||||
|
col_widths[col] = max(col_widths[col], len(col_str))
|
||||||
|
line += col_str
|
||||||
|
|
||||||
|
if do_accum:
|
||||||
|
data_accum[col] += d[row]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if None not in [latest, earliest]:
|
||||||
|
vert_pos = len(line)
|
||||||
|
e = earliest[row]
|
||||||
|
l = latest[row]
|
||||||
|
timespan = relativedelta(l, e)
|
||||||
|
if timespan.months:
|
||||||
|
temp = " │ {:0.1f} months"
|
||||||
|
line += temp.format(timespan.months + timespan.days / 30.0)
|
||||||
|
elif timespan.days:
|
||||||
|
temp = " │ {:0.1f} days"
|
||||||
|
line += temp.format(timespan.days + timespan.hours / 24.0)
|
||||||
|
elif (e.hour, e.minute) == (l.hour, l.minute):
|
||||||
|
temp = " │ {:%H:%M}"
|
||||||
|
line += temp.format(e)
|
||||||
|
else:
|
||||||
|
temp = " │ {:%H:%M} - {:%H:%M}"
|
||||||
|
line += temp.format(e, l)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(line.rstrip())
|
||||||
|
|
||||||
|
try:
|
||||||
|
if VERBOSE:
|
||||||
|
if sub_data is not None:
|
||||||
|
for l, d in sub_data:
|
||||||
|
if d[row]:
|
||||||
|
lines.append("┬")
|
||||||
|
lines.append("│ %s" % l)
|
||||||
|
lines.append("├─%s─" % (len(l) * "─"))
|
||||||
|
lines.append("│")
|
||||||
|
max_len = 0
|
||||||
|
for v in list(d[row]):
|
||||||
|
lines.append("│ %s" % v)
|
||||||
|
max_len = max(max_len, len(v))
|
||||||
|
lines.append("└" + (max_len + 1) * "─")
|
||||||
|
|
||||||
|
if activity is not None:
|
||||||
|
lines.extend(print_time_table(
|
||||||
|
[label for label, _ in activity],
|
||||||
|
[data[row] for _, data in activity],
|
||||||
|
do_print=False
|
||||||
|
))
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
header = str_temp.format("")
|
||||||
|
|
||||||
|
for col, (l, _) in enumerate(data):
|
||||||
|
if col_left[col]:
|
||||||
|
header += l.ljust(max(5, len(l) + 1, col_widths[col]))
|
||||||
|
else:
|
||||||
|
header += l.rjust(max(5, len(l) + 1, col_widths[col]))
|
||||||
|
|
||||||
|
if None not in (latest, earliest):
|
||||||
|
header += " │ timespan "
|
||||||
|
|
||||||
|
lines.insert(0, header.rstrip())
|
||||||
|
|
||||||
|
table_width = max(len(l) for l in lines)
|
||||||
|
t_line = table_width * "─"
|
||||||
|
b_line = table_width * "─"
|
||||||
|
|
||||||
|
if vert_pos:
|
||||||
|
t_line = t_line[:vert_pos + 1] + "┼" + t_line[vert_pos + 2:]
|
||||||
|
b_line = b_line[:vert_pos + 1] + ("┬" if VERBOSE else "┼") + b_line[vert_pos + 2:]
|
||||||
|
|
||||||
|
lines.insert(1, t_line)
|
||||||
|
lines.append(b_line)
|
||||||
|
|
||||||
|
# Print totals
|
||||||
|
|
||||||
|
data_accum = [numstr(a) for a in data_accum]
|
||||||
|
footer = str_temp.format("Totals:" if do_accum else " ")
|
||||||
|
for row, (l, _) in enumerate(data):
|
||||||
|
temp = "{:>%d}" % max(5, len(l) + 1)
|
||||||
|
footer += temp.format(data_accum[row])
|
||||||
|
|
||||||
|
try:
|
||||||
|
if None not in [latest, earliest]:
|
||||||
|
max_l = max(latest)
|
||||||
|
min_e = min(earliest)
|
||||||
|
timespan = relativedelta(max_l, min_e)
|
||||||
|
if timespan.days:
|
||||||
|
temp = " │ {:0.2f} days"
|
||||||
|
footer += temp.format(timespan.days + timespan.hours / 24.0)
|
||||||
|
elif (min_e.hour, min_e.minute) == (max_l.hour, max_l.minute):
|
||||||
|
temp = " │ {:%H:%M}"
|
||||||
|
footer += temp.format(min_e)
|
||||||
|
else:
|
||||||
|
temp = " │ {:%H:%M} - {:%H:%M}"
|
||||||
|
footer += temp.format(min_e, max_l)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(footer)
|
||||||
|
|
||||||
|
print("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def print_header(msg):
|
||||||
|
print('\n' + msg)
|
||||||
|
print("═" * len(msg), '\n')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from status_checks import ConsoleOutput
|
try:
|
||||||
env = utils.load_environment()
|
env_vars = utils.load_environment()
|
||||||
scan_mail_log(ConsoleOutput(), env)
|
except FileNotFoundError:
|
||||||
|
env_vars = {}
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Scan the mail log files for interesting data. By default, this script "
|
||||||
|
"shows today's incoming and outgoing mail statistics. This script was ("
|
||||||
|
"re)written for the Mail-in-a-box email server."
|
||||||
|
"https://github.com/mail-in-a-box/mailinabox",
|
||||||
|
add_help=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switches to determine what to parse and what to ignore
|
||||||
|
|
||||||
|
parser.add_argument("-r", "--received", help="Scan for received emails.",
|
||||||
|
action="store_true")
|
||||||
|
parser.add_argument("-s", "--sent", help="Scan for sent emails.",
|
||||||
|
action="store_true")
|
||||||
|
parser.add_argument("-l", "--logins", help="Scan for user logins to IMAP/POP3.",
|
||||||
|
action="store_true")
|
||||||
|
parser.add_argument("-g", "--grey", help="Scan for greylisted emails.",
|
||||||
|
action="store_true")
|
||||||
|
parser.add_argument("-b", "--blocked", help="Scan for blocked emails.",
|
||||||
|
action="store_true")
|
||||||
|
|
||||||
|
parser.add_argument("-t", "--timespan", choices=TIME_DELTAS.keys(), default='today',
|
||||||
|
metavar='<time span>',
|
||||||
|
help="Time span to scan, going back from the end date. Possible values: "
|
||||||
|
"{}. Defaults to 'today'.".format(", ".join(list(TIME_DELTAS.keys()))))
|
||||||
|
# keep the --startdate arg for backward compatibility
|
||||||
|
parser.add_argument("-d", "--enddate", "--startdate", action="store", dest="enddate",
|
||||||
|
type=valid_date, metavar='<end date>',
|
||||||
|
help="Date and time to end scanning the log file. If no date is "
|
||||||
|
"provided, scanning will end at the current date and time. "
|
||||||
|
"Alias --startdate is for compatibility.")
|
||||||
|
parser.add_argument("-u", "--users", action="store", dest="users",
|
||||||
|
metavar='<email1,email2,email...>',
|
||||||
|
help="Comma separated list of (partial) email addresses to filter the "
|
||||||
|
"output with.")
|
||||||
|
|
||||||
|
parser.add_argument('-h', '--help', action='help', help="Print this message and exit.")
|
||||||
|
parser.add_argument("-v", "--verbose", help="Output extra data where available.",
|
||||||
|
action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.enddate is not None:
|
||||||
|
END_DATE = args.enddate
|
||||||
|
if args.timespan == 'today':
|
||||||
|
args.timespan = 'day'
|
||||||
|
print("Setting end date to {}".format(END_DATE))
|
||||||
|
|
||||||
|
START_DATE = END_DATE - TIME_DELTAS[args.timespan]
|
||||||
|
|
||||||
|
VERBOSE = args.verbose
|
||||||
|
|
||||||
|
if args.received or args.sent or args.logins or args.grey or args.blocked:
|
||||||
|
SCAN_IN = args.received
|
||||||
|
if not SCAN_IN:
|
||||||
|
print("Ignoring received emails")
|
||||||
|
|
||||||
|
SCAN_OUT = args.sent
|
||||||
|
if not SCAN_OUT:
|
||||||
|
print("Ignoring sent emails")
|
||||||
|
|
||||||
|
SCAN_DOVECOT_LOGIN = args.logins
|
||||||
|
if not SCAN_DOVECOT_LOGIN:
|
||||||
|
print("Ignoring logins")
|
||||||
|
|
||||||
|
SCAN_GREY = args.grey
|
||||||
|
if SCAN_GREY:
|
||||||
|
print("Showing greylisted emails")
|
||||||
|
|
||||||
|
SCAN_BLOCKED = args.blocked
|
||||||
|
if SCAN_BLOCKED:
|
||||||
|
print("Showing blocked emails")
|
||||||
|
|
||||||
|
if args.users is not None:
|
||||||
|
FILTERS = args.users.strip().split(',')
|
||||||
|
|
||||||
|
scan_mail_log(env_vars)
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
|
||||||
|
# NOTE:
|
||||||
|
# This script is run both using the system-wide Python 3
|
||||||
|
# interpreter (/usr/bin/python3) as well as through the
|
||||||
|
# virtualenv (/usr/local/lib/mailinabox/env). So only
|
||||||
|
# import packages at the top level of this script that
|
||||||
|
# are installed in *both* contexts. We use the system-wide
|
||||||
|
# Python 3 in setup/questions.sh to validate the email
|
||||||
|
# address entered by the user.
|
||||||
|
|
||||||
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.
|
||||||
# Until Postfix supports SMTPUTF8, an email address may contain ASCII
|
# An email address may contain ASCII characters only because Dovecot's
|
||||||
# characters only; IDNs must be IDNA-encoded.
|
# authentication mechanism gets confused with other character encodings.
|
||||||
#
|
#
|
||||||
# When mode=="user", we're checking that this can be a user account name.
|
# When mode=="user", we're checking that this can be a user account name.
|
||||||
# Dovecot has tighter restrictions - letters, numbers, underscore, and
|
# Dovecot has tighter restrictions - letters, numbers, underscore, and
|
||||||
@@ -52,11 +62,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 +77,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
|
||||||
@@ -92,7 +105,7 @@ def get_mail_users(env):
|
|||||||
users = [ row[0] for row in c.fetchall() ]
|
users = [ row[0] for row in c.fetchall() ]
|
||||||
return utils.sort_email_addresses(users, env)
|
return utils.sort_email_addresses(users, env)
|
||||||
|
|
||||||
def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
|
def get_mail_users_ex(env, with_archived=False):
|
||||||
# Returns a complex data structure of all user accounts, optionally
|
# Returns a complex data structure of all user accounts, optionally
|
||||||
# including archived (status="inactive") accounts.
|
# including archived (status="inactive") accounts.
|
||||||
#
|
#
|
||||||
@@ -126,26 +139,22 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
|
|||||||
}
|
}
|
||||||
users.append(user)
|
users.append(user)
|
||||||
|
|
||||||
if with_slow_info:
|
|
||||||
user["mailbox_size"] = utils.du(os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', *reversed(email.split("@"))))
|
|
||||||
|
|
||||||
# Add in archived accounts.
|
# Add in archived accounts.
|
||||||
if with_archived:
|
if with_archived:
|
||||||
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes')
|
||||||
for domain in os.listdir(root):
|
for domain in os.listdir(root):
|
||||||
for user in os.listdir(os.path.join(root, domain)):
|
if os.path.isdir(os.path.join(root, domain)):
|
||||||
email = user + "@" + domain
|
for user in os.listdir(os.path.join(root, domain)):
|
||||||
mbox = os.path.join(root, domain, user)
|
email = user + "@" + domain
|
||||||
if email in active_accounts: continue
|
mbox = os.path.join(root, domain, user)
|
||||||
user = {
|
if email in active_accounts: continue
|
||||||
"email": email,
|
user = {
|
||||||
"privileges": "",
|
"email": email,
|
||||||
"status": "inactive",
|
"privileges": [],
|
||||||
"mailbox": mbox,
|
"status": "inactive",
|
||||||
}
|
"mailbox": mbox,
|
||||||
users.append(user)
|
}
|
||||||
if with_slow_info:
|
users.append(user)
|
||||||
user["mailbox_size"] = utils.du(mbox)
|
|
||||||
|
|
||||||
# Group by domain.
|
# Group by domain.
|
||||||
domains = { }
|
domains = { }
|
||||||
@@ -177,13 +186,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, auto).
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT source, destination FROM aliases')
|
c.execute('SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases')
|
||||||
aliases = { row[0]: row[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,10 +204,11 @@ 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", ...],
|
||||||
# required: True|False
|
# permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null,
|
||||||
|
# auto: True|False
|
||||||
# },
|
# },
|
||||||
# ...
|
# ...
|
||||||
# ]
|
# ]
|
||||||
@@ -206,12 +216,13 @@ def get_mail_aliases_ex(env):
|
|||||||
# ...
|
# ...
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
required_aliases = get_required_aliases(env)
|
|
||||||
domains = {}
|
domains = {}
|
||||||
for source, destination in get_mail_aliases(env):
|
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
||||||
|
# skip auto domain maps since these are not informative in the control panel's aliases list
|
||||||
|
if auto and address.startswith("@"): continue
|
||||||
|
|
||||||
# get alias info
|
# get alias info
|
||||||
domain = get_domain(source)
|
domain = get_domain(address)
|
||||||
required = ((source in required_aliases) or (source == get_system_administrator(env)))
|
|
||||||
|
|
||||||
# add to list
|
# add to list
|
||||||
if not domain in domains:
|
if not domain in domains:
|
||||||
@@ -220,34 +231,44 @@ 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(",")],
|
||||||
"required": required,
|
"permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None,
|
||||||
|
"auto": bool(auto),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort domains.
|
# 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["auto"], 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, users_only=False):
|
||||||
# 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. If users_only is True, only return domains
|
||||||
return set(
|
# with email addresses that correspond to user accounts. Exclude Unicode
|
||||||
[get_domain(addr, as_unicode=False) for addr in get_mail_users(env)]
|
# forms of domain names listed in the automatic aliases table.
|
||||||
+ [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ]
|
domains = []
|
||||||
)
|
domains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])
|
||||||
|
if not users_only:
|
||||||
|
domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ])
|
||||||
|
return set(domains)
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, env):
|
def add_mail_user(email, pw, privs, env):
|
||||||
# validate email
|
# validate email
|
||||||
@@ -291,25 +312,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 +404,93 @@ 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
|
||||||
|
# Strip any +tag from email alias and check privileges
|
||||||
|
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',email)
|
||||||
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(privileged_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,21 +499,28 @@ 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:
|
||||||
# Update things in case any domains are removed.
|
# Update things in case any domains are removed.
|
||||||
return kick(env, "alias removed")
|
return kick(env, "alias removed")
|
||||||
|
|
||||||
|
def add_auto_aliases(aliases, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute("DELETE FROM auto_aliases");
|
||||||
|
for source, destination in aliases.items():
|
||||||
|
c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def get_system_administrator(env):
|
def get_system_administrator(env):
|
||||||
return "administrator@" + env['PRIMARY_HOSTNAME']
|
return "administrator@" + env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
@@ -493,25 +528,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
|
||||||
|
|
||||||
@@ -523,39 +564,36 @@ def kick(env, mail_result=None):
|
|||||||
if mail_result is not None:
|
if mail_result is not None:
|
||||||
results.append(mail_result + "\n")
|
results.append(mail_result + "\n")
|
||||||
|
|
||||||
# Ensure every required alias exists.
|
auto_aliases = { }
|
||||||
|
|
||||||
existing_users = get_mail_users(env)
|
# Mape required aliases to the administrator alias (which should be created manually).
|
||||||
existing_aliases = get_mail_aliases(env)
|
administrator = get_system_administrator(env)
|
||||||
required_aliases = get_required_aliases(env)
|
required_aliases = get_required_aliases(env)
|
||||||
|
|
||||||
def ensure_admin_alias_exists(source):
|
|
||||||
# If a user account exists with that address, we're good.
|
|
||||||
if source in existing_users:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Does this alias exists?
|
|
||||||
for s, t in existing_aliases:
|
|
||||||
if s == source:
|
|
||||||
return
|
|
||||||
# Doesn't exist.
|
|
||||||
administrator = get_system_administrator(env)
|
|
||||||
add_mail_alias(source, administrator, env, do_kick=False)
|
|
||||||
results.append("added alias %s (=> %s)\n" % (source, administrator))
|
|
||||||
|
|
||||||
|
|
||||||
for alias in required_aliases:
|
for alias in required_aliases:
|
||||||
ensure_admin_alias_exists(alias)
|
if alias == administrator: continue # don't make an alias from the administrator to itself --- this alias must be created manually
|
||||||
|
auto_aliases[alias] = administrator
|
||||||
|
|
||||||
# Remove auto-generated postmaster/admin on domains we no
|
# Add domain maps from Unicode forms of IDNA domains to the ASCII forms stored in the alias table.
|
||||||
# longer have any other email addresses for.
|
for domain in get_mail_domains(env):
|
||||||
for source, target in existing_aliases:
|
try:
|
||||||
user, domain = source.split("@")
|
domain_unicode = idna.decode(domain.encode("ascii"))
|
||||||
if user in ("postmaster", "admin") \
|
if domain == domain_unicode: continue # not an IDNA/Unicode domain
|
||||||
and source not in required_aliases \
|
auto_aliases["@" + domain_unicode] = "@" + domain
|
||||||
and target == get_system_administrator(env):
|
except (ValueError, UnicodeError, idna.IDNAError):
|
||||||
remove_mail_alias(source, env, do_kick=False)
|
continue
|
||||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target))
|
|
||||||
|
add_auto_aliases(auto_aliases, env)
|
||||||
|
|
||||||
|
# Remove auto-generated postmaster/admin/abuse alises from the main aliases table.
|
||||||
|
# They are now stored in the auto_aliases table.
|
||||||
|
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
||||||
|
user, domain = address.split("@")
|
||||||
|
if user in ("postmaster", "admin", "abuse") \
|
||||||
|
and address not in required_aliases \
|
||||||
|
and forwards_to == get_system_administrator(env) \
|
||||||
|
and not auto:
|
||||||
|
remove_mail_alias(address, env, do_kick=False)
|
||||||
|
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to))
|
||||||
|
|
||||||
# Update DNS and nginx in case any domains are added/removed.
|
# Update DNS and nginx in case any domains are added/removed.
|
||||||
|
|
||||||
@@ -571,11 +609,8 @@ def validate_password(pw):
|
|||||||
# validate password
|
# validate password
|
||||||
if pw.strip() == "":
|
if pw.strip() == "":
|
||||||
raise ValueError("No password provided.")
|
raise ValueError("No password provided.")
|
||||||
if re.search(r"[\s]", pw):
|
if len(pw) < 8:
|
||||||
raise ValueError("Passwords cannot contain spaces.")
|
raise ValueError("Passwords must be at least eight characters.")
|
||||||
if len(pw) < 4:
|
|
||||||
raise ValueError("Passwords must be at least four characters.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
141
management/mfa.py
Normal file
141
management/mfa.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
from mailconfig import open_database
|
||||||
|
|
||||||
|
def get_user_id(email, c):
|
||||||
|
c.execute('SELECT id FROM users WHERE email=?', (email,))
|
||||||
|
r = c.fetchone()
|
||||||
|
if not r: raise ValueError("User does not exist.")
|
||||||
|
return r[0]
|
||||||
|
|
||||||
|
def get_mfa_state(email, env):
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
return [
|
||||||
|
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
|
||||||
|
for r in c.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_public_mfa_state(email, env):
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
return [
|
||||||
|
{ "id": s["id"], "type": s["type"], "label": s["label"] }
|
||||||
|
for s in mfa_state
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_hash_mfa_state(email, env):
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
return [
|
||||||
|
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
|
||||||
|
for s in mfa_state
|
||||||
|
]
|
||||||
|
|
||||||
|
def enable_mfa(email, type, secret, token, label, env):
|
||||||
|
if type == "totp":
|
||||||
|
validate_totp_secret(secret)
|
||||||
|
# Sanity check with the provide current token.
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
raise ValueError("Invalid token.")
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid MFA type.")
|
||||||
|
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def set_mru_token(email, mfa_id, token, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def disable_mfa(email, mfa_id, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
if mfa_id is None:
|
||||||
|
# Disable all MFA for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
|
else:
|
||||||
|
# Disable a particular MFA mode for a user.
|
||||||
|
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
|
||||||
|
conn.commit()
|
||||||
|
return c.rowcount > 0
|
||||||
|
|
||||||
|
def validate_totp_secret(secret):
|
||||||
|
if type(secret) != str or secret.strip() == "":
|
||||||
|
raise ValueError("No secret provided.")
|
||||||
|
if len(secret) != 32:
|
||||||
|
raise ValueError("Secret should be a 32 characters base32 string")
|
||||||
|
|
||||||
|
def provision_totp(email, env):
|
||||||
|
# Make a new secret.
|
||||||
|
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||||
|
validate_totp_secret(secret) # sanity check
|
||||||
|
|
||||||
|
# Make a URI that we encode within a QR code.
|
||||||
|
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||||
|
name=email,
|
||||||
|
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate a QR code as a base64-encode PNG image.
|
||||||
|
qr = qrcode.make(uri)
|
||||||
|
byte_arr = io.BytesIO()
|
||||||
|
qr.save(byte_arr, format='PNG')
|
||||||
|
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "totp",
|
||||||
|
"secret": secret,
|
||||||
|
"qr_code_base64": png_b64
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_auth_mfa(email, request, env):
|
||||||
|
# Validates that a login request satisfies any MFA modes
|
||||||
|
# that have been enabled for the user's account. Returns
|
||||||
|
# a tuple (status, [hints]). status is True for a successful
|
||||||
|
# MFA login, False for a missing token. If status is False,
|
||||||
|
# hints is an array of codes that indicate what the user
|
||||||
|
# can try. Possible codes are:
|
||||||
|
# "missing-totp-token"
|
||||||
|
# "invalid-totp-token"
|
||||||
|
|
||||||
|
mfa_state = get_mfa_state(email, env)
|
||||||
|
|
||||||
|
# If no MFA modes are added, return True.
|
||||||
|
if len(mfa_state) == 0:
|
||||||
|
return (True, [])
|
||||||
|
|
||||||
|
# Try the enabled MFA modes.
|
||||||
|
hints = set()
|
||||||
|
for mfa_mode in mfa_state:
|
||||||
|
if mfa_mode["type"] == "totp":
|
||||||
|
# Check that a token is present in the X-Auth-Token header.
|
||||||
|
# If not, give a hint that one can be supplied.
|
||||||
|
token = request.headers.get('x-auth-token')
|
||||||
|
if not token:
|
||||||
|
hints.add("missing-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for a replay attack.
|
||||||
|
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
|
||||||
|
# If the token fails, skip this MFA mode.
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check the token.
|
||||||
|
totp = pyotp.TOTP(mfa_mode["secret"])
|
||||||
|
if not totp.verify(token, valid_window=1):
|
||||||
|
hints.add("invalid-totp-token")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# On success, record the token to prevent a replay attack.
|
||||||
|
set_mru_token(email, mfa_mode['id'], token, env)
|
||||||
|
return (True, [])
|
||||||
|
|
||||||
|
# On a failed login, indicate failure and any hints for what the user can do instead.
|
||||||
|
return (False, list(hints))
|
||||||
2
management/munin_start.sh
Executable file
2
management/munin_start.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
mkdir -p /var/run/munin && chown munin /var/run/munin
|
||||||
683
management/ssl_certificates.py
Executable file
683
management/ssl_certificates.py
Executable file
@@ -0,0 +1,683 @@
|
|||||||
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
|
# Utilities for installing and selecting SSL certificates.
|
||||||
|
|
||||||
|
import os, os.path, re, shutil, subprocess, tempfile
|
||||||
|
|
||||||
|
from utils import shell, safe_domain_name, sort_domains
|
||||||
|
import idna
|
||||||
|
|
||||||
|
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
||||||
|
|
||||||
|
def get_ssl_certificates(env):
|
||||||
|
# Scan all of the installed SSL certificates and map every domain
|
||||||
|
# that the certificates are good for to the best certificate for
|
||||||
|
# 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):
|
||||||
|
if fn == 'ssl_certificate.pem':
|
||||||
|
# This is always a symbolic link
|
||||||
|
# to the certificate to use for
|
||||||
|
# PRIMARY_HOSTNAME. Don't let it
|
||||||
|
# be eligible for use because we
|
||||||
|
# could end up creating a symlink
|
||||||
|
# to itself --- we want to find
|
||||||
|
# the cert that it should be a
|
||||||
|
# symlink to.
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
|
||||||
|
# Is it a private key?
|
||||||
|
if isinstance(pem, RSAPrivateKey):
|
||||||
|
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
|
||||||
|
|
||||||
|
# Is it a certificate?
|
||||||
|
if isinstance(pem, Certificate):
|
||||||
|
certificates.append({ "filename": fn, "cert": 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"])
|
||||||
|
cert["primary_domain"] = primary_domain
|
||||||
|
|
||||||
|
# Is there a private key file for this certificate?
|
||||||
|
private_key = private_keys.get(cert["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:
|
||||||
|
# The primary hostname can only use a certificate mapped
|
||||||
|
# to the system private key.
|
||||||
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
|
if cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
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():
|
||||||
|
#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename)
|
||||||
|
cert_list.sort(key = lambda cert : (
|
||||||
|
# must be valid NOW
|
||||||
|
cert["cert"].not_valid_before <= now <= cert["cert"].not_valid_after,
|
||||||
|
|
||||||
|
# prefer one that is not self-signed
|
||||||
|
cert["cert"].issuer != cert["cert"].subject,
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
# The above lines ensure that valid certificates are chosen
|
||||||
|
# over invalid certificates. The lines below choose between
|
||||||
|
# multiple valid certificates available for this domain.
|
||||||
|
###########################################################
|
||||||
|
|
||||||
|
# prefer one with the expiration furthest into the future so
|
||||||
|
# that we can easily rotate to new certs as we get them
|
||||||
|
cert["cert"].not_valid_after,
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
# We always choose the certificate that is good for the
|
||||||
|
# longest period of time. This is important for how we
|
||||||
|
# provision certificates for Let's Encrypt. To ensure that
|
||||||
|
# we don't re-provision every night, we have to ensure that
|
||||||
|
# if we choose to provison a certificate that it will
|
||||||
|
# *actually* be used so the provisioning logic knows it
|
||||||
|
# doesn't still need to provision a certificate for the
|
||||||
|
# domain.
|
||||||
|
###########################################################
|
||||||
|
|
||||||
|
# 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["cert"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False, use_main_cert=True):
|
||||||
|
if use_main_cert or not allow_missing_cert:
|
||||||
|
# Get the system certificate info.
|
||||||
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
|
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
|
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 use_main_cert:
|
||||||
|
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, limit_domains=None, show_valid_certs=True):
|
||||||
|
# Get a set of domain names that we can provision certificates for
|
||||||
|
# using certbot. We start with domains that the box is serving web
|
||||||
|
# for and subtract:
|
||||||
|
# * domains not in limit_domains if limit_domains is not empty
|
||||||
|
# * domains with custom "A" records, i.e. they are hosted elsewhere
|
||||||
|
# * domains with actual "A" records that point elsewhere (misconfiguration)
|
||||||
|
# * domains that already have certificates that will be valid for a while
|
||||||
|
|
||||||
|
from web_update import get_web_domains
|
||||||
|
from status_checks import query_dns, normalize_ip
|
||||||
|
|
||||||
|
existing_certs = get_ssl_certificates(env)
|
||||||
|
|
||||||
|
plausible_web_domains = get_web_domains(env, exclude_dns_elsewhere=False)
|
||||||
|
actual_web_domains = get_web_domains(env)
|
||||||
|
|
||||||
|
domains_to_provision = set()
|
||||||
|
domains_cant_provision = { }
|
||||||
|
|
||||||
|
for domain in plausible_web_domains:
|
||||||
|
# Skip domains that the user doesn't want to provision now.
|
||||||
|
if limit_domains and domain not in limit_domains:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check that there isn't an explicit A/AAAA record.
|
||||||
|
if domain not in actual_web_domains:
|
||||||
|
domains_cant_provision[domain] = "The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here)."
|
||||||
|
|
||||||
|
# Check that the DNS resolves to here.
|
||||||
|
else:
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
bad_dns = []
|
||||||
|
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
|
||||||
|
if not value: continue # IPv6 is not configured
|
||||||
|
response = query_dns(domain, rtype)
|
||||||
|
if response != normalize_ip(value):
|
||||||
|
bad_dns.append("%s (%s)" % (response, rtype))
|
||||||
|
|
||||||
|
if bad_dns:
|
||||||
|
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
|
||||||
|
+ (", ".join(bad_dns)) \
|
||||||
|
+ "."
|
||||||
|
|
||||||
|
else:
|
||||||
|
# DNS is all good.
|
||||||
|
|
||||||
|
# Check for a good existing cert.
|
||||||
|
existing_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
|
||||||
|
if existing_cert:
|
||||||
|
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],
|
||||||
|
warn_if_expiring_soon=14)
|
||||||
|
if existing_cert_check[0] == "OK":
|
||||||
|
if show_valid_certs:
|
||||||
|
domains_cant_provision[domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
|
||||||
|
existing_cert_check[1],
|
||||||
|
existing_cert['certificate'],
|
||||||
|
existing_cert['private-key'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
domains_to_provision.add(domain)
|
||||||
|
|
||||||
|
return (domains_to_provision, domains_cant_provision)
|
||||||
|
|
||||||
|
def provision_certificates(env, limit_domains):
|
||||||
|
# What domains should we provision certificates for? And what
|
||||||
|
# errors prevent provisioning for other domains.
|
||||||
|
domains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)
|
||||||
|
|
||||||
|
# Build a list of what happened on each domain or domain-set.
|
||||||
|
ret = []
|
||||||
|
for domain, error in domains_cant_provision.items():
|
||||||
|
ret.append({
|
||||||
|
"domains": [domain],
|
||||||
|
"log": [error],
|
||||||
|
"result": "skipped",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Break into groups by DNS zone: Group every domain with its parent domain, if
|
||||||
|
# its parent domain is in the list of domains to request a certificate for.
|
||||||
|
# Start with the zones so that if the zone doesn't need a certificate itself,
|
||||||
|
# its children will still be grouped together. Sort the provision domains to
|
||||||
|
# put parents ahead of children.
|
||||||
|
# Since Let's Encrypt requests are limited to 100 domains at a time,
|
||||||
|
# we'll create a list of lists of domains where the inner lists have
|
||||||
|
# at most 100 items. By sorting we also get the DNS zone domain as the first
|
||||||
|
# entry in each list (unless we overflow beyond 100) which ends up as the
|
||||||
|
# primary domain listed in each certificate.
|
||||||
|
from dns_update import get_dns_zones
|
||||||
|
certs = { }
|
||||||
|
for zone, zonefile in get_dns_zones(env):
|
||||||
|
certs[zone] = [[]]
|
||||||
|
for domain in sort_domains(domains, env):
|
||||||
|
# Does the domain end with any domain we've seen so far.
|
||||||
|
for parent in certs.keys():
|
||||||
|
if domain.endswith("." + parent):
|
||||||
|
# Add this to the parent's list of domains.
|
||||||
|
# Start a new group if the list already has
|
||||||
|
# 100 items.
|
||||||
|
if len(certs[parent][-1]) == 100:
|
||||||
|
certs[parent].append([])
|
||||||
|
certs[parent][-1].append(domain)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# This domain is not a child of any domain we've seen yet, so
|
||||||
|
# start a new group. This shouldn't happen since every zone
|
||||||
|
# was already added.
|
||||||
|
certs[domain] = [[domain]]
|
||||||
|
|
||||||
|
# Flatten to a list of lists of domains (from a mapping). Remove empty
|
||||||
|
# lists (zones with no domains that need certs).
|
||||||
|
certs = sum(certs.values(), [])
|
||||||
|
certs = [_ for _ in certs if len(_) > 0]
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Provision certificates.
|
||||||
|
for domain_list in certs:
|
||||||
|
ret.append({
|
||||||
|
"domains": domain_list,
|
||||||
|
"log": [],
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
# Create a CSR file for our master private key so that certbot
|
||||||
|
# uses our private key.
|
||||||
|
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')
|
||||||
|
with tempfile.NamedTemporaryFile() as csr_file:
|
||||||
|
# We could use openssl, but certbot requires
|
||||||
|
# that the CN domain and SAN domains match
|
||||||
|
# the domain list passed to certbot, and adding
|
||||||
|
# SAN domains openssl req is ridiculously complicated.
|
||||||
|
# subprocess.check_output([
|
||||||
|
# "openssl", "req", "-new",
|
||||||
|
# "-key", key_file,
|
||||||
|
# "-out", csr_file.name,
|
||||||
|
# "-subj", "/CN=" + domain_list[0],
|
||||||
|
# "-sha256" ])
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
builder = x509.CertificateSigningRequestBuilder()
|
||||||
|
builder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ]))
|
||||||
|
builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
|
builder = builder.add_extension(x509.SubjectAlternativeName(
|
||||||
|
[x509.DNSName(d) for d in domain_list]
|
||||||
|
), critical=False)
|
||||||
|
request = builder.sign(load_pem(load_cert_chain(key_file)[0]), hashes.SHA256(), default_backend())
|
||||||
|
with open(csr_file.name, "wb") as f:
|
||||||
|
f.write(request.public_bytes(Encoding.PEM))
|
||||||
|
|
||||||
|
# Provision, writing to a temporary file.
|
||||||
|
webroot = os.path.join(account_path, 'webroot')
|
||||||
|
os.makedirs(webroot, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
cert_file = os.path.join(d, 'cert_and_chain.pem')
|
||||||
|
print("Provisioning TLS certificates for " + ", ".join(domain_list) + ".")
|
||||||
|
certbotret = subprocess.check_output([
|
||||||
|
"certbot",
|
||||||
|
"certonly",
|
||||||
|
#"-v", # just enough to see ACME errors
|
||||||
|
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
|
||||||
|
|
||||||
|
"-d", ",".join(domain_list), # first will be main domain
|
||||||
|
|
||||||
|
"--csr", csr_file.name, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
|
||||||
|
"--cert-path", os.path.join(d, 'cert'), # we only use the full chain
|
||||||
|
"--chain-path", os.path.join(d, 'chain'), # we only use the full chain
|
||||||
|
"--fullchain-path", cert_file,
|
||||||
|
|
||||||
|
"--webroot", "--webroot-path", webroot,
|
||||||
|
|
||||||
|
"--config-dir", account_path,
|
||||||
|
#"--staging",
|
||||||
|
], stderr=subprocess.STDOUT).decode("utf8")
|
||||||
|
install_cert_copy_file(cert_file, env)
|
||||||
|
|
||||||
|
ret[-1]["log"].append(certbotret)
|
||||||
|
ret[-1]["result"] = "installed"
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
ret[-1]["log"].append(e.output.decode("utf8"))
|
||||||
|
ret[-1]["result"] = "error"
|
||||||
|
except Exception as e:
|
||||||
|
ret[-1]["log"].append(str(e))
|
||||||
|
ret[-1]["result"] = "error"
|
||||||
|
|
||||||
|
# Run post-install steps.
|
||||||
|
ret.extend(post_install_func(env))
|
||||||
|
|
||||||
|
# Return what happened with each certificate request.
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def provision_certificates_cmdline():
|
||||||
|
import sys
|
||||||
|
from exclusiveprocess import Lock
|
||||||
|
|
||||||
|
from utils import load_environment
|
||||||
|
|
||||||
|
Lock(die=True).forever()
|
||||||
|
env = load_environment()
|
||||||
|
|
||||||
|
quiet = False
|
||||||
|
domains = []
|
||||||
|
|
||||||
|
for arg in sys.argv[1:]:
|
||||||
|
if arg == "-q":
|
||||||
|
quiet = True
|
||||||
|
else:
|
||||||
|
domains.append(arg)
|
||||||
|
|
||||||
|
# Go.
|
||||||
|
status = provision_certificates(env, limit_domains=domains)
|
||||||
|
|
||||||
|
# Show what happened.
|
||||||
|
for request in status:
|
||||||
|
if isinstance(request, str):
|
||||||
|
print(request)
|
||||||
|
else:
|
||||||
|
if quiet and request['result'] == 'skipped':
|
||||||
|
continue
|
||||||
|
print(request['result'] + ":", ", ".join(request['domains']) + ":")
|
||||||
|
for line in request["log"]:
|
||||||
|
print(line)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
|
||||||
|
|
||||||
|
def create_csr(domain, ssl_key, country_code, env):
|
||||||
|
return shell("check_output", [
|
||||||
|
"openssl", "req", "-new",
|
||||||
|
"-key", ssl_key,
|
||||||
|
"-sha256",
|
||||||
|
"-subj", "/C=%s/CN=%s" % (country_code, domain)])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy certifiate into ssl directory.
|
||||||
|
install_cert_copy_file(fn, env)
|
||||||
|
|
||||||
|
# Run post-install steps.
|
||||||
|
ret = post_install_func(env)
|
||||||
|
if raw: return ret
|
||||||
|
return "\n".join(ret)
|
||||||
|
|
||||||
|
|
||||||
|
def install_cert_copy_file(fn, env):
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
def post_install_func(env):
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
# Get the certificate to use for PRIMARY_HOSTNAME.
|
||||||
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'], ssl_certificates, env, use_main_cert=False)
|
||||||
|
if not cert:
|
||||||
|
# Ruh-row, we don't have any certificate usable
|
||||||
|
# for the primary hostname.
|
||||||
|
ret.append("there is no valid certificate for " + env['PRIMARY_HOSTNAME'])
|
||||||
|
|
||||||
|
# Symlink the best cert for PRIMARY_HOSTNAME to the system
|
||||||
|
# certificate path, which is hard-coded for various purposes, and then
|
||||||
|
# restart postfix and dovecot.
|
||||||
|
system_ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
|
if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
|
||||||
|
# Update symlink.
|
||||||
|
ret.append("updating primary certificate")
|
||||||
|
ssl_certificate = cert['certificate']
|
||||||
|
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) )
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
# VALIDATION OF CERTIFICATES
|
||||||
|
|
||||||
|
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=10, rounded_time=False, just_check_domain=False):
|
||||||
|
# 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.date().isoformat())
|
||||||
|
else:
|
||||||
|
# We'll renew it with Lets Encrypt.
|
||||||
|
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
|
||||||
|
|
||||||
|
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
|
||||||
|
# Warn on day 10 to give 4 days for us to automatically renew the
|
||||||
|
# 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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,68 @@
|
|||||||
<style>
|
<style>
|
||||||
#alias_table .actions > * { padding-right: 3px; }
|
#alias_table .actions > * { padding-right: 3px; }
|
||||||
#alias_table .alias-required .remove { display: none }
|
#alias_table .alias-auto .actions > * { display: none }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Aliases</h2>
|
<h2>Aliases</h2>
|
||||||
|
|
||||||
<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%">
|
||||||
|
<span class="domainalias text-muted">Enter just the part of an email address starting with the @-sign.</span>
|
||||||
|
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other domains, create a mail user and then log into webmail for the user and create a filter rule to forward mail.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addaliasSenders" class="col-sm-1 control-label">Permitted Senders</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div class="radio">
|
||||||
|
<label>
|
||||||
|
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio" checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
||||||
|
Any mail user listed in the Forwards To box can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="radio">
|
||||||
|
<label>
|
||||||
|
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
||||||
|
I’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 +80,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,12 +100,48 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<h3>Mail aliases API (advanced)</h3>
|
||||||
|
|
||||||
|
<p>Use your box’s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p>
|
||||||
|
|
||||||
|
<p>Usage:</p>
|
||||||
|
|
||||||
|
<pre>curl -X <b>VERB</b> [-d "<b>parameters</b>"] --user {email}:{password} https://{{hostname}}/admin/mail/aliases[<b>action</b>]</pre>
|
||||||
|
|
||||||
|
<p>Brackets denote an optional argument. Please note that the POST body <code>parameters</code> must be URL-encoded.</p>
|
||||||
|
|
||||||
|
<p>The email and password given to the <code>--user</code> option must be an administrative user on this system.</p>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom: 0">Verbs</h4>
|
||||||
|
|
||||||
|
<table class="table" style="margin-top: .5em">
|
||||||
|
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||||
|
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/add</td> <td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/remove</td> <td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
|
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
|
||||||
|
|
||||||
|
<pre># Gives a JSON-encoded list of all mail aliases
|
||||||
|
curl -X GET https://{{hostname}}/admin/mail/aliases?format=json
|
||||||
|
|
||||||
|
# Adds a new alias
|
||||||
|
curl -X POST -d "address=new_alias@mydomail.com" -d "forwards_to=my_email@mydomain.com" https://{{hostname}}/admin/mail/aliases/add
|
||||||
|
|
||||||
|
# Removes an alias
|
||||||
|
curl -X POST -d "address=new_alias@mydomail.com" https://{{hostname}}/admin/mail/aliases/remove
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_aliases() {
|
function show_aliases() {
|
||||||
@@ -87,8 +153,8 @@ function show_aliases() {
|
|||||||
function(r) {
|
function(r) {
|
||||||
$('#alias_table tbody').html("");
|
$('#alias_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
|
var hdr = $("<tr><th colspan='4' style='background-color: #EEE'></th></tr>");
|
||||||
hdr.find('h4').text(r[i].domain);
|
hdr.find('th').text(r[i].domain);
|
||||||
$('#alias_table tbody').append(hdr);
|
$('#alias_table tbody').append(hdr);
|
||||||
|
|
||||||
for (var k = 0; k < r[i].aliases.length; k++) {
|
for (var k = 0; k < r[i].aliases.length; k++) {
|
||||||
@@ -97,11 +163,13 @@ function show_aliases() {
|
|||||||
var n = $("#alias-template").clone();
|
var n = $("#alias-template").clone();
|
||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
|
||||||
if (alias.required) n.addClass('alias-required');
|
if (alias.auto) n.addClass('alias-auto');
|
||||||
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 +179,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 +207,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,52 +236,59 @@ 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.
|
||||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
show_modal_error("Remove Alias", $("<pre/>").text(r));
|
||||||
show_aliases();
|
show_aliases();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
||||||
|
|
||||||
<h3>Set Custom DNS Records</h3>
|
<h3>Set custom DNS records</h3>
|
||||||
|
|
||||||
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
|
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p>
|
||||||
|
|
||||||
@@ -31,11 +31,15 @@
|
|||||||
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
|
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
|
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
|
||||||
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012).">A (IPv4 address)</option>
|
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">A (IPv4 address)</option>
|
||||||
<option value="AAAA" data-hint="Enter an IPv6 address.">AAAA (IPv6 address)</option>
|
<option value="AAAA" data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">AAAA (IPv6 address)</option>
|
||||||
|
<option value="CAA" data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild "letsencrypt.org")">CAA (Certificate Authority Authorization)</option>
|
||||||
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
|
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
|
||||||
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||||
<option value="MX" data-hint="Enter record in the form of PRIORIY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
|
<option value="MX" data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
|
||||||
|
<option value="SRV" data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">SRV (service record)</option>
|
||||||
|
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH fingerprint record)</option>
|
||||||
|
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS subdomain delegation)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +57,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<table id="custom-dns-current" class="table" style="width: auto; display: none">
|
<div style="text-align: right; font-size; 90%; margin-top: 1em;">
|
||||||
|
sort by:
|
||||||
|
<a href="#" onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">domain name</a>
|
||||||
|
|
|
||||||
|
<a href="#" onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">created</a>
|
||||||
|
</div>
|
||||||
|
<table id="custom-dns-current" class="table" style="width: auto; display: none; margin-top: 0;">
|
||||||
<thead>
|
<thead>
|
||||||
<th>Domain Name</th>
|
<th>Domain Name</th>
|
||||||
<th>Record Type</th>
|
<th>Record Type</th>
|
||||||
@@ -65,9 +75,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<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 secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -81,9 +92,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 an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</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>
|
||||||
@@ -116,7 +133,7 @@
|
|||||||
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
||||||
<tr><td>password</td> <td>That user’s password.</td></tr>
|
<tr><td>password</td> <td>That user’s password.</td></tr>
|
||||||
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
|
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
|
||||||
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name — don’t forget the final period), <code>MX</code>, or <code>SRV</code>.</td></tr>
|
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name — don’t forget the final period), <code>MX</code>, <code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td></tr>
|
||||||
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
|
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -152,8 +169,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(
|
||||||
@@ -181,20 +198,38 @@ function show_current_custom_dns() {
|
|||||||
$('#custom-dns-current').fadeIn();
|
$('#custom-dns-current').fadeIn();
|
||||||
else
|
else
|
||||||
$('#custom-dns-current').fadeOut();
|
$('#custom-dns-current').fadeOut();
|
||||||
|
window.miab_custom_dns_data = data;
|
||||||
|
show_current_custom_dns_update_after_sort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$('#custom-dns-current').find("tbody").text('');
|
function show_current_custom_dns_update_after_sort() {
|
||||||
|
var data = window.miab_custom_dns_data;
|
||||||
|
var sort_key = window.miab_custom_dns_data_sort_order || "qname";
|
||||||
|
|
||||||
|
data.sort(function(a, b) { return a["sort-order"][sort_key] - b["sort-order"][sort_key] });
|
||||||
|
|
||||||
|
var tbody = $('#custom-dns-current').find("tbody");
|
||||||
|
tbody.text('');
|
||||||
|
var last_zone = null;
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
if (sort_key == "qname" && data[i].zone != last_zone) {
|
||||||
|
var r = $("<tr><th colspan=4 style='background-color: #EEE'></th></tr>");
|
||||||
|
r.find("th").text(data[i].zone);
|
||||||
|
tbody.append(r);
|
||||||
|
last_zone = data[i].zone;
|
||||||
|
}
|
||||||
|
|
||||||
var tr = $("<tr/>");
|
var tr = $("<tr/>");
|
||||||
$('#custom-dns-current').find("tbody").append(tr);
|
tbody.append(tr);
|
||||||
tr.attr('data-qname', data[i].qname);
|
tr.attr('data-qname', data[i].qname);
|
||||||
tr.attr('data-rtype', data[i].rtype);
|
tr.attr('data-rtype', data[i].rtype);
|
||||||
tr.attr('data-value', data[i].value);
|
tr.attr('data-value', data[i].value);
|
||||||
tr.append($('<td class="long"/>').text(data[i].qname));
|
tr.append($('<td class="long"/>').text(data[i].qname));
|
||||||
tr.append($('<td/>').text(data[i].rtype));
|
tr.append($('<td/>').text(data[i].rtype));
|
||||||
tr.append($('<td class="long"/>').text(data[i].value));
|
tr.append($('<td class="long" style="max-width: 40em"/>').text(data[i].value));
|
||||||
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delete_custom_dns_record(elem) {
|
function delete_custom_dns_record(elem) {
|
||||||
@@ -210,7 +245,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,28 @@
|
|||||||
|
|
||||||
<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="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
||||||
|
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Download zonefile</h3>
|
||||||
|
<p>You can download your zonefiles here or use the table of records below.</p>
|
||||||
|
<form class="form-inline" role="form" onsubmit="do_download_zonefile(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="downloadZonefile" class="control-label sr-only">Zone</label>
|
||||||
|
<select id="downloadZonefile" class="form-control" style="width: auto"> </select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Download</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Records</h3>
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
<table id="external_dns_settings" class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -48,6 +70,18 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_external_dns() {
|
function show_external_dns() {
|
||||||
|
api(
|
||||||
|
"/dns/zones",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(data) {
|
||||||
|
var zones = $('#downloadZonefile');
|
||||||
|
zones.text('');
|
||||||
|
for (var j = 0; j < data.length; j++) {
|
||||||
|
zones.append($('<option/>').text(data[j]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/dns/dump",
|
"/dns/dump",
|
||||||
@@ -75,4 +109,19 @@ function show_external_dns() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function do_download_zonefile() {
|
||||||
|
var zone = $('#downloadZonefile').val();
|
||||||
|
|
||||||
|
api(
|
||||||
|
"/dns/zonefile/"+ zone,
|
||||||
|
"GET",
|
||||||
|
{},
|
||||||
|
function(data) {
|
||||||
|
show_modal_error("Download Zonefile", $("<pre/>").text(data));
|
||||||
|
},
|
||||||
|
function(err) {
|
||||||
|
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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="/admin/assets/bootstrap/css/bootstrap.min.css">
|
||||||
<style>
|
<style>
|
||||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
body {
|
||||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
padding-top: 50px;
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +62,46 @@
|
|||||||
ol li {
|
ol li {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.if-logged-in { display: none; }
|
||||||
|
.if-logged-in-admin { display: none; }
|
||||||
|
|
||||||
|
/* The below only gets used if it is supported */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* Invert invert lightness but not hue */
|
||||||
|
html {
|
||||||
|
filter: invert(100%) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set explicit background color (necessary for Firefox) */
|
||||||
|
html {
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Boostrap theme here to give more contrast. The black turns to white by the filter. */
|
||||||
|
.form-control {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Revert the invert for the navbar */
|
||||||
|
button, div.navbar {
|
||||||
|
filter: invert(100%) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Revert the revert for the dropdowns */
|
||||||
|
ul.dropdown-menu {
|
||||||
|
filter: invert(100%) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/css/bootstrap-theme.min.css">
|
<link rel="stylesheet" href="/admin/assets/bootstrap/css/bootstrap-theme.min.css">
|
||||||
<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">
|
||||||
@@ -91,37 +114,46 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
<ul class="nav navbar-nav">
|
<ul class="nav navbar-nav">
|
||||||
<li class="dropdown">
|
<li class="dropdown if-logged-in-admin">
|
||||||
<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="#munin" onclick="return show_panel(this);">Munin Monitoring</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown">
|
<li><a href="#mail-guide" onclick="return show_panel(this);" class="if-logged-in-not-admin">Mail</a></li>
|
||||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a>
|
<li class="dropdown if-logged-in-admin">
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail & Users <b class="caret"></b></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
|
||||||
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
<li><a href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||||
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li class="dropdown-header">Your Account</li>
|
||||||
|
<li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
<li><a href="#sync_guide" onclick="return show_panel(this);" class="if-logged-in">Contacts/Calendar</a></li>
|
||||||
<li><a href="#web" onclick="return show_panel(this);">Web</a></li>
|
<li><a href="#web" onclick="return show_panel(this);" class="if-logged-in-admin">Web</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="nav navbar-nav navbar-right">
|
<ul class="nav navbar-nav navbar-right">
|
||||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
<li class="if-logged-in"><a href="#" onclick="do_logout(); return false;" style="color: white">Log out</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div><!--/.navbar-collapse -->
|
</div><!--/.navbar-collapse -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div id="panel_welcome" class="admin_panel">
|
||||||
|
{% include "welcome.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="panel_system_status" class="admin_panel">
|
<div id="panel_system_status" class="admin_panel">
|
||||||
{% include "system-status.html" %}
|
{% include "system-status.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +170,10 @@
|
|||||||
{% include "custom-dns.html" %}
|
{% include "custom-dns.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="panel_mfa" class="admin_panel">
|
||||||
|
{% include "mfa.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="panel_login" class="admin_panel">
|
<div id="panel_login" class="admin_panel">
|
||||||
{% include "login.html" %}
|
{% include "login.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,10 +198,14 @@
|
|||||||
{% 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>
|
||||||
|
|
||||||
|
<div id="panel_munin" class="admin_panel">
|
||||||
|
{% include "munin.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -198,8 +238,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
|
<script src="/admin/assets/jquery.min.js"></script>
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js"></script>
|
<script src="/admin/assets/bootstrap/js/bootstrap.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var global_modal_state = null;
|
var global_modal_state = null;
|
||||||
@@ -209,8 +249,8 @@ $(function() {
|
|||||||
$('#global_modal').on('shown.bs.modal', function (e) {
|
$('#global_modal').on('shown.bs.modal', function (e) {
|
||||||
// set focus to first input in the global modal's body
|
// set focus to first input in the global modal's body
|
||||||
var input = $('#global_modal .modal-body input');
|
var input = $('#global_modal .modal-body input');
|
||||||
if (input.length > 0) $(input[0]).focus();
|
if (input.length > 0) $(input[0]).focus();
|
||||||
})
|
})
|
||||||
$('#global_modal .btn-danger').click(function() {
|
$('#global_modal .btn-danger').click(function() {
|
||||||
// Don't take action now. Wait for the modal to be totally hidden
|
// Don't take action now. Wait for the modal to be totally hidden
|
||||||
// so that we don't attempt to show another modal while this one
|
// so that we don't attempt to show another modal while this one
|
||||||
@@ -225,7 +265,7 @@ $(function() {
|
|||||||
if (global_modal_state == null) global_modal_state = 1; // cancel if the user hit ESC or clicked outside of the modal
|
if (global_modal_state == null) global_modal_state = 1; // cancel if the user hit ESC or clicked outside of the modal
|
||||||
if (global_modal_funcs && global_modal_funcs[global_modal_state])
|
if (global_modal_funcs && global_modal_funcs[global_modal_state])
|
||||||
global_modal_funcs[global_modal_state]();
|
global_modal_funcs[global_modal_state]();
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function show_modal_error(title, message, callback) {
|
function show_modal_error(title, message, callback) {
|
||||||
@@ -270,7 +310,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--;
|
||||||
@@ -288,7 +328,7 @@ function ajax(options) {
|
|||||||
};
|
};
|
||||||
options.error = function(jqxhr) {
|
options.error = function(jqxhr) {
|
||||||
hide_loading_indicator();
|
hide_loading_indicator();
|
||||||
if (!old_error)
|
if (!old_error)
|
||||||
show_modal_error("Error", "Something went wrong, sorry.")
|
show_modal_error("Error", "Something went wrong, sorry.")
|
||||||
else
|
else
|
||||||
old_error(jqxhr.responseText, jqxhr);
|
old_error(jqxhr.responseText, jqxhr);
|
||||||
@@ -298,8 +338,8 @@ function ajax(options) {
|
|||||||
return false; // handy when called from onclick
|
return false; // handy when called from onclick
|
||||||
}
|
}
|
||||||
|
|
||||||
var api_credentials = ["", ""];
|
var api_credentials = null;
|
||||||
function api(url, method, data, callback, callback_error) {
|
function api(url, method, data, callback, callback_error, headers) {
|
||||||
// from http://www.webtoolkit.info/javascript-base64.html
|
// from http://www.webtoolkit.info/javascript-base64.html
|
||||||
function base64encode(input) {
|
function base64encode(input) {
|
||||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||||
@@ -332,12 +372,12 @@ 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,
|
||||||
data: data,
|
data: data,
|
||||||
|
headers: headers,
|
||||||
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
||||||
processData: typeof data != "string",
|
processData: typeof data != "string",
|
||||||
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
||||||
@@ -346,9 +386,10 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
// We don't store user credentials in a cookie to avoid the hassle of CSRF
|
// We don't store user credentials in a cookie to avoid the hassle of CSRF
|
||||||
// attacks. The Authorization header only gets set in our AJAX calls triggered
|
// attacks. The Authorization header only gets set in our AJAX calls triggered
|
||||||
// by user actions.
|
// by user actions.
|
||||||
xhr.setRequestHeader(
|
if (api_credentials)
|
||||||
'Authorization',
|
xhr.setRequestHeader(
|
||||||
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
'Authorization',
|
||||||
|
'Basic ' + base64encode(api_credentials.username + ':' + api_credentials.session_key));
|
||||||
},
|
},
|
||||||
success: callback,
|
success: callback,
|
||||||
error: callback_error || default_error,
|
error: callback_error || default_error,
|
||||||
@@ -365,6 +406,25 @@ function api(url, method, data, callback, callback_error) {
|
|||||||
|
|
||||||
var current_panel = null;
|
var current_panel = null;
|
||||||
var switch_back_to_panel = null;
|
var switch_back_to_panel = null;
|
||||||
|
|
||||||
|
function do_logout() {
|
||||||
|
// Clear the session from the backend.
|
||||||
|
api("/logout", "POST");
|
||||||
|
|
||||||
|
// Forget the token.
|
||||||
|
api_credentials = null;
|
||||||
|
if (typeof localStorage != 'undefined')
|
||||||
|
localStorage.removeItem("miab-cp-credentials");
|
||||||
|
if (typeof sessionStorage != 'undefined')
|
||||||
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
|
|
||||||
|
// Return to the start.
|
||||||
|
show_panel('login');
|
||||||
|
|
||||||
|
// Reset menus.
|
||||||
|
show_hide_menus();
|
||||||
|
}
|
||||||
|
|
||||||
function show_panel(panelid) {
|
function show_panel(panelid) {
|
||||||
if (panelid.getAttribute)
|
if (panelid.getAttribute)
|
||||||
// we might be passed an HTMLElement <a>.
|
// we might be passed an HTMLElement <a>.
|
||||||
@@ -385,14 +445,22 @@ function show_panel(panelid) {
|
|||||||
|
|
||||||
$(function() {
|
$(function() {
|
||||||
// Recall saved user credentials.
|
// Recall saved user credentials.
|
||||||
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
|
try {
|
||||||
api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":");
|
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
|
||||||
else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
|
api_credentials = JSON.parse(sessionStorage.getItem("miab-cp-credentials"));
|
||||||
api_credentials = localStorage.getItem("miab-cp-credentials").split(":");
|
else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
|
||||||
|
api_credentials = JSON.parse(localStorage.getItem("miab-cp-credentials"));
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle menu state.
|
||||||
|
show_hide_menus();
|
||||||
|
|
||||||
// Recall what the user was last looking at.
|
// Recall what the user was last looking at.
|
||||||
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
|
if (api_credentials != null && typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
|
||||||
show_panel(localStorage.getItem("miab-cp-lastpanel"));
|
show_panel(localStorage.getItem("miab-cp-lastpanel"));
|
||||||
|
} else if (api_credentials != null) {
|
||||||
|
show_panel('welcome');
|
||||||
} else {
|
} else {
|
||||||
show_panel('login');
|
show_panel('login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,29 @@
|
|||||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
<style>
|
||||||
|
.title {
|
||||||
|
margin: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 32em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login #loginOtp {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm.is-twofactor #loginOtp {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1 class="title">{{hostname}}</h1>
|
||||||
|
|
||||||
{% if no_users_exist or no_admins_exist %}
|
{% if no_users_exist or no_admins_exist %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -7,23 +32,23 @@
|
|||||||
<p class="text-danger">There are no users on this system! To make an administrative user,
|
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo tools/mail.py user add me@{{hostname}}
|
sudo management/cli.py user add me@{{hostname}}
|
||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
|
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||||
|
|
||||||
<div style="margin: 0 auto; max-width: 32em;">
|
<div class="login">
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||||
<div class="col-sm-9">
|
<div class="col-sm-9">
|
||||||
@@ -36,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" id="loginOtp">
|
||||||
|
<label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code" autocomplete="off">
|
||||||
|
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-3 col-sm-9">
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
@@ -53,35 +85,51 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange the email address & password for an API key.
|
// Exchange the email address & password for an API key.
|
||||||
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
|
api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/me",
|
"/login",
|
||||||
"GET",
|
"POST",
|
||||||
{ },
|
{},
|
||||||
function(response){
|
function(response) {
|
||||||
// This API call always succeeds. It returns a JSON object indicating
|
// This API call always succeeds. It returns a JSON object indicating
|
||||||
// whether the request was authenticated or not.
|
// whether the request was authenticated or not.
|
||||||
if (response.status != "ok") {
|
if (response.status != 'ok') {
|
||||||
// Show why the login failed.
|
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
||||||
show_modal_error("Login Failed", response.reason)
|
$('#loginForm').addClass('is-twofactor');
|
||||||
|
if (response.reason === "invalid-totp-token") {
|
||||||
|
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
$('#loginOtpInput').focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Show why the login failed.
|
||||||
do_logout();
|
show_modal_error("Login Failed", response.reason)
|
||||||
|
|
||||||
|
// Reset any saved credentials.
|
||||||
|
do_logout();
|
||||||
|
}
|
||||||
} else if (!("api_key" in response)) {
|
} else if (!("api_key" in response)) {
|
||||||
// Login succeeded but user might not be authorized!
|
// Login succeeded but user might not be authorized!
|
||||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||||
@@ -93,37 +141,66 @@ function do_login() {
|
|||||||
// Login succeeded.
|
// Login succeeded.
|
||||||
|
|
||||||
// Save the new credentials.
|
// Save the new credentials.
|
||||||
api_credentials = [response.email, response.api_key];
|
api_credentials = { username: response.email,
|
||||||
|
session_key: response.api_key,
|
||||||
|
privileges: response.privileges };
|
||||||
|
|
||||||
// Try to wipe the username/password information.
|
// Try to wipe the username/password information.
|
||||||
$('#loginEmail').val('');
|
$('#loginEmail').val('');
|
||||||
$('#loginPassword').val('');
|
$('#loginPassword').val('');
|
||||||
|
$('#loginOtpInput').val('');
|
||||||
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Remember the credentials.
|
// Remember the credentials.
|
||||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||||
if ($('#loginRemember').val()) {
|
if ($('#loginRemember').val()) {
|
||||||
localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||||
sessionStorage.removeItem("miab-cp-credentials");
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("miab-cp-credentials");
|
localStorage.removeItem("miab-cp-credentials");
|
||||||
sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle menus.
|
||||||
|
show_hide_menus();
|
||||||
|
|
||||||
// Open the next panel the user wants to go to. Do this after the XHR response
|
// 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" ? 'welcome' : switch_back_to_panel) }, 300);
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
'x-auth-token': $('#loginOtpInput').val()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_logout() {
|
function show_login() {
|
||||||
api_credentials = ["", ""];
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
if (typeof localStorage != 'undefined')
|
$('#loginOtpInput').val('');
|
||||||
localStorage.removeItem("miab-cp-credentials");
|
$('#loginEmail,#loginPassword').each(function() {
|
||||||
if (typeof sessionStorage != 'undefined')
|
var input = $(this);
|
||||||
sessionStorage.removeItem("miab-cp-credentials");
|
if (!$.trim(input.val())) {
|
||||||
show_panel('login');
|
input.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_hide_menus() {
|
||||||
|
var is_logged_in = (api_credentials != null);
|
||||||
|
var privs = api_credentials ? api_credentials.privileges : [];
|
||||||
|
$('.if-logged-in').toggle(is_logged_in);
|
||||||
|
$('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);
|
||||||
|
if (is_logged_in) {
|
||||||
|
$('.if-logged-in-not-admin').toggle(true);
|
||||||
|
privs.forEach(function(priv) {
|
||||||
|
$('.if-logged-in-' + priv).toggle(true);
|
||||||
|
$('.if-logged-in-not-' + priv).toggle(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('.if-not-logged-in').toggle(!is_logged_in);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,18 +29,20 @@
|
|||||||
<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>465</td></tr>
|
||||||
<tr><th>SMTP Security</td> <td>STARTTLS <small>(“always” or “required”, if prompted)</small></td></tr>
|
<tr><th>SMTP Security</td> <td>SSL or TLS</td></tr>
|
||||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<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="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’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>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||||
@@ -57,10 +59,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<h4>Greylisting</h4>
|
<h4>Greylisting</h4>
|
||||||
<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 uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven’t received mail from before. Legitimate mail servers will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please be aware there will be a minimum of 3 minutes delay, depending how soon the remote server attempts redelivery.</p>
|
||||||
|
|
||||||
<h4>+tag addresses</h4>
|
<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>
|
||||||
|
|||||||
242
management/templates/mfa.html
Normal file
242
management/templates/mfa.html
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<style>
|
||||||
|
.twofactor #totp-setup,
|
||||||
|
.twofactor #disable-2fa,
|
||||||
|
.twofactor #output-2fa {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.loaded .loading-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.disabled #disable-2fa,
|
||||||
|
.twofactor.enabled #totp-setup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor.disabled #totp-setup,
|
||||||
|
.twofactor.enabled #disable-2fa {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor #totp-setup-qr img {
|
||||||
|
display: block;
|
||||||
|
width: 256px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twofactor #output-2fa.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2>Two-Factor Authentication</h2>
|
||||||
|
|
||||||
|
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
|
||||||
|
authenticator app (usually on your phone) when you log into this control panel.</p>
|
||||||
|
|
||||||
|
<div class="panel panel-danger">
|
||||||
|
<div class="panel-heading">
|
||||||
|
Enabling two-factor authentication does not protect access to your email
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
|
||||||
|
reset your password by checking your email, so anyone with access to your email can typically take over
|
||||||
|
your other accounts. Additionally, if your email address or any alias that forwards to your email
|
||||||
|
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
|
||||||
|
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
|
||||||
|
and ensure every administrator account for this control panel does the same.</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="twofactor">
|
||||||
|
<div class="loading-indicator">Loading...</div>
|
||||||
|
|
||||||
|
<form id="totp-setup">
|
||||||
|
<h3>Setup Instructions</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||||
|
other two-factor authentication app</a> that supports TOTP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
|
||||||
|
<div id="totp-setup-qr"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
|
||||||
|
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</label>
|
||||||
|
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="totp-setup-secret" />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in
|
||||||
|
again, now using your two-factor authentication app.</p>
|
||||||
|
<button id="totp-setup-submit" disabled type="submit" class="btn">Enable Two-Factor Authentication</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="disable-2fa">
|
||||||
|
<div class="form-group">
|
||||||
|
<p>Two-factor authentication is active for your account<span id="mfa-device-label"></span>.</p>
|
||||||
|
<p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="output-2fa" class="panel panel-danger">
|
||||||
|
<div class="panel-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var el = {
|
||||||
|
disableForm: document.getElementById('disable-2fa'),
|
||||||
|
output: document.getElementById('output-2fa'),
|
||||||
|
totpSetupForm: document.getElementById('totp-setup'),
|
||||||
|
totpSetupToken: document.getElementById('totp-setup-token'),
|
||||||
|
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
||||||
|
totpSetupLabel: document.getElementById('totp-setup-label'),
|
||||||
|
totpQr: document.getElementById('totp-setup-qr'),
|
||||||
|
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
||||||
|
wrapper: document.querySelector('.twofactor')
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_setup_disabled(evt) {
|
||||||
|
var val = evt.target.value.trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof val !== 'string' ||
|
||||||
|
typeof el.totpSetupSecret.value !== 'string' ||
|
||||||
|
val.length !== 6 ||
|
||||||
|
el.totpSetupSecret.value.length !== 32 ||
|
||||||
|
!(/^\+?\d+$/.test(val))
|
||||||
|
) {
|
||||||
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
|
} else {
|
||||||
|
el.totpSetupSubmit.removeAttribute('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_totp_setup(provisioned_totp) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
|
||||||
|
|
||||||
|
var code = document.createElement('div');
|
||||||
|
code.innerHTML = `Secret: ${provisioned_totp.secret}`;
|
||||||
|
|
||||||
|
el.totpQr.appendChild(img);
|
||||||
|
el.totpQr.appendChild(code);
|
||||||
|
|
||||||
|
el.totpSetupToken.addEventListener('input', update_setup_disabled);
|
||||||
|
el.totpSetupForm.addEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
|
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
|
||||||
|
|
||||||
|
el.wrapper.classList.add('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_disable(mfa) {
|
||||||
|
el.disableForm.addEventListener('submit', do_disable);
|
||||||
|
el.wrapper.classList.add('enabled');
|
||||||
|
if (mfa.label)
|
||||||
|
$("#mfa-device-label").text(" on device '" + mfa.label + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide_error() {
|
||||||
|
el.output.querySelector('.panel-body').innerHTML = '';
|
||||||
|
el.output.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_error(msg) {
|
||||||
|
el.output.querySelector('.panel-body').innerHTML = msg;
|
||||||
|
el.output.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset_view() {
|
||||||
|
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
||||||
|
|
||||||
|
el.disableForm.removeEventListener('submit', do_disable);
|
||||||
|
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
el.totpSetupForm.reset();
|
||||||
|
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
|
el.totpSetupSecret.setAttribute('value', '');
|
||||||
|
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||||
|
|
||||||
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
|
el.totpQr.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function show_mfa() {
|
||||||
|
reset_view();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/status',
|
||||||
|
'POST',
|
||||||
|
{},
|
||||||
|
function(res) {
|
||||||
|
el.wrapper.classList.add('loaded');
|
||||||
|
|
||||||
|
var has_mfa = false;
|
||||||
|
res.enabled_mfa.forEach(function(mfa) {
|
||||||
|
if (mfa.type == "totp") {
|
||||||
|
render_disable(mfa);
|
||||||
|
has_mfa = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!has_mfa)
|
||||||
|
render_totp_setup(res.new_mfa.totp);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_disable(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/disable',
|
||||||
|
'POST',
|
||||||
|
{ type: 'totp' },
|
||||||
|
function() {
|
||||||
|
do_logout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function do_enable_totp(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/totp/enable',
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
token: $(el.totpSetupToken).val(),
|
||||||
|
secret: $(el.totpSetupSecret).val(),
|
||||||
|
label: $(el.totpSetupLabel).val()
|
||||||
|
},
|
||||||
|
function(res) { do_logout(); },
|
||||||
|
function(res) { render_error(res); }
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
20
management/templates/munin.html
Normal file
20
management/templates/munin.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<h2>Munin Monitoring</h2>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<p>Opening munin in a new tab... You may need to allow pop-ups for this site.</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function show_munin() {
|
||||||
|
// Set the cookie.
|
||||||
|
api(
|
||||||
|
"/munin",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
// Redirect.
|
||||||
|
window.open("/admin/munin/index.html", "_blank");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
<style>
|
<style>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>SSL Certificates</h2>
|
<h2>TLS (SSL) Certificates</h2>
|
||||||
|
|
||||||
<h3>Certificate Status</h3>
|
<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>
|
||||||
|
|
||||||
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto;">
|
<div id="ssl_provision">
|
||||||
|
<h3>Provision certificates</h3>
|
||||||
|
|
||||||
|
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
|
||||||
|
<button onclick='return provision_tls_cert();' class='btn btn-primary' style="float: left; margin: 0 1.5em 1em 0;">Provision</button>
|
||||||
|
<p>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>
|
||||||
|
|
||||||
|
<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; display: none">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
@@ -18,29 +37,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>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try. You can generate the needed CSR below.</p>
|
||||||
|
|
||||||
<p>Which domain are you getting 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 +78,41 @@
|
|||||||
</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 > 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(", "));
|
||||||
|
|
||||||
|
// 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 +126,24 @@ 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() {
|
||||||
|
// Can't show a CSR until both inputs are entered.
|
||||||
|
if ($('#ssldomain').val() == "") return;
|
||||||
|
if ($('#sslcc').val() == "") return;
|
||||||
|
|
||||||
|
// Scroll to it and fetch.
|
||||||
|
$('#csr_info').slideDown();
|
||||||
|
$('#ssl_csr').text('Loading...');
|
||||||
api(
|
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 +162,75 @@ 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function provision_tls_cert() {
|
||||||
|
// Automatically provision any certs.
|
||||||
|
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
|
||||||
|
api(
|
||||||
|
"/ssl/provision",
|
||||||
|
"POST",
|
||||||
|
{ },
|
||||||
|
function(status) {
|
||||||
|
// Clear last attempt.
|
||||||
|
$('#ssl_provision_result').text("");
|
||||||
|
may_reenable_provision_button = true;
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
if (r.result == "skipped") {
|
||||||
|
// not interested --- this domain wasn't in the table
|
||||||
|
// to begin with
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create an HTML block to display the results of this request
|
||||||
|
var n = $("<div><h4/><p/></div>");
|
||||||
|
$('#ssl_provision_result').append(n);
|
||||||
|
|
||||||
|
// plain log line
|
||||||
|
if (typeof r === "string") {
|
||||||
|
n.find("p").text(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// show a header only to disambiguate request blocks
|
||||||
|
if (status.requests.length > 0)
|
||||||
|
n.find("h4").text(r.domains.join(", "));
|
||||||
|
|
||||||
|
if (r.result == "error") {
|
||||||
|
n.find("p").addClass("text-danger").text(r.message);
|
||||||
|
|
||||||
|
} 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>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||||
<tr><td>Contacts and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVdroid</a> ($3.69; free <a href="https://f-droid.org/repository/browse/?fdfilter=dav&fdid=at.bitfire.davdroid">here</a>)</td></tr>
|
<tr><td>Contacts and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVx⁵</a> ($5.99; free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td></tr>
|
||||||
<tr><td>Only Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free beta</a> (free)</td></tr>
|
<tr><td>Only Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free</a> (free)</td></tr>
|
||||||
<tr><td>Only Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
|
<tr><td>Only Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.99)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>Use the following settings:</p>
|
<p>Use the following settings:</p>
|
||||||
|
|||||||
@@ -5,17 +5,156 @@
|
|||||||
|
|
||||||
<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 store it on S3-compatible services like Amazon Web Services (AWS).</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="rsync">rsync</option>
|
||||||
|
<option value="s3">S3 (Amazon or compatible) </option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- LOCAL BACKUP -->
|
||||||
|
<div class="form-group backup-target-local">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>
|
||||||
|
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RSYNC BACKUP -->
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
|
||||||
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrypted, so they are safe to store anywhere. Copy the encryption password from <tt id="backup-encpassword-file"></tt> also but keep it in a safe location.</p>
|
<p>Backups synced to a remote machine using rsync over SSH, with local
|
||||||
|
copies in <tt class="backup-location"></tt>. These files are encrypted, so
|
||||||
|
they are safe to store anywhere.</p> <p>Separately copy the encryption
|
||||||
|
password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||||
|
secure location. You will need this file to decrypt backup files.</p>
|
||||||
|
|
||||||
<h3>Current Backups</h3>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<label for="backup-target-rsync-host" class="col-sm-2 control-label">Hostname</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<label for="backup-target-rsync-path" class="col-sm-2 control-label">Path</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" rows="1" id="backup-target-rsync-path">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<label for="backup-target-rsync-user" class="col-sm-2 control-label">Username</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-rsync-user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<label for="ssh-pub-key" class="col-sm-2 control-label">Public SSH Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="ssh-pub-key" readonly>
|
||||||
|
<div class="small" style="margin-top: 2px">
|
||||||
|
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
|
||||||
|
of target user on the backup server specified above. That way you'll enable secure and
|
||||||
|
passwordless authentication from your mail-in-a-box server and your backup server.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- S3 BACKUP -->
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
|
||||||
|
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-host-select" class="col-sm-2 control-label">S3 Region</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<select class="form-control" rows="1" id="backup-target-s3-host-select">
|
||||||
|
{% for name, host in backup_s3_hosts %}
|
||||||
|
<option value="{{host}}">{{name}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="other">Other (non AWS)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="Endpoint" class="form-control" rows="1" id="backup-target-s3-host">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-user" class="col-sm-2 control-label">S3 Access Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-s3">
|
||||||
|
<label for="backup-target-pass" class="col-sm-2 control-label">S3 Secret Access Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Backblaze -->
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2 bucket. You must have a Backblaze account already.</p>
|
||||||
|
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-user" class="col-sm-2 control-label">B2 Application KeyID</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-pass" class="col-sm-2 control-label">B2 Application Key</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-pass">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group backup-target-b2">
|
||||||
|
<label for="backup-target-b2-bucket" class="col-sm-2 control-label">B2 Bucket</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-bucket">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Common -->
|
||||||
|
<div class="form-group backup-target-local backup-target-rsync backup-target-s3 backup-target-b2">
|
||||||
|
<label for="min-age" class="col-sm-2 control-label">Retention 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 minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="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>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>
|
<h3>Available backups</h3>
|
||||||
|
|
||||||
|
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span id="backup-total-size"></span>.</p>
|
||||||
|
|
||||||
<table id="backup-status" class="table" style="width: auto">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -27,8 +166,16 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
function toggle_form() {
|
||||||
|
var target_type = $("#backup-target-type").val();
|
||||||
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||||
|
$(".backup-target-" + target_type).show();
|
||||||
|
|
||||||
|
init_inputs(target_type);
|
||||||
|
}
|
||||||
|
|
||||||
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 +193,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 +222,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)) );
|
||||||
@@ -80,7 +235,113 @@ function show_system_backup() {
|
|||||||
total_disk_size += b.size;
|
total_disk_size += b.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
total_disk_size += r.unmatched_file_size;
|
||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function show_custom_backup() {
|
||||||
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||||
|
api(
|
||||||
|
"/system/backup/config",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
$("#backup-target-user").val(r.target_user);
|
||||||
|
$("#backup-target-pass").val(r.target_pass);
|
||||||
|
$("#min-age").val(r.min_age_in_days);
|
||||||
|
$(".backup-location").text(r.file_target_directory);
|
||||||
|
$(".backup-encpassword-file").text(r.enc_pw_file);
|
||||||
|
$("#ssh-pub-key").val(r.ssh_pub_key);
|
||||||
|
|
||||||
|
if (r.target == "file://" + r.file_target_directory) {
|
||||||
|
$("#backup-target-type").val("local");
|
||||||
|
} else if (r.target == "off") {
|
||||||
|
$("#backup-target-type").val("off");
|
||||||
|
} else if (r.target.substring(0, 8) == "rsync://") {
|
||||||
|
$("#backup-target-type").val("rsync");
|
||||||
|
var path = r.target.substring(8).split('//');
|
||||||
|
var host_parts = path.shift().split('@');
|
||||||
|
$("#backup-target-rsync-user").val(host_parts[0]);
|
||||||
|
$("#backup-target-rsync-host").val(host_parts[1]);
|
||||||
|
$("#backup-target-rsync-path").val('/'+path[0]);
|
||||||
|
} else if (r.target.substring(0, 5) == "s3://") {
|
||||||
|
$("#backup-target-type").val("s3");
|
||||||
|
var hostpath = r.target.substring(5).split('/');
|
||||||
|
var host = hostpath.shift();
|
||||||
|
$("#backup-target-s3-host-select").val(host);
|
||||||
|
$("#backup-target-s3-host").val(host);
|
||||||
|
$("#backup-target-s3-path").val(hostpath.join('/'));
|
||||||
|
} else if (r.target.substring(0, 5) == "b2://") {
|
||||||
|
$("#backup-target-type").val("b2");
|
||||||
|
var targetPath = r.target.substring(5);
|
||||||
|
var b2_application_keyid = targetPath.split(':')[0];
|
||||||
|
var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
|
||||||
|
var b2_bucket = targetPath.split('@')[1];
|
||||||
|
$("#backup-target-b2-user").val(b2_application_keyid);
|
||||||
|
$("#backup-target-b2-pass").val(b2_applicationkey);
|
||||||
|
$("#backup-target-b2-bucket").val(b2_bucket);
|
||||||
|
}
|
||||||
|
toggle_form()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_custom_backup() {
|
||||||
|
var target_type = $("#backup-target-type").val();
|
||||||
|
var target_user = $("#backup-target-user").val();
|
||||||
|
var target_pass = $("#backup-target-pass").val();
|
||||||
|
|
||||||
|
var target;
|
||||||
|
if (target_type == "local" || target_type == "off")
|
||||||
|
target = target_type;
|
||||||
|
else if (target_type == "s3")
|
||||||
|
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
||||||
|
else if (target_type == "rsync") {
|
||||||
|
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
||||||
|
+ "/" + $("#backup-target-rsync-path").val();
|
||||||
|
target_user = '';
|
||||||
|
} else if (target_type == "b2") {
|
||||||
|
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
|
||||||
|
+ '@' + $('#backup-target-b2-bucket').val()
|
||||||
|
target_user = '';
|
||||||
|
target_pass = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var min_age = $("#min-age").val();
|
||||||
|
api(
|
||||||
|
"/system/backup/config",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
target: target,
|
||||||
|
target_user: target_user,
|
||||||
|
target_pass: target_pass,
|
||||||
|
min_age: min_age
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
// use .text() --- it's a text response, not html
|
||||||
|
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||||
|
},
|
||||||
|
function(r) {
|
||||||
|
// use .text() --- it's a text response, not html
|
||||||
|
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init_inputs(target_type) {
|
||||||
|
function set_host(host) {
|
||||||
|
if(host !== 'other') {
|
||||||
|
$("#backup-target-s3-host").val(host);
|
||||||
|
} else {
|
||||||
|
$("#backup-target-s3-host").val('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target_type == "s3") {
|
||||||
|
$('#backup-target-s3-host-select').off('change').on('change', function() {
|
||||||
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
|
});
|
||||||
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -36,6 +36,22 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-push-9 col-md-3">
|
||||||
|
|
||||||
|
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
|
||||||
|
<div>No reboot is necessary.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="system-privacy-setting" style="display: none">
|
||||||
|
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span> New-Version Check</a></div>
|
||||||
|
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of Mail-in-a-Box.)</small></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /col -->
|
||||||
|
<div class="col-md-pull-3 col-md-8">
|
||||||
|
|
||||||
<table id="system-checks" class="table" style="max-width: 60em">
|
<table id="system-checks" class="table" style="max-width: 60em">
|
||||||
<thead>
|
<thead>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -43,9 +59,34 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
</div> <!-- /col -->
|
||||||
|
</div> <!-- /row -->
|
||||||
|
|
||||||
<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(
|
||||||
|
"/system/reboot",
|
||||||
|
"GET",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
$('#system-reboot-required').show(); // show when r becomes available
|
||||||
|
$('#system-reboot-required').find('button').toggle(r);
|
||||||
|
$('#system-reboot-required').find('div').toggle(!r);
|
||||||
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/system/status",
|
"/system/status",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -82,5 +123,38 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm_reboot() {
|
||||||
|
show_modal_confirm(
|
||||||
|
"Reboot",
|
||||||
|
$("<p>This will reboot your Mail-in-a-Box <code>{{hostname}}</code>.</p> <p>Until the machine is fully restarted, your users will not be able to send and receive email, and you will not be able to connect to this control panel or with SSH. The reboot cannot be cancelled.</p>"),
|
||||||
|
"Reboot Now",
|
||||||
|
function() {
|
||||||
|
api(
|
||||||
|
"/system/reboot",
|
||||||
|
"POST",
|
||||||
|
{ },
|
||||||
|
function(r) {
|
||||||
|
var msg = "<p>Please reload this page after a minute or so.</p>";
|
||||||
|
if (r) msg = "<p>The reboot command said:</p> <pre>" + $("<pre/>").text(r).html() + "</pre>"; // successful reboots don't produce any output; the output must be HTML-escaped
|
||||||
|
show_modal_error("Reboot", msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#user_table h4 { margin: 1em 0 0 0; }
|
|
||||||
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
||||||
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||||
#user_table .account_inactive .if_active { display: none; }
|
#user_table .account_inactive .if_active { display: none; }
|
||||||
@@ -31,10 +30,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 eight characters consisting of English letters and numbers only. 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>
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th width="50%">Email Address</th>
|
<th width="50%">Email Address</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
<th>Mailbox Size</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -73,8 +71,6 @@
|
|||||||
archive account
|
archive account
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class='mailboxsize'>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="user-extra-template" class="if_inactive">
|
<tr id="user-extra-template" class="if_inactive">
|
||||||
<td colspan="3" style="border: 0; padding-top: 0">
|
<td colspan="3" style="border: 0; padding-top: 0">
|
||||||
@@ -84,6 +80,48 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3>Mail user API (advanced)</h3>
|
||||||
|
|
||||||
|
<p>Use your box’s mail user API to add/change/remove users from the command-line or custom services you build.</p>
|
||||||
|
|
||||||
|
<p>Usage:</p>
|
||||||
|
|
||||||
|
<pre>curl -X <b>VERB</b> [-d "<b>parameters</b>"] --user {email}:{password} https://{{hostname}}/admin/mail/users[<b>action</b>]</pre>
|
||||||
|
|
||||||
|
<p>Brackets denote an optional argument. Please note that the POST body <code>parameters</code> must be URL-encoded.</p>
|
||||||
|
|
||||||
|
<p>The email and password given to the <code>--user</code> option must be an administrative user on this system.</p>
|
||||||
|
|
||||||
|
<h4 style="margin-bottom: 0">Verbs</h4>
|
||||||
|
|
||||||
|
<table class="table" style="margin-top: .5em">
|
||||||
|
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||||
|
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/remove</td> <td>Removes a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
|
||||||
|
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
|
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your administrative email address and password.</p>
|
||||||
|
|
||||||
|
<pre># Gives a JSON-encoded list of all mail users
|
||||||
|
curl -X GET https://{{hostname}}/admin/mail/users?format=json
|
||||||
|
|
||||||
|
# Adds a new email user
|
||||||
|
curl -X POST -d "email=new_user@mydomail.com" -d "password=s3curE_pa5Sw0rD" https://{{hostname}}/admin/mail/users/add
|
||||||
|
|
||||||
|
# Removes a email user
|
||||||
|
curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/users/remove
|
||||||
|
|
||||||
|
# Adds admin privilege to an email user
|
||||||
|
curl -X POST -d "email=new_user@mydomail.com" -d "privilege=admin" https://{{hostname}}/admin/mail/users/privileges/add
|
||||||
|
|
||||||
|
# Removes admin privilege from an email user
|
||||||
|
curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/users/privileges/remove
|
||||||
|
</pre>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_users() {
|
function show_users() {
|
||||||
@@ -95,8 +133,8 @@ function show_users() {
|
|||||||
function(r) {
|
function(r) {
|
||||||
$('#user_table tbody').html("");
|
$('#user_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var hdr = $("<tr><td colspan='3'><h4/></td></tr>");
|
var hdr = $("<tr><th colspan='2' style='background-color: #EEE'></th></tr>");
|
||||||
hdr.find('h4').text(r[i].domain);
|
hdr.find('th').text(r[i].domain);
|
||||||
$('#user_table tbody').append(hdr);
|
$('#user_table tbody').append(hdr);
|
||||||
|
|
||||||
for (var k = 0; k < r[i].users.length; k++) {
|
for (var k = 0; k < r[i].users.length; k++) {
|
||||||
@@ -114,7 +152,6 @@ function show_users() {
|
|||||||
|
|
||||||
n.attr('data-email', user.email);
|
n.attr('data-email', user.email);
|
||||||
n.find('.address').text(user.email)
|
n.find('.address').text(user.email)
|
||||||
n.find('.mailboxsize').text(nice_size(user.mailbox_size))
|
|
||||||
n2.find('.restore_info tt').text(user.mailbox);
|
n2.find('.restore_info tt').text(user.mailbox);
|
||||||
|
|
||||||
if (user.status == 'inactive') continue;
|
if (user.status == 'inactive') continue;
|
||||||
@@ -164,9 +201,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.username)
|
||||||
|
yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Archive User",
|
"Set Password",
|
||||||
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least 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 eight characters and may not contain spaces.</small>" + yourpw + "</p>"),
|
||||||
"Set Password",
|
"Set Password",
|
||||||
function() {
|
function() {
|
||||||
api(
|
api(
|
||||||
@@ -190,7 +232,7 @@ function users_remove(elem) {
|
|||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
|
|
||||||
// can't remove yourself
|
// can't remove yourself
|
||||||
if (api_credentials != null && email == api_credentials[0]) {
|
if (api_credentials != null && email == api_credentials.username) {
|
||||||
show_modal_error("Archive User", "You cannot archive your own account.");
|
show_modal_error("Archive User", "You cannot archive your own account.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -222,7 +264,7 @@ function mod_priv(elem, add_remove) {
|
|||||||
var priv = $(elem).parents('td').find('.name').text();
|
var priv = $(elem).parents('td').find('.name').text();
|
||||||
|
|
||||||
// can't remove your own admin access
|
// can't remove your own admin access
|
||||||
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
|
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
|
||||||
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -245,4 +287,13 @@ function mod_priv(elem, add_remove) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generate_random_password() {
|
||||||
|
var pw = "";
|
||||||
|
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
pw += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||||
|
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr");
|
||||||
|
return false; // cancel click
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function show_change_web_root(elem) {
|
|||||||
var root = $(elem).parents('tr').attr('data-custom-web-root');
|
var root = $(elem).parents('tr').attr('data-custom-web-root');
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
'Change Root Directory for ' + domain,
|
'Change Root Directory for ' + domain,
|
||||||
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.'),
|
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),
|
||||||
'Update',
|
'Update',
|
||||||
function() { do_web_update(); });
|
function() { do_web_update(); });
|
||||||
}
|
}
|
||||||
|
|||||||
16
management/templates/welcome.html
Normal file
16
management/templates/welcome.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<style>
|
||||||
|
.title {
|
||||||
|
margin: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1 class="title">{{hostname}}</h1>
|
||||||
|
|
||||||
|
<p class="subtitle">Welcome to your Mail-in-a-Box control panel.</p>
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
|
# The nice order will group domain names by DNS zone, i.e. the top-most
|
||||||
# First group PRIMARY_HOSTNAME and its subdomains, then parent domains of PRIMARY_HOSTNAME, then other domains.
|
# domain name that we serve that ecompasses a set of subdomains. Map
|
||||||
groups = ( [], [], [] )
|
# each of the domain names to the zone that contains them. Walk the domains
|
||||||
for d in domain_names:
|
# from shortest to longest since zones are always shorter than their
|
||||||
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
|
# subdomains.
|
||||||
groups[0].append(d)
|
zones = { }
|
||||||
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
|
for domain in sorted(domain_names, key=lambda d : len(d)):
|
||||||
groups[1].append(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]
|
|
||||||
|
|
||||||
return groups[0] + groups[1] + groups[2]
|
# Then just dumb lexicographically.
|
||||||
|
d,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Now sort the domain names that fall within each zone.
|
||||||
|
domain_names = sorted(domain_names,
|
||||||
|
key = lambda d : (
|
||||||
|
# First by zone.
|
||||||
|
zone_domains.index(zones[d]),
|
||||||
|
|
||||||
|
# PRIMARY_HOSTNAME is always first within the zone that contains it.
|
||||||
|
d != env['PRIMARY_HOSTNAME'],
|
||||||
|
|
||||||
|
# Followed by any of its subdomains.
|
||||||
|
not d.endswith("." + env['PRIMARY_HOSTNAME']),
|
||||||
|
|
||||||
|
# Then in right-to-left lexicographic order of the .-separated parts of the name.
|
||||||
|
list(reversed(d.split("."))),
|
||||||
|
))
|
||||||
|
|
||||||
|
return domain_names
|
||||||
|
|
||||||
def sort_email_addresses(email_addresses, env):
|
def sort_email_addresses(email_addresses, env):
|
||||||
email_addresses = set(email_addresses)
|
email_addresses = set(email_addresses)
|
||||||
@@ -62,76 +106,6 @@ def sort_email_addresses(email_addresses, env):
|
|||||||
ret.extend(sorted(email_addresses)) # whatever is left
|
ret.extend(sorted(email_addresses)) # whatever is left
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def exclusive_process(name):
|
|
||||||
# Ensure that a process named `name` does not execute multiple
|
|
||||||
# times concurrently.
|
|
||||||
import os, sys, atexit
|
|
||||||
pidfile = '/var/run/mailinabox-%s.pid' % name
|
|
||||||
mypid = os.getpid()
|
|
||||||
|
|
||||||
# Attempt to get a lock on ourself so that the concurrency check
|
|
||||||
# itself is not executed in parallel.
|
|
||||||
with open(__file__, 'r+') as flock:
|
|
||||||
# Try to get a lock. This blocks until a lock is acquired. The
|
|
||||||
# lock is held until the flock file is closed at the end of the
|
|
||||||
# with block.
|
|
||||||
os.lockf(flock.fileno(), os.F_LOCK, 0)
|
|
||||||
|
|
||||||
# While we have a lock, look at the pid file. First attempt
|
|
||||||
# to write our pid to a pidfile if no file already exists there.
|
|
||||||
try:
|
|
||||||
with open(pidfile, 'x') as f:
|
|
||||||
# Successfully opened a new file. Since the file is new
|
|
||||||
# there is no concurrent process. Write our pid.
|
|
||||||
f.write(str(mypid))
|
|
||||||
atexit.register(clear_my_pid, pidfile)
|
|
||||||
return
|
|
||||||
except FileExistsError:
|
|
||||||
# The pid file already exixts, but it may contain a stale
|
|
||||||
# pid of a terminated process.
|
|
||||||
with open(pidfile, 'r+') as f:
|
|
||||||
# Read the pid in the file.
|
|
||||||
existing_pid = None
|
|
||||||
try:
|
|
||||||
existing_pid = int(f.read().strip())
|
|
||||||
except ValueError:
|
|
||||||
pass # No valid integer in the file.
|
|
||||||
|
|
||||||
# Check if the pid in it is valid.
|
|
||||||
if existing_pid:
|
|
||||||
if is_pid_valid(existing_pid):
|
|
||||||
print("Another %s is already running (pid %d)." % (name, existing_pid), file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Write our pid.
|
|
||||||
f.seek(0)
|
|
||||||
f.write(str(mypid))
|
|
||||||
f.truncate()
|
|
||||||
atexit.register(clear_my_pid, pidfile)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_my_pid(pidfile):
|
|
||||||
import os
|
|
||||||
os.unlink(pidfile)
|
|
||||||
|
|
||||||
|
|
||||||
def is_pid_valid(pid):
|
|
||||||
"""Checks whether a pid is a valid process ID of a currently running process."""
|
|
||||||
# adapted from http://stackoverflow.com/questions/568271/how-to-check-if-there-exists-a-process-with-a-given-pid
|
|
||||||
import os, errno
|
|
||||||
if pid <= 0: raise ValueError('Invalid PID.')
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
except OSError as err:
|
|
||||||
if err.errno == errno.ESRCH: # No such process
|
|
||||||
return False
|
|
||||||
elif err.errno == errno.EPERM: # Not permitted to send signal
|
|
||||||
return True
|
|
||||||
else: # EINVAL
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
|
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
|
||||||
# A safe way to execute processes.
|
# A safe way to execute processes.
|
||||||
# Some processes like apt-get require being given a sane PATH.
|
# Some processes like apt-get require being given a sane PATH.
|
||||||
@@ -200,3 +174,10 @@ 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))
|
||||||
|
|
||||||
|
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,107 @@
|
|||||||
# 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, include_auto=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)
|
||||||
|
|
||||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
if include_www_redirects and include_auto:
|
||||||
# IP address than this box. Remove those domains from our list.
|
# Add 'www.' subdomains that we want to provide default redirects
|
||||||
dns = get_custom_dns_config(env)
|
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
|
||||||
for domain, rtype, value in dns:
|
# the topmost of each domain we serve.
|
||||||
if domain not in domains: continue
|
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
|
||||||
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
|
if include_auto:
|
||||||
# default server (nginx's default_server).
|
# Add Autoconfiguration domains for domains that there are user accounts at:
|
||||||
|
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||||
|
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
|
||||||
|
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
|
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
|
|
||||||
|
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
|
||||||
|
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
|
||||||
|
|
||||||
|
if exclude_dns_elsewhere:
|
||||||
|
# ...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.
|
||||||
|
domains -= get_domains_with_a_records(env)
|
||||||
|
|
||||||
|
# Ensure the PRIMARY_HOSTNAME is in the list so we can serve webmail
|
||||||
|
# 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 +123,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 +147,81 @@ 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 += "\t# 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)
|
# Parse some flags in the fragment of the URL.
|
||||||
|
pass_http_host_header = False
|
||||||
|
proxy_redirect_off = False
|
||||||
|
frame_options_header_sameorigin = False
|
||||||
|
m = re.search("#(.*)$", url)
|
||||||
|
if m:
|
||||||
|
for flag in m.group(1).split(","):
|
||||||
|
if flag == "pass-http-host":
|
||||||
|
pass_http_host_header = True
|
||||||
|
elif flag == "no-proxy-redirect":
|
||||||
|
proxy_redirect_off = True
|
||||||
|
elif flag == "frame-options-sameorigin":
|
||||||
|
frame_options_header_sameorigin = True
|
||||||
|
url = re.sub("#(.*)$", "", url)
|
||||||
|
|
||||||
|
nginx_conf_extra += "\tlocation %s {" % path
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_pass %s;" % url
|
||||||
|
if proxy_redirect_off:
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_redirect off;"
|
||||||
|
if pass_http_host_header:
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header Host $http_host;"
|
||||||
|
if frame_options_header_sameorigin:
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Frame-Options SAMEORIGIN;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Host $http_host;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Forwarded-Proto $scheme;"
|
||||||
|
nginx_conf_extra += "\n\t\tproxy_set_header X-Real-IP $remote_addr;"
|
||||||
|
nginx_conf_extra += "\n\t}\n"
|
||||||
|
for path, alias in yaml.get("aliases", {}).items():
|
||||||
|
nginx_conf_extra += "\tlocation %s {" % path
|
||||||
|
nginx_conf_extra += "\n\t\talias %s;" % alias
|
||||||
|
nginx_conf_extra += "\n\t}\n"
|
||||||
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 += "\tadd_header Strict-Transport-Security \"max-age=15768000\" always;\n"
|
||||||
|
elif hsts == "preload":
|
||||||
|
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\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 +232,21 @@ 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
|
try:
|
||||||
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 not os.path.exists(ssl_certificate):
|
except OSError: # PRIMARY_HOSTNAME cert is missing
|
||||||
return ("danger", "No Certificate Installed")
|
tls_cert = None
|
||||||
cert_status, cert_status_details = check_certificate(domain, ssl_certificate, ssl_key)
|
if tls_cert is None: return ("danger", "No certificate installed.")
|
||||||
|
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||||
if cert_status == "OK":
|
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 +258,8 @@ 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)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
7
management/wsgi.py
Normal file
7
management/wsgi.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from daemon import app
|
||||||
|
import auth, utils
|
||||||
|
|
||||||
|
app.logger.addHandler(utils.create_syslog_handler())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(port=10222)
|
||||||
54
ppa/Makefile
54
ppa/Makefile
@@ -1,54 +0,0 @@
|
|||||||
all: clean build_postgrey build_dovecot_lucene
|
|
||||||
|
|
||||||
clean:
|
|
||||||
# Clean.
|
|
||||||
rm -rf /tmp/build
|
|
||||||
|
|
||||||
# Prepare to build source packages.
|
|
||||||
mkdir -p /tmp/build
|
|
||||||
|
|
||||||
build_postgrey:
|
|
||||||
# Download our fork of the Debian postgrey package.
|
|
||||||
git clone https://github.com/mail-in-a-box/postgrey /tmp/build/postgrey
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Build the source package.
|
|
||||||
(cd /tmp/build/postgrey; dpkg-buildpackage -S -us -uc -nc)
|
|
||||||
|
|
||||||
# Sign the packages.
|
|
||||||
debsign /tmp/build/postgrey_1.35-1miab1_source.changes
|
|
||||||
|
|
||||||
# Upload to PPA.
|
|
||||||
dput ppa:mail-in-a-box/ppa /tmp/build/postgrey_1.35-1miab1_source.changes
|
|
||||||
|
|
||||||
# Clear the intermediate files.
|
|
||||||
rm -rf /tmp/build/postgrey
|
|
||||||
|
|
||||||
# TESTING BINARY PACKAGE
|
|
||||||
#sudo apt-get build-dep -y postgrey
|
|
||||||
#(cd /tmp/build/postgrey; dpkg-buildpackage -us -uc -nc)
|
|
||||||
|
|
||||||
build_dovecot_lucene:
|
|
||||||
# Get the upstream source.
|
|
||||||
(cd /tmp/build; apt-get source dovecot)
|
|
||||||
|
|
||||||
# Patch it so that we build dovecot-lucene (and nothing else).
|
|
||||||
patch -p1 -d /tmp/build/dovecot-2.2.9 < dovecot_lucene.diff
|
|
||||||
|
|
||||||
# Build the source package.
|
|
||||||
(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -S -us -uc -nc)
|
|
||||||
|
|
||||||
# Sign the packages.
|
|
||||||
#debsign /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
|
|
||||||
|
|
||||||
# Upload it.
|
|
||||||
#dput ppa:mail-in-a-box/ppa /tmp/build/dovecot_2.2.9-1ubuntu2.1_amd64.changes
|
|
||||||
|
|
||||||
# TESTING BINARY PACKAGE
|
|
||||||
# Install build dependencies and build dependencies we've added in our patch,
|
|
||||||
# and then build the binary package.
|
|
||||||
#sudo apt-get build-dep -y dovecot
|
|
||||||
#sudo apt-get install libclucene-dev liblzma-dev libexttextcat-dev libstemmer-dev
|
|
||||||
#(cd /tmp/build/dovecot-2.2.9; dpkg-buildpackage -us -uc -nc)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
ppa instructions
|
|
||||||
================
|
|
||||||
|
|
||||||
Mail-in-a-Box maintains a Launchpad.net PPA ([Mail-in-a-Box PPA](https://launchpad.net/~mail-in-a-box/+archive/ubuntu/ppa)) for additional deb's that we want to have installed on systems.
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
Building
|
|
||||||
--------
|
|
||||||
|
|
||||||
To rebuild the packages in the PPA, you'll need to be @JoshData.
|
|
||||||
|
|
||||||
First:
|
|
||||||
|
|
||||||
* You should have an account on Launchpad.net.
|
|
||||||
* Your account should have your GPG key set (to the fingerprint of a GPG key on your system matching the identity at the top of the debian/changelog files).
|
|
||||||
* You should have write permission to the PPA.
|
|
||||||
|
|
||||||
To build:
|
|
||||||
|
|
||||||
# Start a clean VM.
|
|
||||||
vagrant up
|
|
||||||
|
|
||||||
# Put your signing keys (on the host machine) into the VM (so it can sign the debs).
|
|
||||||
gpg --export-secret-keys | vagrant ssh -- gpg --import
|
|
||||||
|
|
||||||
# Build & upload to launchpad.
|
|
||||||
vagrant ssh -- "cd /vagrant && make"
|
|
||||||
|
|
||||||
To use on a Mail-in-a-Box box, add the PPA and then upgrade packages:
|
|
||||||
|
|
||||||
apt-add-repository ppa:mail-in-a-box/ppa
|
|
||||||
apt-get update
|
|
||||||
apt-get upgrade
|
|
||||||
|
|
||||||
12
ppa/Vagrantfile
vendored
12
ppa/Vagrantfile
vendored
@@ -1,12 +0,0 @@
|
|||||||
# -*- mode: ruby -*-
|
|
||||||
# vi: set ft=ruby :
|
|
||||||
|
|
||||||
Vagrant.configure("2") do |config|
|
|
||||||
config.vm.box = "ubuntu14.04"
|
|
||||||
config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box"
|
|
||||||
|
|
||||||
config.vm.provision :shell, :inline => <<-SH
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y git dpkg-dev devscripts dput
|
|
||||||
SH
|
|
||||||
end
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
--- a/debian/control
|
|
||||||
+++ b/debian/control
|
|
||||||
@@ -1,210 +1,22 @@
|
|
||||||
Source: dovecot
|
|
||||||
Section: mail
|
|
||||||
Priority: optional
|
|
||||||
-Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
|
|
||||||
-XSBC-Original-Maintainer: Dovecot Maintainers <jaldhar-dovecot@debian.org>
|
|
||||||
-Uploaders: Jaldhar H. Vyas <jaldhar@debian.org>, Fabio Tranchitella <kobold@debian.org>, Joel Johnson <mrjoel@lixil.net>, Marco Nenciarini <mnencia@debian.org>
|
|
||||||
-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, hardening-wrapper, dh-autoreconf, autotools-dev
|
|
||||||
+Maintainer: Joshua Tauberer <jt@occams.info>
|
|
||||||
+XSBC-Original-Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
|
|
||||||
+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
|
|
||||||
Homepage: http://dovecot.org/
|
|
||||||
Vcs-Git: git://git.debian.org/git/collab-maint/dovecot.git
|
|
||||||
Vcs-Browser: http://git.debian.org/?p=collab-maint/dovecot.git
|
|
||||||
|
|
||||||
-Package: dovecot-core
|
|
||||||
-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)
|
|
||||||
-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
|
|
||||||
-Provides: dovecot-common
|
|
||||||
-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)
|
|
||||||
-Description: secure POP3/IMAP server - core files
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- 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.
|
|
||||||
-
|
|
||||||
-Package: dovecot-dev
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Replaces: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Breaks: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Description: secure POP3/IMAP server - header files
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains header files needed to compile plugins for the Dovecot
|
|
||||||
- mail server.
|
|
||||||
-
|
|
||||||
-Package: dovecot-imapd
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Provides: imap-server
|
|
||||||
-Description: secure POP3/IMAP server - IMAP daemon
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains the Dovecot IMAP server.
|
|
||||||
-
|
|
||||||
-Package: dovecot-pop3d
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Provides: pop3-server
|
|
||||||
-Description: secure POP3/IMAP server - POP3 daemon
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains the Dovecot POP3 server.
|
|
||||||
-
|
|
||||||
-Package: dovecot-lmtpd
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Replaces: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Breaks: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Description: secure POP3/IMAP server - LMTP server
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains the Dovecot LMTP server.
|
|
||||||
-
|
|
||||||
-Package: dovecot-managesieved
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), dovecot-sieve (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Replaces: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Breaks: dovecot-common (<< 1:2.0.14-2~)
|
|
||||||
-Description: secure POP3/IMAP server - ManageSieve server
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains the Dovecot ManageSieve server.
|
|
||||||
-
|
|
||||||
-Package: dovecot-pgsql
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - PostgreSQL support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides PostgreSQL support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-mysql
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - MySQL support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides MySQL support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-sqlite
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - SQLite support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides SQLite support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-ldap
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Description: secure POP3/IMAP server - LDAP support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides LDAP support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-gssapi
|
|
||||||
+Package: dovecot-lucene
|
|
||||||
Architecture: any
|
|
||||||
Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-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
|
|
||||||
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
|
|
||||||
mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
fast, extensible, and portable.
|
|
||||||
.
|
|
||||||
- This package provides GSSAPI authentication support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-sieve
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version}), ucf (>= 2.0020)
|
|
||||||
-Description: secure POP3/IMAP server - Sieve filters support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides Sieve filters support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-solr
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${shlibs:Depends}, ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - Solr support
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package provides Solr full text search support for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: dovecot-dbg
|
|
||||||
-Section: debug
|
|
||||||
-Priority: extra
|
|
||||||
-Architecture: any
|
|
||||||
-Depends: ${misc:Depends}, dovecot-core (= ${binary:Version})
|
|
||||||
-Description: secure POP3/IMAP server - debug symbols
|
|
||||||
- Dovecot is a mail server whose major goals are security and extreme
|
|
||||||
- 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
|
|
||||||
- mbox/Maildir and its own dbox/mdbox formats, and should also be pretty
|
|
||||||
- fast, extensible, and portable.
|
|
||||||
- .
|
|
||||||
- This package contains debug symbols for Dovecot.
|
|
||||||
-
|
|
||||||
-Package: mail-stack-delivery
|
|
||||||
-Architecture: all
|
|
||||||
-Depends: dovecot-core, dovecot-imapd, dovecot-pop3d, dovecot-managesieved,
|
|
||||||
- postfix, ${misc:Depends}
|
|
||||||
-Replaces: dovecot-postfix (<< 1:1.2.12-0ubuntu1~)
|
|
||||||
-Description: mail server delivery agent stack provided by Ubuntu server team
|
|
||||||
- Ubuntu's mail stack provides fully operational delivery with
|
|
||||||
- safe defaults and additional options. Out of the box it supports IMAP,
|
|
||||||
- POP3 and SMTP services with SASL authentication and Maildir as default
|
|
||||||
- storage engine.
|
|
||||||
- .
|
|
||||||
- This package contains configuration files for dovecot.
|
|
||||||
- .
|
|
||||||
- This package modifies postfix's configuration to integrate with dovecot
|
|
||||||
+ This package provides Lucene full text search support for Dovecot.
|
|
||||||
diff --git a/debian/dovecot-lucene.links b/debian/dovecot-lucene.links
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..6ffcbeb
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/debian/dovecot-lucene.links
|
|
||||||
@@ -0,0 +1 @@
|
|
||||||
+/usr/share/bug/dovecot-core /usr/share/bug/dovecot-lucene
|
|
||||||
diff --git a/debian/dovecot-lucene.lintian-overrides b/debian/dovecot-lucene.lintian-overrides
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..60d90fd
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/debian/dovecot-lucene.lintian-overrides
|
|
||||||
@@ -0,0 +1,2 @@
|
|
||||||
+dovecot-lucene: hardening-no-fortify-functions usr/lib/dovecot/modules/lib21_fts_lucene_plugin.so
|
|
||||||
+
|
|
||||||
diff --git a/debian/dovecot-lucene.substvars b/debian/dovecot-lucene.substvars
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..ed54f36
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/debian/dovecot-lucene.substvars
|
|
||||||
@@ -0,0 +1,2 @@
|
|
||||||
+shlibs:Depends=libc6 (>= 2.4), libclucene-core1 (>= 2.3.3.4), libgcc1 (>= 1:4.1.1), libstdc++6 (>= 4.1.1), libstemmer0d (>= 0+svn527)
|
|
||||||
+misc:Depends=
|
|
||||||
diff --git a/debian/dovecot-lucene.triggers b/debian/dovecot-lucene.triggers
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..3d933a5
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/debian/dovecot-lucene.triggers
|
|
||||||
@@ -0,0 +1 @@
|
|
||||||
+activate register-dovecot-plugin
|
|
||||||
diff --git a/debian/rules b/debian/rules
|
|
||||||
index dcee2f6..9533a4a 100755
|
|
||||||
--- a/debian/rules
|
|
||||||
+++ b/debian/rules
|
|
||||||
@@ -40,6 +40,7 @@ config-stamp: configure
|
|
||||||
--with-solr \
|
|
||||||
--with-ioloop=best \
|
|
||||||
--with-libwrap \
|
|
||||||
+ --with-lucene \
|
|
||||||
--host=$(DEB_HOST_GNU_TYPE) \
|
|
||||||
--build=$(DEB_BUILD_GNU_TYPE) \
|
|
||||||
--prefix=/usr \
|
|
||||||
@@ -95,6 +96,10 @@ install: build
|
|
||||||
dh_testroot
|
|
||||||
dh_clean -k
|
|
||||||
dh_installdirs
|
|
||||||
+ mkdir -p $(CURDIR)/debian/dovecot-lucene/usr/lib/dovecot/modules
|
|
||||||
+ mv $(CURDIR)/src/plugins/fts-lucene/.libs/* $(CURDIR)/debian/dovecot-lucene/usr/lib/dovecot/modules/
|
|
||||||
+
|
|
||||||
+rest_disabled_by_miab:
|
|
||||||
$(MAKE) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
|
||||||
$(MAKE) -C $(PIGEONHOLE_DIR) install DESTDIR=$(CURDIR)/debian/dovecot-core
|
|
||||||
rm `find $(CURDIR)/debian -name '*.la'`
|
|
||||||
@@ -209,7 +214,7 @@ binary-arch: build install
|
|
||||||
dh_installdocs -a
|
|
||||||
dh_installexamples -a
|
|
||||||
dh_installpam -a
|
|
||||||
- mv $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot-core $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot
|
|
||||||
+ # mv $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot-core $(CURDIR)/debian/dovecot-core/etc/pam.d/dovecot
|
|
||||||
dh_systemd_enable
|
|
||||||
dh_installinit -pdovecot-core --name=dovecot
|
|
||||||
dh_systemd_start
|
|
||||||
@@ -220,10 +225,10 @@ binary-arch: build install
|
|
||||||
dh_lintian -a
|
|
||||||
dh_installchangelogs -a ChangeLog
|
|
||||||
dh_link -a
|
|
||||||
- dh_strip -a --dbg-package=dovecot-dbg
|
|
||||||
+ #dh_strip -a --dbg-package=dovecot-dbg
|
|
||||||
dh_compress -a
|
|
||||||
dh_fixperms -a
|
|
||||||
- chmod 0700 debian/dovecot-core/etc/dovecot/private
|
|
||||||
+ #chmod 0700 debian/dovecot-core/etc/dovecot/private
|
|
||||||
dh_makeshlibs -a -n
|
|
||||||
dh_installdeb -a
|
|
||||||
dh_shlibdeps -a
|
|
||||||
72
security.md
72
security.md
@@ -1,9 +1,14 @@
|
|||||||
Mail-in-a-Box Security Guide
|
Mail-in-a-Box Security Guide
|
||||||
============================
|
============================
|
||||||
|
|
||||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
|
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
|
||||||
|
|
||||||
This page documents the security features of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
|
This page documents the security posture of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
|
||||||
|
|
||||||
|
Reporting Security Vulnerabilities
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
Security vulnerabilities should be reported to the [project's maintainer](https://joshdata.me) via email.
|
||||||
|
|
||||||
Threat Model
|
Threat Model
|
||||||
------------
|
------------
|
||||||
@@ -17,8 +22,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
|
||||||
----------------
|
----------------
|
||||||
@@ -29,32 +37,24 @@ The box's administrator and its (non-administrative) mail users must sometimes c
|
|||||||
|
|
||||||
These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):
|
These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):
|
||||||
|
|
||||||
* SMTP Submission (port 587). Mail users submit outbound mail through SMTP with STARTTLS on port 587.
|
* SMTP Submission (ports 465/587). Mail users submit outbound mail through SMTP with TLS (port 465) or STARTTLS (port 587).
|
||||||
* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.
|
* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.
|
||||||
* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.
|
* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.
|
||||||
|
|
||||||
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.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.
|
* We track the [Mozilla Intermediate Ciphers Recommendation](https://wiki.mozilla.org/Security/Server_Side_TLS), balancing security with supporting a wide range of mail clients. Diffie-Hellman ciphers use a 2048-bit key for forward secrecy. For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).
|
||||||
* 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.
|
|
||||||
|
|
||||||
Additionally:
|
Additionally:
|
||||||
|
|
||||||
* SMTP Submission (port 587) will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
|
* SMTP Submission on port 587 will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
|
||||||
* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))
|
* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))
|
||||||
|
|
||||||
For more details, see the [output of SSLyze for these ports](tests/tls_results.txt).
|
|
||||||
|
|
||||||
The cipher and protocol selection are chosen to support the following clients:
|
|
||||||
|
|
||||||
* For HTTPS: Firefox 1, Chrome 1, IE 7, Opera 5, Safari 1, Windows XP IE8, Android 2.3, Java 7.
|
|
||||||
* For other protocols: TBD.
|
|
||||||
|
|
||||||
### Password Storage
|
### Password Storage
|
||||||
|
|
||||||
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
|
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py)) Password changes (as well as changes to control panel two-factor authentication settings) expire any control panel login sessions.
|
||||||
|
|
||||||
### Console access
|
### Console access
|
||||||
|
|
||||||
@@ -64,27 +64,51 @@ The [setup guide video](https://mailinabox.email/) explains how to verify the ho
|
|||||||
|
|
||||||
If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that the box automatically puts into DNS can also be used to verify the host key fingerprint by setting `VerifyHostKeyDNS yes` in your `ssh/.config` file or by logging in with `ssh -o VerifyHostKeyDNS=yes`. ([source](management/dns_update.py))
|
If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that the box automatically puts into DNS can also be used to verify the host key fingerprint by setting `VerifyHostKeyDNS yes` in your `ssh/.config` file or by logging in with `ssh -o VerifyHostKeyDNS=yes`. ([source](management/dns_update.py))
|
||||||
|
|
||||||
|
### Brute-force attack mitigation
|
||||||
|
|
||||||
|
`fail2ban` provides some protection from brute-force login attacks (repeated logins that guess account passwords) by blocking offending IP addresses at the network level.
|
||||||
|
|
||||||
|
The following services are protected: SSH, IMAP (dovecot), SMTP submission (postfix), webmail (roundcube), Nextcloud/CalDAV/CardDAV (over HTTP), and the Mail-in-a-Box control panel (over HTTP).
|
||||||
|
|
||||||
|
Some other services running on the box may be missing fail2ban filters.
|
||||||
|
|
||||||
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 (TLSv1 and later, no RC4) 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 guarantee 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
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
### Encryption
|
### Encryption Settings
|
||||||
|
|
||||||
As discussed above, there is no way to require on-the-wire encryption of mail. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to SSLv3 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for forward secrecy, however. ([source](setup/mail-postfix.sh))
|
As with outbound email, there is no way to require on-the-wire encryption of incoming mail from all senders. When the box receives an incoming email (SMTP on port 25), it offers encryption (STARTTLS) but cannot require that senders use it because some senders may not support STARTTLS at all and other senders may support STARTTLS but not with the latest protocols/ciphers. To give senders the best chance at making use of encryption, the box offers protocols back to TLSv1 and ciphers with key lengths as low as 112 bits. Modern clients (senders) will make use of the 256-bit ciphers and Diffie-Hellman ciphers with a 2048-bit key for perfect forward secrecy, however. ([source](setup/mail-postfix.sh))
|
||||||
|
|
||||||
|
### MTA-STS
|
||||||
|
|
||||||
|
The box publishes a SMTP MTA Strict Transport Security ([SMTP MTA-STS](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security)) policy (via DNS and HTTPS) in "enforce" mode. Senders that support MTA-STS will use a secure SMTP connection. (MTA-STS tells senders to connect and expect a signed TLS certificate for the "MX" domain without permitting a fallback to an unencrypted connection.)
|
||||||
|
|
||||||
### DANE
|
### DANE
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,62 @@
|
|||||||
#########################################################
|
#########################################################
|
||||||
# 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
|
# If a version to install isn't explicitly given as an environment
|
||||||
|
# variable, then install the latest version. But the latest version
|
||||||
|
# depends on the machine's version of Ubuntu. Existing users need to
|
||||||
|
# be able to upgrade to the latest version available for that version
|
||||||
|
# of Ubuntu to satisfy the migration requirements.
|
||||||
|
#
|
||||||
|
# Also, the system status checks read this script for TAG = (without the
|
||||||
|
# space, but if we put it in a comment it would confuse the status checks!)
|
||||||
|
# to get the latest version, so the first such line must be the one that we
|
||||||
|
# want to display in status checks.
|
||||||
|
#
|
||||||
|
# Allow point-release versions of the major releases, e.g. 22.04.1 is OK.
|
||||||
|
UBUNTU_VERSION=$( lsb_release -d | sed 's/.*:\s*//' | sed 's/\([0-9]*\.[0-9]*\)\.[0-9]/\1/' )
|
||||||
|
if [ "$UBUNTU_VERSION" == "Ubuntu 22.04 LTS" ]; then
|
||||||
|
# This machine is running Ubuntu 22.04, which is supported by
|
||||||
|
# Mail-in-a-Box versions 60 and later.
|
||||||
|
TAG=v60.1
|
||||||
|
elif [ "$UBUNTU_VERSION" == "Ubuntu 18.04 LTS" ]; then
|
||||||
|
# This machine is running Ubuntu 18.04, which is supported by
|
||||||
|
# Mail-in-a-Box versions 0.40 through 5x.
|
||||||
|
echo "Support is ending for Ubuntu 18.04."
|
||||||
|
echo "Please immediately begin to migrate your data to"
|
||||||
|
echo "a new machine running Ubuntu 22.04. See:"
|
||||||
|
echo "https://mailinabox.email/maintenance.html#upgrade"
|
||||||
|
TAG=v57a
|
||||||
|
elif [ "$UBUNTU_VERSION" == "Ubuntu 14.04 LTS" ]; then
|
||||||
|
# This machine is running Ubuntu 14.04, which is supported by
|
||||||
|
# Mail-in-a-Box versions 1 through v0.30.
|
||||||
|
echo "Ubuntu 14.04 is no longer supported."
|
||||||
|
echo "The last version of Mail-in-a-Box supporting Ubuntu 14.04 will be installed."
|
||||||
|
TAG=v0.30
|
||||||
|
else
|
||||||
|
echo "This script may be used only on a machine running Ubuntu 14.04, 18.04, or 22.04."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Are we running as root?
|
# Are we running as root?
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This script must be run as root. Did you leave out sudo?"
|
echo "This script must be run as root. Did you leave out sudo?"
|
||||||
exit
|
exit 1
|
||||||
fi
|
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
|
||||||
echo Installing git . . .
|
if [ ! -f /usr/bin/git ]; then
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
echo Installing git . . .
|
||||||
echo
|
apt-get -q -q update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y git < /dev/null
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
echo Downloading Mail-in-a-Box $TAG. . .
|
echo Downloading Mail-in-a-Box $TAG. . .
|
||||||
git clone \
|
git clone \
|
||||||
@@ -36,12 +73,12 @@ fi
|
|||||||
cd $HOME/mailinabox
|
cd $HOME/mailinabox
|
||||||
|
|
||||||
# Update it.
|
# Update it.
|
||||||
if [ "$TAG" != `git describe` ]; then
|
if [ "$TAG" != $(git describe) ]; then
|
||||||
echo Updating Mail-in-a-Box to $TAG . . .
|
echo Updating Mail-in-a-Box to $TAG . . .
|
||||||
git fetch --depth 1 --force --prune origin tag $TAG
|
git fetch --depth 1 --force --prune origin tag $TAG
|
||||||
if ! git checkout -q $TAG; then
|
if ! git checkout -q $TAG; then
|
||||||
echo "Update failed. Did you modify something in `pwd`?"
|
echo "Update failed. Did you modify something in $(pwd)?"
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -20,28 +21,39 @@ mkdir -p $STORAGE_ROOT/mail/dkim
|
|||||||
# Not quite sure why.
|
# Not quite sure why.
|
||||||
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
|
echo "127.0.0.1" > /etc/opendkim/TrustedHosts
|
||||||
|
|
||||||
|
# We need to at least create these files, since we reference them later.
|
||||||
|
# Otherwise, opendkim startup will fail
|
||||||
|
touch /etc/opendkim/KeyTable
|
||||||
|
touch /etc/opendkim/SigningTable
|
||||||
|
|
||||||
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then
|
||||||
true # already done #NODOC
|
true # already done #NODOC
|
||||||
else
|
else
|
||||||
# Add various configuration options to the end of `opendkim.conf`.
|
# Add various configuration options to the end of `opendkim.conf`.
|
||||||
cat >> /etc/opendkim.conf << EOF;
|
cat >> /etc/opendkim.conf << EOF;
|
||||||
|
Canonicalization relaxed/simple
|
||||||
MinimumKeyBits 1024
|
MinimumKeyBits 1024
|
||||||
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||||
InternalHosts refile:/etc/opendkim/TrustedHosts
|
InternalHosts refile:/etc/opendkim/TrustedHosts
|
||||||
KeyTable refile:/etc/opendkim/KeyTable
|
KeyTable refile:/etc/opendkim/KeyTable
|
||||||
SigningTable refile:/etc/opendkim/SigningTable
|
SigningTable refile:/etc/opendkim/SigningTable
|
||||||
Socket inet:8891@localhost
|
Socket inet:8891@127.0.0.1
|
||||||
RequireSafeKeys false
|
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.
|
||||||
@@ -50,7 +62,40 @@ chmod go-rwx $STORAGE_ROOT/mail/dkim
|
|||||||
|
|
||||||
tools/editconf.py /etc/opendmarc.conf -s \
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
"Syslog=true" \
|
"Syslog=true" \
|
||||||
"Socket=inet:8893@[127.0.0.1]"
|
"Socket=inet:8893@[127.0.0.1]" \
|
||||||
|
"FailureReports=true"
|
||||||
|
|
||||||
|
# SPFIgnoreResults causes the filter to ignore any SPF results in the header
|
||||||
|
# of the message. This is useful if you want the filter to perfrom SPF checks
|
||||||
|
# itself, or because you don't trust the arriving header. This added header is
|
||||||
|
# used by spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
|
"SPFIgnoreResults=true"
|
||||||
|
|
||||||
|
# SPFSelfValidate causes the filter to perform a fallback SPF check itself
|
||||||
|
# when it can find no SPF results in the message header. If SPFIgnoreResults
|
||||||
|
# is also set, it never looks for SPF results in headers and always performs
|
||||||
|
# the SPF check itself when this is set. This added header is used by
|
||||||
|
# spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
|
"SPFSelfValidate=true"
|
||||||
|
|
||||||
|
# Enables generation of failure reports for sending domains that publish a
|
||||||
|
# "none" policy.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendmarc.conf -s \
|
||||||
|
"FailureReportsOnNone=true"
|
||||||
|
|
||||||
|
# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
|
||||||
|
# unsigned messages from domains with no "signs all" policy. The reported DKIM
|
||||||
|
# result will be "none" in such cases. Normally unsigned mail from non-strict
|
||||||
|
# domains does not cause the results header field to be added. This added header
|
||||||
|
# is used by spamassassin to evaluate the mail for spamminess.
|
||||||
|
|
||||||
|
tools/editconf.py /etc/opendkim.conf -s \
|
||||||
|
"AlwaysAddARHeader=true"
|
||||||
|
|
||||||
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
||||||
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
||||||
@@ -69,6 +114,9 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
non_smtpd_milters=\$smtpd_milters \
|
non_smtpd_milters=\$smtpd_milters \
|
||||||
milter_default_action=accept
|
milter_default_action=accept
|
||||||
|
|
||||||
|
# We need to explicitly enable the opendmarc service, or it will not start
|
||||||
|
hide_output systemctl enable opendmarc
|
||||||
|
|
||||||
# Restart services.
|
# Restart services.
|
||||||
restart_service opendkim
|
restart_service opendkim
|
||||||
restart_service opendmarc
|
restart_service opendmarc
|
||||||
|
|||||||
96
setup/dns.sh
96
setup/dns.sh
@@ -10,22 +10,19 @@
|
|||||||
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 the packages.
|
|
||||||
#
|
|
||||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
|
||||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
|
||||||
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
|
||||||
|
|
||||||
apt_install nsd ldnsutils openssh-client
|
|
||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
|
# We configure nsd before installation as we only want it to bind to some addresses
|
||||||
|
# and it otherwise will have port / bind conflicts with bind9 used as the local resolver
|
||||||
mkdir -p /var/run/nsd
|
mkdir -p /var/run/nsd
|
||||||
|
mkdir -p /etc/nsd
|
||||||
|
mkdir -p /etc/nsd/zones
|
||||||
|
touch /etc/nsd/zones.conf
|
||||||
|
|
||||||
cat > /etc/nsd/nsd.conf << EOF;
|
cat > /etc/nsd/nsd.conf << EOF;
|
||||||
# No not edit. Overwritten by Mail-in-a-Box setup.
|
# Do not edit. Overwritten by Mail-in-a-Box setup.
|
||||||
server:
|
server:
|
||||||
hide-version: yes
|
hide-version: yes
|
||||||
|
logfile: "/var/log/nsd.log"
|
||||||
|
|
||||||
# identify the server (CH TXT ID.SERVER entry).
|
# identify the server (CH TXT ID.SERVER entry).
|
||||||
identity: ""
|
identity: ""
|
||||||
@@ -49,56 +46,79 @@ for ip in $PRIVATE_IP $PRIVATE_IPV6; do
|
|||||||
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
|
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "include: /etc/nsd/zones.conf" >> /etc/nsd/nsd.conf;
|
# Create a directory for additional configuration directives, including
|
||||||
|
# the zones.conf file written out by our management daemon.
|
||||||
|
echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf;
|
||||||
|
|
||||||
|
# Remove the old location of zones.conf that we generate. It will
|
||||||
|
# now be stored in /etc/nsd/nsd.conf.d.
|
||||||
|
rm -f /etc/nsd/zones.conf
|
||||||
|
|
||||||
|
# Add log rotation
|
||||||
|
cat > /etc/logrotate.d/nsd <<EOF;
|
||||||
|
/var/log/nsd.log {
|
||||||
|
weekly
|
||||||
|
missingok
|
||||||
|
rotate 12
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install the packages.
|
||||||
|
#
|
||||||
|
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||||
|
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||||
|
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
||||||
|
echo "Installing nsd (DNS server)..."
|
||||||
|
apt_install nsd ldnsutils openssh-client
|
||||||
|
|
||||||
# Create DNSSEC signing keys.
|
# Create DNSSEC signing keys.
|
||||||
|
|
||||||
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||||
|
|
||||||
# TLDs don't all support the same algorithms, so we'll generate keys using a few
|
# TLDs, registrars, and validating nameservers don't all support the same algorithms,
|
||||||
# different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used
|
# so we'll generate keys using a few different algorithms so that dns_update.py can
|
||||||
# algorithm that supported NSEC3, which is a security best practice. However TLDs
|
# choose which algorithm to use when generating the zonefiles. See #1953 for recent
|
||||||
# will probably be moving away from it to a a SHA256-based algorithm.
|
# discussion. File for previously used algorithms (i.e. RSASHA1-NSEC3-SHA1) may still
|
||||||
#
|
# be in the output directory, and we'll continue to support signing zones with them
|
||||||
# Supports `RSASHA1-NSEC3-SHA1` (didn't test with `RSASHA256`):
|
# so that trust isn't broken with deployed DS records, but we won't generate those
|
||||||
#
|
# keys on new systems.
|
||||||
# * .info
|
|
||||||
# * .me
|
|
||||||
#
|
|
||||||
# Requires `RSASHA256`
|
|
||||||
#
|
|
||||||
# * .email
|
|
||||||
# * .guide
|
|
||||||
#
|
|
||||||
# Supports `RSASHA256` (and defaulting to this)
|
|
||||||
#
|
|
||||||
# * .fund
|
|
||||||
|
|
||||||
FIRST=1 #NODOC
|
FIRST=1 #NODOC
|
||||||
for algo in RSASHA1-NSEC3-SHA1 RSASHA256; do
|
for algo in RSASHA256 ECDSAP256SHA256; do
|
||||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
if [ ! -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
|
||||||
|
|
||||||
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
|
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
|
||||||
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
|
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
|
||||||
# matter -- we'll use the same keys for all our domains.
|
# matter -- we'll use the same keys for all our domains.
|
||||||
#
|
#
|
||||||
# `ldns-keygen` outputs the new key's filename to stdout, which
|
# `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.
|
||||||
|
# (This previously used -b 2048 but it's unclear if this setting makes sense
|
||||||
|
# for non-RSA keys, so it's removed. The RSA-based keys are not recommended
|
||||||
|
# anymore anyway.)
|
||||||
|
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -k _domain_);
|
||||||
|
|
||||||
# Now create a Zone-Signing Key (ZSK) which is expected to be
|
# 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`.
|
||||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a $algo -b 1024 _domain_);
|
# (This previously used -b 1024 but it's unclear if this setting makes sense
|
||||||
|
# for non-RSA keys, so it's removed.)
|
||||||
|
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo _domain_);
|
||||||
|
|
||||||
# These generate two sets of files like:
|
# These generate two sets of files like:
|
||||||
#
|
#
|
||||||
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_`)
|
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_` so we don't use this file)
|
||||||
# * `K_domain_.+007+08882.key`: public key
|
# * `K_domain_.+007+08882.key`: public key
|
||||||
# * `K_domain_.+007+08882.private`: private key (secret!)
|
# * `K_domain_.+007+08882.private`: private key (secret!)
|
||||||
|
|
||||||
@@ -122,7 +142,7 @@ cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mail-in-a-Box
|
# Mail-in-a-Box
|
||||||
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
|
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
|
||||||
`pwd`/tools/dns_update
|
$(pwd)/tools/dns_update
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.daily/mailinabox-dnssec
|
chmod +x /etc/cron.daily/mailinabox-dnssec
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# If there aren't any mail users yet, create one.
|
# If there aren't any mail users yet, create one.
|
||||||
if [ -z "`tools/mail.py user`" ]; then
|
if [ -z "$(management/cli.py user)" ]; then
|
||||||
# The outut of "tools/mail.py user" is a list of mail users. If there
|
# The outut of "management/cli.py user" is a list of mail users. If there
|
||||||
# aren't any yet, it'll be empty.
|
# aren't any yet, it'll be empty.
|
||||||
|
|
||||||
# If we didn't ask for an email address at the start, do so now.
|
# If we didn't ask for an email address at the start, do so now.
|
||||||
if [ -z "$EMAIL_ADDR" ]; then
|
if [ -z "${EMAIL_ADDR:-}" ]; then
|
||||||
# In an interactive shell, ask the user for an email address.
|
# In an interactive shell, ask the user for an email address.
|
||||||
if [ -z "$NONINTERACTIVE" ]; then
|
if [ -z "${NONINTERACTIVE:-}" ]; then
|
||||||
input_box "Mail Account" \
|
input_box "Mail Account" \
|
||||||
"Let's create your first mail account.
|
"Let's create your first mail account.
|
||||||
\n\nWhat email address do you want?" \
|
\n\nWhat email address do you want?" \
|
||||||
me@`get_default_hostname` \
|
me@$(get_default_hostname) \
|
||||||
EMAIL_ADDR
|
EMAIL_ADDR
|
||||||
|
|
||||||
if [ -z "$EMAIL_ADDR" ]; then
|
if [ -z "$EMAIL_ADDR" ]; then
|
||||||
@@ -35,7 +35,7 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
else
|
else
|
||||||
# Use me@PRIMARY_HOSTNAME
|
# Use me@PRIMARY_HOSTNAME
|
||||||
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
|
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
|
||||||
EMAIL_PW=1234
|
EMAIL_PW=12345678
|
||||||
echo
|
echo
|
||||||
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
|
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
|
||||||
echo
|
echo
|
||||||
@@ -47,11 +47,11 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the user's mail account. This will ask for a password if none was given above.
|
# Create the user's mail account. This will ask for a password if none was given above.
|
||||||
tools/mail.py user add $EMAIL_ADDR $EMAIL_PW
|
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
|
||||||
|
|
||||||
# Make it an admin.
|
# Make it an admin.
|
||||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
hide_output management/cli.py user make-admin $EMAIL_ADDR
|
||||||
|
|
||||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
|
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
|
# Turn on "strict mode." See http://redsymbol.net/articles/unofficial-bash-strict-mode/.
|
||||||
|
# -e: exit if any command unexpectedly fails.
|
||||||
|
# -u: exit if we have a variable typo.
|
||||||
|
# -o pipefail: don't ignore errors in the non-last command in a pipeline
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PHP_VER=8.0
|
||||||
|
|
||||||
function hide_output {
|
function hide_output {
|
||||||
# This function hides the output of a command unless the command fails
|
# This function hides the output of a command unless the command fails
|
||||||
# and returns a non-zero exit code.
|
# and returns a non-zero exit code.
|
||||||
|
|
||||||
# Get a temporary file.
|
# Get a temporary file.
|
||||||
OUTPUT=$(tempfile)
|
OUTPUT=$(mktemp)
|
||||||
|
|
||||||
# Execute command, redirecting stderr/stdout to the temporary file.
|
# Execute command, redirecting stderr/stdout to the temporary file. Since we
|
||||||
$@ &> $OUTPUT
|
# check the return code ourselves, disable 'set -e' temporarily.
|
||||||
|
set +e
|
||||||
|
"$@" &> $OUTPUT
|
||||||
|
E=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
# If the command failed, show the output that was captured in the temporary file.
|
# If the command failed, show the output that was captured in the temporary file.
|
||||||
E=$?
|
|
||||||
if [ $E != 0 ]; then
|
if [ $E != 0 ]; then
|
||||||
# Something failed.
|
# Something failed.
|
||||||
echo
|
echo
|
||||||
echo FAILED: $@
|
echo FAILED: "$@"
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
cat $OUTPUT
|
cat $OUTPUT
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
@@ -39,33 +50,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
|
||||||
PACKAGES=$@
|
# installed and which needed installing, before just running an 'apt-get
|
||||||
TO_INSTALL=""
|
# install' for all of the packages. Calling `dpkg` on each package is slow,
|
||||||
ALREADY_INSTALLED=""
|
# and doesn't affect what we actually do, except in the messages, so let's
|
||||||
for pkg in $PACKAGES; do
|
# not do that anymore.
|
||||||
if dpkg -s $pkg 2>/dev/null | grep "^Status: install ok installed" > /dev/null; then
|
apt_get_quiet install "$@"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_default_hostname {
|
function get_default_hostname {
|
||||||
@@ -86,7 +76,7 @@ function get_publicip_from_web_service {
|
|||||||
#
|
#
|
||||||
# Pass '4' or '6' as an argument to this function to specify
|
# Pass '4' or '6' as an argument to this function to specify
|
||||||
# what type of address to get (IPv4, IPv6).
|
# what type of address to get (IPv4, IPv6).
|
||||||
curl -$1 --fail --silent --max-time 15 icanhazip.com 2>/dev/null
|
curl -$1 --fail --silent --max-time 15 icanhazip.com 2>/dev/null || /bin/true
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_default_privateip {
|
function get_default_privateip {
|
||||||
@@ -129,7 +119,7 @@ function get_default_privateip {
|
|||||||
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
||||||
|
|
||||||
# Get the route information.
|
# Get the route information.
|
||||||
route=$(ip -$1 -o route get $target | grep -v unreachable)
|
route=$(ip -$1 -o route get $target 2>/dev/null | grep -v unreachable)
|
||||||
|
|
||||||
# Parse the address out of the route information.
|
# Parse the address out of the route information.
|
||||||
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
||||||
@@ -142,13 +132,19 @@ function get_default_privateip {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo $address
|
echo $address
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ufw_allow {
|
function ufw_allow {
|
||||||
if [ -z "$DISABLE_FIREWALL" ]; then
|
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
# ufw has completely unhelpful output
|
# ufw has completely unhelpful output
|
||||||
ufw allow $1 > /dev/null;
|
ufw allow "$1" > /dev/null;
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function ufw_limit {
|
||||||
|
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
|
# ufw has completely unhelpful output
|
||||||
|
ufw limit "$1" > /dev/null;
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +161,13 @@ function input_box {
|
|||||||
# input_box "title" "prompt" "defaultvalue" VARIABLE
|
# input_box "title" "prompt" "defaultvalue" VARIABLE
|
||||||
# The user's input will be stored in the variable VARIABLE.
|
# The user's input will be stored in the variable VARIABLE.
|
||||||
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
|
# The exit code from dialog will be stored in VARIABLE_EXITCODE.
|
||||||
|
# Temporarily turn off 'set -e' because we need the dialog return code.
|
||||||
declare -n result=$4
|
declare -n result=$4
|
||||||
declare -n result_code=$4_EXITCODE
|
declare -n result_code=$4_EXITCODE
|
||||||
|
set +e
|
||||||
result=$(dialog --stdout --title "$1" --inputbox "$2" 0 0 "$3")
|
result=$(dialog --stdout --title "$1" --inputbox "$2" 0 0 "$3")
|
||||||
result_code=$?
|
result_code=$?
|
||||||
|
set -e
|
||||||
}
|
}
|
||||||
|
|
||||||
function input_menu {
|
function input_menu {
|
||||||
@@ -178,8 +177,10 @@ function input_menu {
|
|||||||
declare -n result=$4
|
declare -n result=$4
|
||||||
declare -n result_code=$4_EXITCODE
|
declare -n result_code=$4_EXITCODE
|
||||||
local IFS=^$'\n'
|
local IFS=^$'\n'
|
||||||
|
set +e
|
||||||
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
||||||
result_code=$?
|
result_code=$?
|
||||||
|
set -e
|
||||||
}
|
}
|
||||||
|
|
||||||
function wget_verify {
|
function wget_verify {
|
||||||
@@ -190,7 +191,7 @@ function wget_verify {
|
|||||||
DEST=$3
|
DEST=$3
|
||||||
CHECKSUM="$HASH $DEST"
|
CHECKSUM="$HASH $DEST"
|
||||||
rm -f $DEST
|
rm -f $DEST
|
||||||
wget -q -O $DEST $URL || exit 1
|
hide_output wget -O $DEST $URL
|
||||||
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
|
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
|
||||||
echo "------------------------------------------------------------"
|
echo "------------------------------------------------------------"
|
||||||
echo "Download of $URL did not match expected checksum."
|
echo "Download of $URL did not match expected checksum."
|
||||||
|
|||||||
@@ -18,8 +18,12 @@
|
|||||||
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
|
||||||
@@ -33,8 +37,17 @@ apt_install \
|
|||||||
# of active IMAP connections (at, say, 5 open connections per user that
|
# of active IMAP connections (at, say, 5 open connections per user that
|
||||||
# would be 20 users). Set it to 250 times the number of cores this
|
# would be 20 users). Set it to 250 times the number of cores this
|
||||||
# machine has, so on a two-core machine that's 500 processes/100 users).
|
# machine has, so on a two-core machine that's 500 processes/100 users).
|
||||||
|
# The `default_vsz_limit` is the maximum amount of virtual memory that
|
||||||
|
# can be allocated. It should be set *reasonably high* to avoid allocation
|
||||||
|
# issues with larger mailboxes. We're setting it to 1/3 of the total
|
||||||
|
# available memory (physical mem + swap) to be sure.
|
||||||
|
# See here for discussion:
|
||||||
|
# - https://www.dovecot.org/list/dovecot/2012-August/137569.html
|
||||||
|
# - https://www.dovecot.org/list/dovecot/2011-December/132455.html
|
||||||
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
|
tools/editconf.py /etc/dovecot/conf.d/10-master.conf \
|
||||||
default_process_limit=$(echo "`nproc` * 250" | bc)
|
default_process_limit=$(echo "$(nproc) * 250" | bc) \
|
||||||
|
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \
|
||||||
|
log_path=/var/log/mail.log
|
||||||
|
|
||||||
# The inotify `max_user_instances` default is 128, which constrains
|
# The inotify `max_user_instances` default is 128, which constrains
|
||||||
# the total number of watched (IMAP IDLE push) folders by open connections.
|
# the total number of watched (IMAP IDLE push) folders by open connections.
|
||||||
@@ -52,6 +65,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.
|
||||||
@@ -62,13 +78,17 @@ tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
|||||||
"auth_mechanisms=plain login"
|
"auth_mechanisms=plain login"
|
||||||
|
|
||||||
# Enable SSL, specify the location of the SSL certificate and private key files.
|
# Enable SSL, specify the location of the SSL certificate and private key files.
|
||||||
# Disable obsolete SSL protocols and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# Use Mozilla's "Intermediate" recommendations at https://ssl-config.mozilla.org/#server=dovecot&server-version=2.2.33&config=intermediate&openssl-version=1.1.1,
|
||||||
|
# except that the current version of Dovecot does not have a TLSv1.3 setting, so we only use TLSv1.2.
|
||||||
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||||
ssl=required \
|
ssl=required \
|
||||||
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
||||||
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
||||||
"ssl_protocols=!SSLv3 !SSLv2" \
|
"ssl_min_protocol=TLSv1.2" \
|
||||||
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
|
"ssl_cipher_list=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384" \
|
||||||
|
"ssl_prefer_server_ciphers=no" \
|
||||||
|
"ssl_dh_parameters_length=2048" \
|
||||||
|
"ssl_dh=<$STORAGE_ROOT/ssl/dh2048.pem"
|
||||||
|
|
||||||
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
||||||
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
||||||
@@ -81,12 +101,13 @@ 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 \
|
||||||
@@ -115,8 +136,16 @@ service lmtp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Enable imap-login on localhost to allow the user_external plugin
|
||||||
|
# for Nextcloud to do imap authentication. (See #1577)
|
||||||
|
service imap-login {
|
||||||
|
inet_listener imap {
|
||||||
|
address = 127.0.0.1
|
||||||
|
port = 143
|
||||||
|
}
|
||||||
|
}
|
||||||
protocol imap {
|
protocol imap {
|
||||||
mail_max_userip_connections = 20
|
mail_max_userip_connections = 40
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -136,6 +165,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,8 +180,11 @@ 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
|
||||||
|
sieve_redirect_envelope_from = recipient
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -168,11 +206,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
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
# destinations according to aliases, and passses email on to
|
# destinations according to aliases, and passses email on to
|
||||||
# another service for local mail delivery.
|
# another service for local mail delivery.
|
||||||
#
|
#
|
||||||
# The first hop in local mail delivery is to Spamassassin via
|
# The first hop in local mail delivery is to spampd via
|
||||||
# LMTP. Spamassassin then passes mail over to Dovecot for
|
# LMTP. spampd then passes mail over to Dovecot for
|
||||||
# storage in the user's mailbox.
|
# storage in the user's mailbox.
|
||||||
#
|
#
|
||||||
# Postfix also listens on port 587 (SMTP+STARTLS) for
|
# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for
|
||||||
# connections from users who can authenticate and then sends
|
# connections from users who can authenticate and then sends
|
||||||
# their email out to the outside world. Postfix queries Dovecot
|
# their email out to the outside world. Postfix queries Dovecot
|
||||||
# to authenticate users.
|
# to authenticate users.
|
||||||
@@ -41,71 +41,115 @@ 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.
|
||||||
|
echo "Installing Postfix (SMTP server)..."
|
||||||
apt_install postfix postfix-pcre postgrey ca-certificates
|
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates
|
||||||
|
|
||||||
# ### Basic Settings
|
# ### Basic Settings
|
||||||
|
|
||||||
# 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' ports 465 and 587 and tweak their settings.
|
||||||
#
|
#
|
||||||
|
# * Enable authentication. It's disabled globally so that it is disabled on port 25,
|
||||||
|
# so we need to explicitly enable it here.
|
||||||
# * Do not add the OpenDMAC Authentication-Results header. That should only be added
|
# * Do not add the OpenDMAC Authentication-Results header. That should only be added
|
||||||
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
|
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
|
||||||
# OpenDKIM milter only. See dkim.sh.
|
# OpenDKIM milter only. See dkim.sh.
|
||||||
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
||||||
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
||||||
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
# Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below (but this is ignored with smtpd_tls_wrappermode=yes.)
|
||||||
# By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
|
|
||||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||||
# * Add a new cleanup service specific to the submission service ('authclean')
|
# * Add a new cleanup service specific to the submission service ('authclean')
|
||||||
# that filters out privacy-sensitive headers on mail being sent out by
|
# that filters out privacy-sensitive headers on mail being sent out by
|
||||||
# authenticated users.
|
# authenticated users. By default Postfix also applies this to attached
|
||||||
|
# emails but we turn this off by setting nested_header_checks empty.
|
||||||
tools/editconf.py /etc/postfix/master.cf -s -w \
|
tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||||
|
"smtps=inet n - - - - smtpd
|
||||||
|
-o smtpd_tls_wrappermode=yes
|
||||||
|
-o smtpd_sasl_auth_enable=yes
|
||||||
|
-o syslog_name=postfix/submission
|
||||||
|
-o smtpd_milters=inet:127.0.0.1:8891
|
||||||
|
-o cleanup_service_name=authclean" \
|
||||||
"submission=inet n - - - - smtpd
|
"submission=inet n - - - - smtpd
|
||||||
|
-o smtpd_sasl_auth_enable=yes
|
||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
-o smtpd_milters=inet:127.0.0.1:8891
|
-o smtpd_milters=inet:127.0.0.1:8891
|
||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_tls_ciphers=high -o smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 -o smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3
|
|
||||||
-o cleanup_service_name=authclean" \
|
-o cleanup_service_name=authclean" \
|
||||||
"authclean=unix n - - - 0 cleanup
|
"authclean=unix n - - - 0 cleanup
|
||||||
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters"
|
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters
|
||||||
|
-o nested_header_checks="
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Enable TLS on these and all other connections (i.e. ports 25 *and* 587) and
|
# Modify the `outgoing_mail_header_filters` file to use the local machine name and ip
|
||||||
# require TLS before a user is allowed to authenticate. This also makes
|
# on the first received header line. This may help reduce the spam score of email by
|
||||||
# opportunistic TLS available on *incoming* mail.
|
# removing the 127.0.0.1 reference.
|
||||||
# Set stronger DH parameters, which via openssl tend to default to 1024 bits
|
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
|
||||||
# (see ssl.sh).
|
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
||||||
|
|
||||||
|
# Enable TLS on incoming connections. It is not required on port 25, allowing for opportunistic
|
||||||
|
# encryption. On ports 465 and 587 it is mandatory (see above). Shared and non-shared settings are
|
||||||
|
# given here. Shared settings include:
|
||||||
|
# * Require TLS before a user is allowed to authenticate.
|
||||||
|
# * Set the path to the server TLS certificate and 2048-bit DH parameters for old DH ciphers.
|
||||||
|
# For port 25 only:
|
||||||
|
# * Disable extremely old versions of TLS and extremely unsafe ciphers, but some mail servers out in
|
||||||
|
# the world are very far behind and if we disable too much, they may not be able to use TLS and
|
||||||
|
# won't fall back to cleartext. So we don't disable too much. smtpd_tls_exclude_ciphers applies to
|
||||||
|
# both port 25 and port 587, but because we override the cipher list for both, it probably isn't used.
|
||||||
|
# Use Mozilla's "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_tls_security_level=may\
|
smtpd_tls_security_level=may\
|
||||||
smtpd_tls_auth_only=yes \
|
smtpd_tls_auth_only=yes \
|
||||||
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
||||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||||
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
||||||
|
smtpd_tls_protocols="!SSLv2,!SSLv3" \
|
||||||
smtpd_tls_ciphers=medium \
|
smtpd_tls_ciphers=medium \
|
||||||
smtpd_tls_exclude_ciphers=aNULL \
|
tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \
|
||||||
|
smtpd_tls_exclude_ciphers=aNULL,RC4 \
|
||||||
|
tls_preempt_cipherlist=no \
|
||||||
smtpd_tls_received_header=yes
|
smtpd_tls_received_header=yes
|
||||||
|
|
||||||
|
# For ports 465/587 (via the 'mandatory' settings):
|
||||||
|
# * Use Mozilla's "Intermediate" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1
|
||||||
|
# using and overriding the "high" cipher list so we don't conflict with the more permissive settings for port 25.
|
||||||
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
|
smtpd_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
|
||||||
|
smtpd_tls_mandatory_ciphers=high \
|
||||||
|
tls_high_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 \
|
||||||
|
smtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4
|
||||||
|
|
||||||
# Prevent non-authenticated users from sending mail that requires being
|
# Prevent non-authenticated users from sending mail that requires being
|
||||||
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
||||||
# mail, require one of:
|
# mail, require one of:
|
||||||
#
|
#
|
||||||
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587).
|
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 465/587).
|
||||||
# * `permit_mynetworks`: Mail that originates locally.
|
# * `permit_mynetworks`: Mail that originates locally.
|
||||||
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
|
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
@@ -120,39 +164,53 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
||||||
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
||||||
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
# protects against passive easvesdropping (but not man-in-the-middle attacks).
|
||||||
|
# Since we'd rather have poor encryption than none at all, we use Mozilla's
|
||||||
|
# "Old" recommendations at https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=old&openssl-version=1.1.1
|
||||||
|
# for opportunistic encryption but "Intermediate" recommendations when DANE
|
||||||
|
# is used (see next and above). The cipher lists are set above.
|
||||||
|
|
||||||
# DANE takes this a step further:
|
# DANE takes this a step further:
|
||||||
#
|
|
||||||
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
# Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found,
|
||||||
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
# then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records
|
||||||
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
# or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC
|
||||||
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
|
# itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also
|
||||||
# relies on our local bind9 server being present and `smtp_dns_support_level=dnssec`.
|
# relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`.
|
||||||
#
|
#
|
||||||
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
|
# The `smtp_tls_CAfile` is superflous, but it eliminates warnings in the logs about untrusted certs,
|
||||||
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
|
# which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt,
|
||||||
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
|
# even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll
|
||||||
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
# now see notices about trusted certs. The CA file is provided by the package `ca-certificates`.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
|
smtp_tls_protocols=\!SSLv2,\!SSLv3 \
|
||||||
|
smtp_tls_ciphers=medium \
|
||||||
|
smtp_tls_exclude_ciphers=aNULL,RC4 \
|
||||||
smtp_tls_security_level=dane \
|
smtp_tls_security_level=dane \
|
||||||
smtp_dns_support_level=dnssec \
|
smtp_dns_support_level=dnssec \
|
||||||
|
smtp_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
|
||||||
|
smtp_tls_mandatory_ciphers=high \
|
||||||
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
|
smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt \
|
||||||
smtp_tls_loglevel=2
|
smtp_tls_loglevel=2
|
||||||
|
|
||||||
# ### Incoming Mail
|
# ### Incoming Mail
|
||||||
|
|
||||||
# Pass any incoming mail over to a local delivery agent. Spamassassin
|
# Pass mail to spampd, which acts as the local delivery agent (LDA),
|
||||||
# will act as the LDA agent at first. It is listening on port 10025
|
# which then passes the mail over to the Dovecot LMTP server after.
|
||||||
# with LMTP. Spamassassin will pass the mail over to Dovecot after.
|
# spampd runs on port 10025 by default.
|
||||||
#
|
#
|
||||||
# In a basic setup we would pass mail directly to Dovecot by setting
|
# In a basic setup we would pass mail directly to Dovecot by setting
|
||||||
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
||||||
#
|
tools/editconf.py /etc/postfix/main.cf "virtual_transport=lmtp:[127.0.0.1]:10025"
|
||||||
tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
# Clear the lmtp_destination_recipient_limit setting which in previous
|
||||||
|
# versions of Mail-in-a-Box was set to 1 because of a spampd bug.
|
||||||
|
# See https://github.com/mail-in-a-box/mailinabox/issues/1523.
|
||||||
|
tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
|
||||||
|
|
||||||
|
|
||||||
# Who can send mail to us? Some basic filters.
|
# Who can send mail to us? Some basic filters.
|
||||||
#
|
#
|
||||||
# * `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 +224,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
|
||||||
@@ -175,9 +233,57 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# As a matter of fact RFC is not strict about retry timer so postfix and
|
# As a matter of fact RFC is not strict about retry timer so postfix and
|
||||||
# other MTA have their own intervals. To fix the problem of receiving
|
# other MTA have their own intervals. To fix the problem of receiving
|
||||||
# e-mails really latter, delay of greylisting has been set to
|
# e-mails really latter, delay of greylisting has been set to
|
||||||
# 180 seconds (default is 300 seconds).
|
# 180 seconds (default is 300 seconds). We will move the postgrey database
|
||||||
|
# under $STORAGE_ROOT. This prevents a "warming up" that would have occured
|
||||||
|
# previously with a migrated or reinstalled OS. We will specify this new path
|
||||||
|
# with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces,
|
||||||
|
# including dbdir. This is due to the way the init script sources the
|
||||||
|
# /etc/default/postgrey file. --dbdir=... either needs to be a path without spaces
|
||||||
|
# (luckily $STORAGE_ROOT does not currently work with spaces), or it needs to be a
|
||||||
|
# symlink without spaces that can point to a folder with spaces). We'll just assume
|
||||||
|
# $STORAGE_ROOT won't have spaces to simplify things.
|
||||||
tools/editconf.py /etc/default/postgrey \
|
tools/editconf.py /etc/default/postgrey \
|
||||||
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
|
POSTGREY_OPTS=\""--inet=127.0.0.1:10023 --delay=180 --dbdir=$STORAGE_ROOT/mail/postgrey/db"\"
|
||||||
|
|
||||||
|
|
||||||
|
# If the $STORAGE_ROOT/mail/postgrey is empty, copy the postgrey database over from the old location
|
||||||
|
if [ ! -d $STORAGE_ROOT/mail/postgrey/db ]; then
|
||||||
|
# Stop the service
|
||||||
|
service postgrey stop
|
||||||
|
# Ensure the new paths for postgrey db exists
|
||||||
|
mkdir -p $STORAGE_ROOT/mail/postgrey/db
|
||||||
|
# Move over database files
|
||||||
|
mv /var/lib/postgrey/* $STORAGE_ROOT/mail/postgrey/db/ || true
|
||||||
|
fi
|
||||||
|
# Ensure permissions are set
|
||||||
|
chown -R postgrey:postgrey $STORAGE_ROOT/mail/postgrey/
|
||||||
|
chmod 700 $STORAGE_ROOT/mail/postgrey/{,db}
|
||||||
|
|
||||||
|
# We are going to setup a newer whitelist for postgrey, the version included in the distribution is old
|
||||||
|
cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Mail-in-a-Box
|
||||||
|
|
||||||
|
# check we have a postgrey_whitelist_clients file and that it is not older than 28 days
|
||||||
|
if [ ! -f /etc/postgrey/whitelist_clients ] || find /etc/postgrey/whitelist_clients -mtime +28 | grep -q '.' ; then
|
||||||
|
# ok we need to update the file, so lets try to fetch it
|
||||||
|
if curl https://postgrey.schweikert.ch/pub/postgrey_whitelist_clients --output /tmp/postgrey_whitelist_clients -sS --fail > /dev/null 2>&1 ; then
|
||||||
|
# if fetching hasn't failed yet then check it is a plain text file
|
||||||
|
# curl manual states that --fail sometimes still produces output
|
||||||
|
# this final check will at least check the output is not html
|
||||||
|
# before moving it into place
|
||||||
|
if [ "\$(file -b --mime-type /tmp/postgrey_whitelist_clients)" == "text/plain" ]; then
|
||||||
|
mv /tmp/postgrey_whitelist_clients /etc/postgrey/whitelist_clients
|
||||||
|
service postgrey restart
|
||||||
|
else
|
||||||
|
rm /tmp/postgrey_whitelist_clients
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/cron.daily/mailinabox-postgrey-whitelist
|
||||||
|
/etc/cron.daily/mailinabox-postgrey-whitelist
|
||||||
|
|
||||||
# Increase the message size limit from 10MB to 128MB.
|
# Increase the message size limit from 10MB to 128MB.
|
||||||
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
||||||
@@ -187,6 +293,7 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||||||
# Allow the two SMTP ports in the firewall.
|
# Allow the two SMTP ports in the firewall.
|
||||||
|
|
||||||
ufw_allow smtp
|
ufw_allow smtp
|
||||||
|
ufw_allow smtps
|
||||||
ufw_allow submission
|
ufw_allow submission
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# This script configures user authentication for Dovecot
|
# This script configures user authentication for Dovecot
|
||||||
# and Postfix (which relies on Dovecot) and destination
|
# and Postfix (which relies on Dovecot) and destination
|
||||||
# validation by quering an Sqlite3 database of mail users.
|
# validation by quering an Sqlite3 database of mail users.
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
@@ -21,7 +21,9 @@ 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;
|
||||||
|
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
|
||||||
|
echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### User Authentication
|
# ### User Authentication
|
||||||
@@ -38,17 +40,19 @@ passdb {
|
|||||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||||
}
|
}
|
||||||
userdb {
|
userdb {
|
||||||
driver = static
|
driver = sql
|
||||||
args = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
|
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Configure the SQL to query for a user's password.
|
# Configure the SQL to query for a user's metadata and password.
|
||||||
cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
|
cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
|
||||||
driver = sqlite
|
driver = sqlite
|
||||||
connect = $db_path
|
connect = $db_path
|
||||||
default_pass_scheme = SHA512-CRYPT
|
default_pass_scheme = SHA512-CRYPT
|
||||||
password_query = SELECT email as user, password FROM users WHERE email='%u';
|
password_query = SELECT email as user, password FROM users WHERE email='%u';
|
||||||
|
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home FROM users WHERE email='%u';
|
||||||
|
iterate_query = SELECT email AS user FROM users;
|
||||||
EOF
|
EOF
|
||||||
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
||||||
|
|
||||||
@@ -63,45 +67,90 @@ service auth {
|
|||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# And have Postfix use that service.
|
# And have Postfix use that service. We *disable* it here
|
||||||
|
# so that authentication is not permitted on port 25 (which
|
||||||
|
# does not run DKIM on relayed mail, so outbound mail isn't
|
||||||
|
# correct, see #830), but we enable it specifically for the
|
||||||
|
# submission port.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_sasl_type=dovecot \
|
smtpd_sasl_type=dovecot \
|
||||||
smtpd_sasl_path=private/auth \
|
smtpd_sasl_path=private/auth \
|
||||||
smtpd_sasl_auth_enable=yes
|
smtpd_sasl_auth_enable=no
|
||||||
|
|
||||||
|
# ### 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,
|
||||||
# and to perform any email alias rewrites in Postfix.
|
# and to perform any email alias rewrites in Postfix. Additionally, we disable
|
||||||
|
# SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does
|
||||||
|
# not support it, and if a message is received with the SMTPUTF8 flag it will
|
||||||
|
# bounce.
|
||||||
tools/editconf.py /etc/postfix/main.cf \
|
tools/editconf.py /etc/postfix/main.cf \
|
||||||
|
smtputf8_enable=no \
|
||||||
virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \
|
virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \
|
||||||
virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \
|
virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.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' UNION SELECT 1 FROM auto_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' UNION SELECT destination, 2 as priority FROM auto_aliases WHERE source='%s' AND destination<>'') ORDER BY priority LIMIT 1;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Restart Services
|
# Restart Services
|
||||||
|
|||||||
@@ -1,10 +1,49 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
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
|
# DEPENDENCIES
|
||||||
|
|
||||||
|
# duplicity is used to make backups of user data.
|
||||||
|
#
|
||||||
|
# virtualenv is used to isolate the Python 3 packages we
|
||||||
|
# install via pip from the system-installed packages.
|
||||||
|
#
|
||||||
|
# certbot installs EFF's certbot which we use to
|
||||||
|
# provision free TLS certificates.
|
||||||
|
apt_install duplicity python3-pip virtualenv certbot rsync
|
||||||
|
|
||||||
|
# b2sdk is used for backblaze backups.
|
||||||
|
# boto3 is used for amazon aws backups.
|
||||||
|
# Both are installed outside the pipenv, so they can be used by duplicity
|
||||||
|
hide_output pip3 install --upgrade b2sdk boto3
|
||||||
|
|
||||||
|
# Create a virtualenv for the installation of Python 3 packages
|
||||||
|
# used by the management daemon.
|
||||||
|
inst_dir=/usr/local/lib/mailinabox
|
||||||
|
mkdir -p $inst_dir
|
||||||
|
venv=$inst_dir/env
|
||||||
|
if [ ! -d $venv ]; then
|
||||||
|
hide_output virtualenv -ppython3 $venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
||||||
|
hide_output $venv/bin/pip install --upgrade pip
|
||||||
|
|
||||||
|
# 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 $venv/bin/pip install --upgrade \
|
||||||
|
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
||||||
|
flask dnspython python-dateutil expiringdict gunicorn \
|
||||||
|
qrcode[pil] pyotp \
|
||||||
|
"idna>=2.0.0" "cryptography==37.0.2" psutil postfix-mta-sts-resolver \
|
||||||
|
b2sdk boto3
|
||||||
|
|
||||||
|
# CONFIGURATION
|
||||||
|
|
||||||
# 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
|
||||||
@@ -12,38 +51,67 @@ if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
|
|||||||
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
|
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Link the management server daemon into a well known location.
|
|
||||||
rm -f /usr/local/bin/mailinabox-daemon
|
# Download jQuery and Bootstrap local files
|
||||||
ln -s `pwd`/management/daemon.py /usr/local/bin/mailinabox-daemon
|
|
||||||
|
# Make sure we have the directory to save to.
|
||||||
|
assets_dir=$inst_dir/vendor/assets
|
||||||
|
rm -rf $assets_dir
|
||||||
|
mkdir -p $assets_dir
|
||||||
|
|
||||||
|
# jQuery CDN URL
|
||||||
|
jquery_version=2.1.4
|
||||||
|
jquery_url=https://code.jquery.com
|
||||||
|
|
||||||
|
# Get jQuery
|
||||||
|
wget_verify $jquery_url/jquery-$jquery_version.min.js 43dc554608df885a59ddeece1598c6ace434d747 $assets_dir/jquery.min.js
|
||||||
|
|
||||||
|
# Bootstrap CDN URL
|
||||||
|
bootstrap_version=3.3.7
|
||||||
|
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
|
||||||
|
|
||||||
|
# Get Bootstrap
|
||||||
|
wget_verify $bootstrap_url e6b1000b94e835ffd37f4c6dcbdad43f4b48a02a /tmp/bootstrap.zip
|
||||||
|
unzip -q /tmp/bootstrap.zip -d $assets_dir
|
||||||
|
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
|
||||||
|
rm -f /tmp/bootstrap.zip
|
||||||
|
|
||||||
# Create an init script to start the management daemon and keep it
|
# Create an init script to start the management daemon and keep it
|
||||||
# running after a reboot.
|
# running after a reboot.
|
||||||
rm -f /etc/init.d/mailinabox
|
# Set a long timeout since some commands take a while to run, matching
|
||||||
ln -s $(pwd)/conf/management-initscript /etc/init.d/mailinabox
|
# the timeout we set for PHP (fastcgi_read_timeout in the nginx confs).
|
||||||
hide_output update-rc.d mailinabox defaults
|
# Note: Authentication currently breaks with more than 1 gunicorn worker.
|
||||||
|
cat > $inst_dir/start <<EOF;
|
||||||
# Perform a daily backup.
|
|
||||||
cat > /etc/cron.daily/mailinabox-backup << EOF;
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
|
||||||
# Perform a backup.
|
export LANGUAGE=en_US.UTF-8
|
||||||
$(pwd)/management/backup.py
|
export LC_ALL=en_US.UTF-8
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
|
mkdir -p /var/lib/mailinabox
|
||||||
|
tr -cd '[:xdigit:]' < /dev/urandom | head -c 32 > /var/lib/mailinabox/api.key
|
||||||
|
chmod 640 /var/lib/mailinabox/api.key
|
||||||
|
|
||||||
|
source $venv/bin/activate
|
||||||
|
export PYTHONPATH=$(pwd)/management
|
||||||
|
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.daily/mailinabox-backup
|
chmod +x $inst_dir/start
|
||||||
|
cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first
|
||||||
|
hide_output systemctl link -f /lib/systemd/system/mailinabox.service
|
||||||
|
hide_output systemctl daemon-reload
|
||||||
|
hide_output systemctl enable mailinabox.service
|
||||||
|
|
||||||
# Perform daily status checks. Compare each day to the previous
|
# Perform nightly tasks at 3am in system time: take a backup, run
|
||||||
# for changes and mail the changes to the administrator.
|
# status checks and email the administrator any changes.
|
||||||
cat > /etc/cron.daily/mailinabox-statuschecks << EOF;
|
|
||||||
#!/bin/bash
|
minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
|
||||||
|
cat > /etc/cron.d/mailinabox-nightly << EOF;
|
||||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||||
# Run status checks.
|
# Run nightly tasks: backup, status checks.
|
||||||
$(pwd)/management/status_checks.py --show-changes --smtp
|
$minute 3 * * * root (cd $(pwd) && management/daily_tasks.sh)
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.daily/mailinabox-statuschecks
|
|
||||||
|
|
||||||
|
# Start the management server.
|
||||||
# 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,103 @@ 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 migration_11(env):
|
||||||
|
# Archive the old Let's Encrypt account directory managed by free_tls_certificates
|
||||||
|
# because we'll use that path now for the directory managed by certbot.
|
||||||
|
try:
|
||||||
|
old_path = os.path.join(env["STORAGE_ROOT"], 'ssl', 'lets_encrypt')
|
||||||
|
new_path = os.path.join(env["STORAGE_ROOT"], 'ssl', 'lets_encrypt-old')
|
||||||
|
shutil.move(old_path, new_path)
|
||||||
|
except:
|
||||||
|
# meh
|
||||||
|
pass
|
||||||
|
|
||||||
|
def migration_12(env):
|
||||||
|
# Upgrading to Carddav Roundcube plugin to version 3+, it requires the carddav_*
|
||||||
|
# tables to be dropped.
|
||||||
|
# Checking that the roundcube database already exists.
|
||||||
|
if os.path.exists(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite")):
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite"))
|
||||||
|
c = conn.cursor()
|
||||||
|
# Get a list of all the tables that begin with 'carddav_'
|
||||||
|
c.execute("SELECT name FROM sqlite_master WHERE type = ? AND name LIKE ?", ('table', 'carddav_%'))
|
||||||
|
carddav_tables = c.fetchall()
|
||||||
|
# If there were tables that begin with 'carddav_', drop them
|
||||||
|
if carddav_tables:
|
||||||
|
for table in carddav_tables:
|
||||||
|
try:
|
||||||
|
table = table[0]
|
||||||
|
c = conn.cursor()
|
||||||
|
dropcmd = "DROP TABLE %s" % table
|
||||||
|
c.execute(dropcmd)
|
||||||
|
except:
|
||||||
|
print("Failed to drop table", table, e)
|
||||||
|
# Save.
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Delete all sessions, requring users to login again to recreate carddav_*
|
||||||
|
# databases
|
||||||
|
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/roundcube/roundcube.sqlite"))
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("delete from session;")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def migration_13(env):
|
||||||
|
# Add the "mfa" table for configuring MFA for login to the control panel.
|
||||||
|
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||||
|
shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
|
||||||
|
|
||||||
|
def migration_14(env):
|
||||||
|
# Add the "auto_aliases" table.
|
||||||
|
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||||
|
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
|
||||||
|
|
||||||
|
###########################################################
|
||||||
|
|
||||||
def get_current_migration():
|
def get_current_migration():
|
||||||
ver = 0
|
ver = 0
|
||||||
|
|||||||
84
setup/munin.sh
Executable file
84
setup/munin.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/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 libcgi-fast-perl
|
||||||
|
# libcgi-fast-perl is needed by /usr/lib/munin/cgi/munin-cgi-graph
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# path dynazoom uses for requests
|
||||||
|
cgiurl_graph /admin/munin/cgi-graph
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi
|
||||||
|
chown munin. /var/log/munin/munin-cgi-html.log
|
||||||
|
chown munin. /var/log/munin/munin-cgi-graph.log
|
||||||
|
|
||||||
|
# ensure munin-node knows the name of this machine
|
||||||
|
# and reduce logging level to warning
|
||||||
|
tools/editconf.py /etc/munin/munin-node.conf -s \
|
||||||
|
host_name=$PRIMARY_HOSTNAME \
|
||||||
|
log_level=1
|
||||||
|
|
||||||
|
# Update the activated plugins through munin's autoconfiguration.
|
||||||
|
munin-node-configure --shell --remove-also 2>/dev/null | sh || /bin/true
|
||||||
|
|
||||||
|
# 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 ! grep -qFx up /sys/class/net/$IF/operstate 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/
|
||||||
|
|
||||||
|
# Create a systemd service for munin.
|
||||||
|
ln -sf $(pwd)/management/munin_start.sh /usr/local/lib/mailinabox/munin_start.sh
|
||||||
|
chmod 0744 /usr/local/lib/mailinabox/munin_start.sh
|
||||||
|
cp --remove-destination conf/munin.service /lib/systemd/system/munin.service # target was previously a symlink so remove first
|
||||||
|
hide_output systemctl link -f /lib/systemd/system/munin.service
|
||||||
|
hide_output systemctl daemon-reload
|
||||||
|
hide_output systemctl unmask munin.service
|
||||||
|
hide_output systemctl enable munin.service
|
||||||
|
|
||||||
|
# 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.)
|
||||||
|
# We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously
|
||||||
|
# generating an error.
|
||||||
|
if [ ! -f /var/run/munin/munin-update.lock ]; then
|
||||||
|
sudo -H -u munin munin-cron
|
||||||
|
fi
|
||||||
380
setup/nextcloud.sh
Executable file
380
setup/nextcloud.sh
Executable file
@@ -0,0 +1,380 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Nextcloud
|
||||||
|
##########################
|
||||||
|
|
||||||
|
source setup/functions.sh # load our functions
|
||||||
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
|
# ### Installing Nextcloud
|
||||||
|
|
||||||
|
echo "Installing Nextcloud (contacts/calendar)..."
|
||||||
|
|
||||||
|
# Nextcloud core and app (plugin) versions to install.
|
||||||
|
# With each version we store a hash to ensure we install what we expect.
|
||||||
|
|
||||||
|
# Nextcloud core
|
||||||
|
# --------------
|
||||||
|
# * See https://nextcloud.com/changelog for the latest version.
|
||||||
|
# * Check https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html
|
||||||
|
# for whether it supports the version of PHP available on this machine.
|
||||||
|
# * Since Nextcloud only supports upgrades from consecutive major versions,
|
||||||
|
# we automatically install intermediate versions as needed.
|
||||||
|
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
|
||||||
|
# copying it from the error message when it doesn't match what is below.
|
||||||
|
nextcloud_ver=23.0.10
|
||||||
|
nextcloud_hash=8831c7862e39460fbb789bacac8729fab0ba02dd
|
||||||
|
|
||||||
|
# Nextcloud apps
|
||||||
|
# --------------
|
||||||
|
# * Find the most recent tag that is compatible with the Nextcloud version above by
|
||||||
|
# consulting the <dependencies>...<nextcloud> node at:
|
||||||
|
# https://github.com/nextcloud-releases/contacts/blob/main/appinfo/info.xml
|
||||||
|
# https://github.com/nextcloud-releases/calendar/blob/main/appinfo/info.xml
|
||||||
|
# https://github.com/nextcloud/user_external/blob/master/appinfo/info.xml
|
||||||
|
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
|
||||||
|
# copying it from the error message when it doesn't match what is below.
|
||||||
|
contacts_ver=4.2.2
|
||||||
|
contacts_hash=ca13d608ed8955aa374cb4f31b6026b57ef88887
|
||||||
|
calendar_ver=3.5.1
|
||||||
|
calendar_hash=c8136a3deb872a3ef73ce1155b58f3ab27ec7110
|
||||||
|
user_external_ver=3.0.0
|
||||||
|
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
|
||||||
|
|
||||||
|
# Clear prior packages and install dependencies from apt.
|
||||||
|
|
||||||
|
apt-get purge -qq -y owncloud* # we used to use the package manager
|
||||||
|
|
||||||
|
apt_install curl php${PHP_VER} php${PHP_VER}-fpm \
|
||||||
|
php${PHP_VER}-cli php${PHP_VER}-sqlite3 php${PHP_VER}-gd php${PHP_VER}-imap php${PHP_VER}-curl \
|
||||||
|
php${PHP_VER}-dev php${PHP_VER}-gd php${PHP_VER}-xml php${PHP_VER}-mbstring php${PHP_VER}-zip php${PHP_VER}-apcu \
|
||||||
|
php${PHP_VER}-intl php${PHP_VER}-imagick php${PHP_VER}-gmp php${PHP_VER}-bcmath
|
||||||
|
|
||||||
|
# Enable APC before Nextcloud tools are run.
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/mods-available/apcu.ini -c ';' \
|
||||||
|
apc.enabled=1 \
|
||||||
|
apc.enable_cli=1
|
||||||
|
|
||||||
|
InstallNextcloud() {
|
||||||
|
|
||||||
|
version=$1
|
||||||
|
hash=$2
|
||||||
|
version_contacts=$3
|
||||||
|
hash_contacts=$4
|
||||||
|
version_calendar=$5
|
||||||
|
hash_calendar=$6
|
||||||
|
version_user_external=${7:-}
|
||||||
|
hash_user_external=${8:-}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Upgrading to Nextcloud version $version"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Download and verify
|
||||||
|
wget_verify https://download.nextcloud.com/server/releases/nextcloud-$version.zip $hash /tmp/nextcloud.zip
|
||||||
|
|
||||||
|
# Remove the current owncloud/Nextcloud
|
||||||
|
rm -rf /usr/local/lib/owncloud
|
||||||
|
|
||||||
|
# Extract ownCloud/Nextcloud
|
||||||
|
unzip -q /tmp/nextcloud.zip -d /usr/local/lib
|
||||||
|
mv /usr/local/lib/nextcloud /usr/local/lib/owncloud
|
||||||
|
rm -f /tmp/nextcloud.zip
|
||||||
|
|
||||||
|
# The two apps we actually want are not in Nextcloud core. Download the releases from
|
||||||
|
# their github repositories.
|
||||||
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
|
|
||||||
|
wget_verify https://github.com/nextcloud-releases/contacts/archive/refs/tags/v$version_contacts.tar.gz $hash_contacts /tmp/contacts.tgz
|
||||||
|
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
|
rm /tmp/contacts.tgz
|
||||||
|
|
||||||
|
wget_verify https://github.com/nextcloud-releases/calendar/archive/refs/tags/v$version_calendar.tar.gz $hash_calendar /tmp/calendar.tgz
|
||||||
|
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
|
rm /tmp/calendar.tgz
|
||||||
|
|
||||||
|
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
||||||
|
# we will install from their github repository.
|
||||||
|
if [ -n "$version_user_external" ]; then
|
||||||
|
wget_verify https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
|
||||||
|
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
|
rm /tmp/user_external.tgz
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix weird permissions.
|
||||||
|
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
||||||
|
|
||||||
|
# Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously
|
||||||
|
# put in, and in new installs we're creating a symlink and will create the actual config later).
|
||||||
|
ln -sf $STORAGE_ROOT/owncloud/config.php /usr/local/lib/owncloud/config/config.php
|
||||||
|
|
||||||
|
# Make sure permissions are correct or the upgrade step won't run.
|
||||||
|
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
||||||
|
# that error.
|
||||||
|
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud || /bin/true
|
||||||
|
|
||||||
|
# If this isn't a new installation, immediately run the upgrade script.
|
||||||
|
# Then check for success (0=ok and 3=no upgrade needed, both are success).
|
||||||
|
if [ -e $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$PHP_VER /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$PHP_VER /usr/local/lib/owncloud/occ upgrade
|
||||||
|
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
||||||
|
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ maintenance:mode --off
|
||||||
|
echo "...which seemed to work."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
|
||||||
|
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:add-missing-indices
|
||||||
|
|
||||||
|
# Run conversion to BigInt identifiers, this process may take some time on large tables.
|
||||||
|
sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Current Nextcloud Version, #1623
|
||||||
|
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
|
||||||
|
# $STORAGE_ROOT/owncloud is kept together even during a backup. It is better to rely on config.php than
|
||||||
|
# version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud
|
||||||
|
# application version than the database.
|
||||||
|
|
||||||
|
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
|
||||||
|
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
|
||||||
|
CURRENT_NEXTCLOUD_VER=$(php$PHP_VER -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
|
||||||
|
else
|
||||||
|
CURRENT_NEXTCLOUD_VER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If the Nextcloud directory is missing (never been installed before, or the nextcloud version to be installed is different
|
||||||
|
# from the version currently installed, do the install/upgrade
|
||||||
|
if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextcloud_ver ]]; then
|
||||||
|
|
||||||
|
# Stop php-fpm if running. If they are not running (which happens on a previously failed install), dont bail.
|
||||||
|
service php$PHP_VER-fpm stop &> /dev/null || /bin/true
|
||||||
|
|
||||||
|
# Backup the existing ownCloud/Nextcloud.
|
||||||
|
# Create a backup directory to store the current installation and database to
|
||||||
|
BACKUP_DIRECTORY=$STORAGE_ROOT/owncloud-backup/$(date +"%Y-%m-%d-%T")
|
||||||
|
mkdir -p "$BACKUP_DIRECTORY"
|
||||||
|
if [ -d /usr/local/lib/owncloud/ ]; then
|
||||||
|
echo "Upgrading Nextcloud --- backing up existing installation, configuration, and database to directory to $BACKUP_DIRECTORY..."
|
||||||
|
cp -r /usr/local/lib/owncloud "$BACKUP_DIRECTORY/owncloud-install"
|
||||||
|
fi
|
||||||
|
if [ -e $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
||||||
|
cp $STORAGE_ROOT/owncloud/owncloud.db $BACKUP_DIRECTORY
|
||||||
|
fi
|
||||||
|
if [ -e $STORAGE_ROOT/owncloud/config.php ]; then
|
||||||
|
cp $STORAGE_ROOT/owncloud/config.php $BACKUP_DIRECTORY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If ownCloud or Nextcloud was previously installed....
|
||||||
|
if [ ! -z ${CURRENT_NEXTCLOUD_VER} ]; then
|
||||||
|
# Database migrations from ownCloud are no longer possible because ownCloud cannot be run under
|
||||||
|
# PHP 7.
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^[89] ]]; then
|
||||||
|
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 8 or 9) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
|
||||||
|
return 0
|
||||||
|
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[012] ]]; then
|
||||||
|
echo "Upgrades from Mail-in-a-Box prior to v0.28 (dated July 30, 2018) with Nextcloud < 13.0.6 (you have ownCloud 10, 11 or 12) are not supported. Upgrade to Mail-in-a-Box version v0.30 first. Setup will continue, but skip the Nextcloud migration."
|
||||||
|
return 0
|
||||||
|
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^1[3456789] ]]; then
|
||||||
|
echo "Upgrades from Mail-in-a-Box prior to v60 with Nextcloud 19 or earlier are not supported. Upgrade to the latest Mail-in-a-Box version supported on your machine first. Setup will continue, but skip the Nextcloud migration."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then
|
||||||
|
InstallNextcloud 21.0.7 f5c7079c5b56ce1e301c6a27c0d975d608bb01c9 4.0.7 45e7cf4bfe99cd8d03625cf9e5a1bb2e90549136 3.0.4 d0284b68135777ec9ca713c307216165b294d0fe
|
||||||
|
CURRENT_NEXTCLOUD_VER="21.0.7"
|
||||||
|
fi
|
||||||
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^21 ]]; then
|
||||||
|
InstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 697f6b4a664e928d72414ea2731cb2c9d1dc3077 3.2.2 ce4030ab57f523f33d5396c6a81396d440756f5f 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
|
||||||
|
CURRENT_NEXTCLOUD_VER="22.2.6"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ### Configuring Nextcloud
|
||||||
|
|
||||||
|
# Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when
|
||||||
|
# the database does exist wipes the database and user data.
|
||||||
|
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
||||||
|
# Create user data directory
|
||||||
|
mkdir -p $STORAGE_ROOT/owncloud
|
||||||
|
|
||||||
|
# Create an initial configuration file.
|
||||||
|
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
||||||
|
cat > $STORAGE_ROOT/owncloud/config.php <<EOF;
|
||||||
|
<?php
|
||||||
|
\$CONFIG = array (
|
||||||
|
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
||||||
|
|
||||||
|
'instanceid' => '$instanceid',
|
||||||
|
|
||||||
|
'forcessl' => true, # if unset/false, Nextcloud sends a HSTS=0 header, which conflicts with nginx config
|
||||||
|
|
||||||
|
'overwritewebroot' => '/cloud',
|
||||||
|
'overwrite.cli.url' => '/cloud',
|
||||||
|
'user_backends' => array(
|
||||||
|
array(
|
||||||
|
'class' => '\OCA\UserExternal\IMAP',
|
||||||
|
'arguments' => array(
|
||||||
|
'127.0.0.1', 143, null, null, false, false
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'memcache.local' => '\OC\Memcache\APCu',
|
||||||
|
'mail_smtpmode' => 'sendmail',
|
||||||
|
'mail_smtpsecure' => '',
|
||||||
|
'mail_smtpauthtype' => 'LOGIN',
|
||||||
|
'mail_smtpauth' => false,
|
||||||
|
'mail_smtphost' => '',
|
||||||
|
'mail_smtpport' => '',
|
||||||
|
'mail_smtpname' => '',
|
||||||
|
'mail_smtppassword' => '',
|
||||||
|
'mail_from_address' => 'owncloud',
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create an auto-configuration file to fill in database settings
|
||||||
|
# when the install script is run. Make an administrator account
|
||||||
|
# here or else the install can't finish.
|
||||||
|
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;
|
||||||
|
<?php
|
||||||
|
\$AUTOCONFIG = array (
|
||||||
|
# storage/database
|
||||||
|
'directory' => '$STORAGE_ROOT/owncloud',
|
||||||
|
'dbtype' => 'sqlite3',
|
||||||
|
|
||||||
|
# create an administrator account with a random password so that
|
||||||
|
# the user does not have to enter anything on first load of Nextcloud
|
||||||
|
'adminlogin' => 'root',
|
||||||
|
'adminpass' => '$adminpassword',
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chown -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
||||||
|
|
||||||
|
# Execute Nextcloud's setup step, which creates the Nextcloud sqlite database.
|
||||||
|
# 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$PHP_VER /usr/local/lib/owncloud/index.php;)
|
||||||
|
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.
|
||||||
|
# * We need to set the timezone to the system timezone to allow fail2ban to ban
|
||||||
|
# users within the proper timeframe
|
||||||
|
# * We need to set the logdateformat to something that will work correctly with fail2ban
|
||||||
|
# * mail_domain' needs to be set every time we run the setup. Making sure we are setting
|
||||||
|
# the correct domain name if the domain is being change from the previous setup.
|
||||||
|
# Use PHP to read the settings file, modify it, and write out the new settings array.
|
||||||
|
TIMEZONE=$(cat /etc/timezone)
|
||||||
|
CONFIG_TEMP=$(/bin/mktemp)
|
||||||
|
php$PHP_VER <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
||||||
|
<?php
|
||||||
|
include("$STORAGE_ROOT/owncloud/config.php");
|
||||||
|
|
||||||
|
\$CONFIG['config_is_read_only'] = true;
|
||||||
|
|
||||||
|
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
||||||
|
|
||||||
|
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
|
||||||
|
\$CONFIG['overwrite.cli.url'] = '/cloud';
|
||||||
|
\$CONFIG['mail_from_address'] = 'administrator'; # just the local part, matches our master administrator address
|
||||||
|
|
||||||
|
\$CONFIG['logtimezone'] = '$TIMEZONE';
|
||||||
|
\$CONFIG['logdateformat'] = 'Y-m-d H:i:s';
|
||||||
|
|
||||||
|
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
|
||||||
|
|
||||||
|
\$CONFIG['user_backends'] = array(
|
||||||
|
array(
|
||||||
|
'class' => '\OCA\UserExternal\IMAP',
|
||||||
|
'arguments' => array(
|
||||||
|
'127.0.0.1', 143, null, null, false, false
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "<?php\n\\\$CONFIG = ";
|
||||||
|
var_export(\$CONFIG);
|
||||||
|
echo ";";
|
||||||
|
?>
|
||||||
|
EOF
|
||||||
|
chown www-data.www-data $STORAGE_ROOT/owncloud/config.php
|
||||||
|
|
||||||
|
# Enable/disable apps. Note that this must be done after the Nextcloud setup.
|
||||||
|
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
||||||
|
# user_external is what allows Nextcloud to use IMAP for login. The contacts
|
||||||
|
# and calendar apps are the extensions we really care about here.
|
||||||
|
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
||||||
|
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable user_external
|
||||||
|
hide_output sudo -u www-data php$PHP_VER /usr/local/lib/owncloud/console.php app:enable contacts
|
||||||
|
hide_output sudo -u www-data php$PHP_VER /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$PHP_VER /usr/local/lib/owncloud/occ upgrade
|
||||||
|
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
||||||
|
|
||||||
|
# Disable default apps that we don't support
|
||||||
|
sudo -u www-data \
|
||||||
|
php$PHP_VER /usr/local/lib/owncloud/occ app:disable photos dashboard activity \
|
||||||
|
| (grep -v "No such app enabled" || /bin/true)
|
||||||
|
|
||||||
|
# Set PHP FPM values to support large file uploads
|
||||||
|
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
|
||||||
|
upload_max_filesize=16G \
|
||||||
|
post_max_size=16G \
|
||||||
|
output_buffering=16384 \
|
||||||
|
memory_limit=512M \
|
||||||
|
max_execution_time=600 \
|
||||||
|
short_open_tag=On
|
||||||
|
|
||||||
|
# Set Nextcloud recommended opcache settings
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/cli/conf.d/10-opcache.ini -c ';' \
|
||||||
|
opcache.enable=1 \
|
||||||
|
opcache.enable_cli=1 \
|
||||||
|
opcache.interned_strings_buffer=8 \
|
||||||
|
opcache.max_accelerated_files=10000 \
|
||||||
|
opcache.memory_consumption=128 \
|
||||||
|
opcache.save_comments=1 \
|
||||||
|
opcache.revalidate_freq=1
|
||||||
|
|
||||||
|
# Migrate users_external data from <0.6.0 to version 3.0.0 (see https://github.com/nextcloud/user_external).
|
||||||
|
# This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier.
|
||||||
|
# We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the
|
||||||
|
# correct backend already.
|
||||||
|
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true
|
||||||
|
|
||||||
|
# Set up a cron job for Nextcloud.
|
||||||
|
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
|
||||||
|
#!/bin/bash
|
||||||
|
# Mail-in-a-Box
|
||||||
|
*/5 * * * * root sudo -u www-data php$PHP_VER -f /usr/local/lib/owncloud/cron.php
|
||||||
|
EOF
|
||||||
|
chmod +x /etc/cron.d/mailinabox-nextcloud
|
||||||
|
|
||||||
|
# There's nothing much of interest that a user could do as an admin for Nextcloud,
|
||||||
|
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
|
||||||
|
# But if we wanted to, we would do this:
|
||||||
|
# ```
|
||||||
|
# for user in $(management/cli.py user admins); do
|
||||||
|
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
|
||||||
|
# done
|
||||||
|
# ```
|
||||||
|
|
||||||
|
# Enable PHP modules and restart PHP.
|
||||||
|
restart_service php$PHP_VER-fpm
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Owncloud
|
|
||||||
##########################
|
|
||||||
|
|
||||||
source setup/functions.sh # load our functions
|
|
||||||
source /etc/mailinabox.conf # load global vars
|
|
||||||
|
|
||||||
# ### Installing ownCloud
|
|
||||||
|
|
||||||
apt_install \
|
|
||||||
dbconfig-common \
|
|
||||||
php5-cli php5-sqlite php5-gd php5-imap php5-curl php-pear php-apc curl libapr1 libtool libcurl4-openssl-dev php-xml-parser \
|
|
||||||
php5 php5-dev php5-gd php5-fpm memcached php5-memcache unzip
|
|
||||||
|
|
||||||
apt-get purge -qq -y owncloud*
|
|
||||||
|
|
||||||
# Install ownCloud from source of this version:
|
|
||||||
owncloud_ver=8.0.3
|
|
||||||
owncloud_hash=3192f3d783f81247eaf2914df63afdd593def4e5
|
|
||||||
|
|
||||||
# Check if ownCloud dir exist, and check if version matches owncloud_ver (if either doesn't - install/upgrade)
|
|
||||||
if [ ! -d /usr/local/lib/owncloud/ ] \
|
|
||||||
|| ! grep -q $owncloud_ver /usr/local/lib/owncloud/version.php; then
|
|
||||||
|
|
||||||
# Clear out the existing ownCloud.
|
|
||||||
rm -f /tmp/owncloud-config.php
|
|
||||||
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-$$)..."
|
|
||||||
cp /usr/local/lib/owncloud/config/config.php /tmp/owncloud-config.php
|
|
||||||
mv /usr/local/lib/owncloud /tmp/owncloud-backup-$$
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download and extract ownCloud.
|
|
||||||
wget_verify https://download.owncloud.org/community/owncloud-$owncloud_ver.zip $owncloud_hash /tmp/owncloud.zip
|
|
||||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
|
||||||
rm -f /tmp/owncloud.zip
|
|
||||||
|
|
||||||
# The two apps we actually want are not in ownCloud core. Clone them from
|
|
||||||
# their github repositories.
|
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
|
||||||
git_clone https://github.com/owncloud/contacts v$owncloud_ver '' /usr/local/lib/owncloud/apps/contacts
|
|
||||||
git_clone https://github.com/owncloud/calendar v$owncloud_ver '' /usr/local/lib/owncloud/apps/calendar
|
|
||||||
|
|
||||||
# Fix weird permissions.
|
|
||||||
chmod 750 /usr/local/lib/owncloud/{apps,config}
|
|
||||||
|
|
||||||
# Restore configuration file if we're doing an upgrade.
|
|
||||||
if [ -f /tmp/owncloud-config.php ]; then
|
|
||||||
mv /tmp/owncloud-config.php /usr/local/lib/owncloud/config/config.php
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Make sure permissions are correct or the upgrade step won't run.
|
|
||||||
# $STORAGE_ROOT/owncloud may not yet exist, so use -f to suppress
|
|
||||||
# that error.
|
|
||||||
chown -f -R www-data.www-data $STORAGE_ROOT/owncloud /usr/local/lib/owncloud
|
|
||||||
|
|
||||||
# Run the upgrade script (if ownCloud is already up-to-date it wont matter).
|
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ### Configuring ownCloud
|
|
||||||
|
|
||||||
# Setup ownCloud if the ownCloud database does not yet exist. Running setup when
|
|
||||||
# the database does exist wipes the database and user data.
|
|
||||||
if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
|
||||||
# Create a configuration file.
|
|
||||||
TIMEZONE=$(cat /etc/timezone)
|
|
||||||
instanceid=oc$(echo $PRIMARY_HOSTNAME | sha1sum | fold -w 10 | head -n 1)
|
|
||||||
cat > /usr/local/lib/owncloud/config/config.php <<EOF;
|
|
||||||
<?php
|
|
||||||
\$CONFIG = array (
|
|
||||||
'datadirectory' => '$STORAGE_ROOT/owncloud',
|
|
||||||
|
|
||||||
'instanceid' => '$instanceid',
|
|
||||||
|
|
||||||
'trusted_domains' =>
|
|
||||||
array (
|
|
||||||
0 => '$PRIMARY_HOSTNAME',
|
|
||||||
),
|
|
||||||
'forcessl' => true, # if unset/false, ownCloud sends a HSTS=0 header, which conflicts with nginx config
|
|
||||||
|
|
||||||
'overwritewebroot' => '/cloud',
|
|
||||||
'user_backends' => array(
|
|
||||||
array(
|
|
||||||
'class'=>'OC_User_IMAP',
|
|
||||||
'arguments'=>array('{localhost:993/imap/ssl/novalidate-cert}')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"memcached_servers" => array (
|
|
||||||
array('localhost', 11211),
|
|
||||||
),
|
|
||||||
'mail_smtpmode' => 'sendmail',
|
|
||||||
'mail_smtpsecure' => '',
|
|
||||||
'mail_smtpauthtype' => 'LOGIN',
|
|
||||||
'mail_smtpauth' => false,
|
|
||||||
'mail_smtphost' => '',
|
|
||||||
'mail_smtpport' => '',
|
|
||||||
'mail_smtpname' => '',
|
|
||||||
'mail_smtppassword' => '',
|
|
||||||
'mail_from_address' => 'owncloud',
|
|
||||||
'mail_domain' => '$PRIMARY_HOSTNAME',
|
|
||||||
'logtimezone' => '$TIMEZONE',
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Create an auto-configuration file to fill in database settings
|
|
||||||
# when the install script is run. Make an administrator account
|
|
||||||
# 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)
|
|
||||||
cat > /usr/local/lib/owncloud/config/autoconfig.php <<EOF;
|
|
||||||
<?php
|
|
||||||
\$AUTOCONFIG = array (
|
|
||||||
# storage/database
|
|
||||||
'directory' => '$STORAGE_ROOT/owncloud',
|
|
||||||
'dbtype' => 'sqlite3',
|
|
||||||
|
|
||||||
# create an administrator account with a random password so that
|
|
||||||
# the user does not have to enter anything on first load of ownCloud
|
|
||||||
'adminlogin' => 'root',
|
|
||||||
'adminpass' => '$adminpassword',
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Create user data directory and set permissions
|
|
||||||
mkdir -p $STORAGE_ROOT/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.
|
|
||||||
# It also wipes it if it exists. And it deletes the autoconfig.php file.
|
|
||||||
(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable/disable apps. Note that this must be done after the ownCloud setup.
|
|
||||||
# The firstrunwizard gave Josh all sorts of problems, so disabling that.
|
|
||||||
# user_external is what allows ownCloud to use IMAP for login. The contacts
|
|
||||||
# and calendar apps are the extensions we really care about here.
|
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
|
||||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable user_external
|
|
||||||
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
|
|
||||||
|
|
||||||
# Set PHP FPM values to support large file uploads
|
|
||||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
|
||||||
tools/editconf.py /etc/php5/fpm/php.ini -c ';' \
|
|
||||||
upload_max_filesize=16G \
|
|
||||||
post_max_size=16G \
|
|
||||||
output_buffering=16384 \
|
|
||||||
memory_limit=512M \
|
|
||||||
max_execution_time=600 \
|
|
||||||
short_open_tag=On
|
|
||||||
|
|
||||||
# Set up a cron job for owncloud.
|
|
||||||
cat > /etc/cron.hourly/mailinabox-owncloud << EOF;
|
|
||||||
#!/bin/bash
|
|
||||||
# Mail-in-a-Box
|
|
||||||
sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
|
|
||||||
EOF
|
|
||||||
chmod +x /etc/cron.hourly/mailinabox-owncloud
|
|
||||||
|
|
||||||
# There's nothing much of interest that a user could do as an admin for ownCloud,
|
|
||||||
# and there's a lot they could mess up, so we don't make any users admins of ownCloud.
|
|
||||||
# But if we wanted to, we would do this:
|
|
||||||
# ```
|
|
||||||
# for user in $(tools/mail.py user admins); do
|
|
||||||
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
|
|
||||||
# done
|
|
||||||
# ```
|
|
||||||
|
|
||||||
# Enable PHP modules and restart PHP.
|
|
||||||
php5enmod imap
|
|
||||||
restart_service php5-fpm
|
|
||||||
@@ -4,32 +4,63 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
echo
|
echo
|
||||||
echo "sudo $0"
|
echo "sudo $0"
|
||||||
echo
|
echo
|
||||||
exit
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we are running on Ubuntu 14.04 LTS (or 14.04.xx).
|
# Check that we are running on Ubuntu 20.04 LTS (or 20.04.xx).
|
||||||
if [ "`lsb_release -d | sed 's/.*:\s*//' | sed 's/14\.04\.[0-9]/14.04/' `" != "Ubuntu 14.04 LTS" ]; then
|
if [ "$( lsb_release --id --short )" != "Ubuntu" ] || [ "$( lsb_release --release --short )" != "22.04" ]; then
|
||||||
echo "Mail-in-a-Box only supports being installed on Ubuntu 14.04, sorry. You are running:"
|
echo "Mail-in-a-Box only supports being installed on Ubuntu 22.04, sorry. You are running:"
|
||||||
echo
|
echo
|
||||||
lsb_release -d | sed 's/.*:\s*//'
|
lsb_release --description --short
|
||||||
echo
|
echo
|
||||||
echo "We can't write scripts that run on every possible setup, sorry."
|
echo "We can't write scripts that run on every possible setup, sorry."
|
||||||
exit
|
exit 1
|
||||||
fi
|
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 512 MB,
|
||||||
# which is 750000 kibibytes.
|
# which is 500000 kibibytes.
|
||||||
|
#
|
||||||
|
# We will display a warning if the memory is below 768 MB 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.
|
||||||
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}')
|
||||||
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
|
if [ $TOTAL_PHYSICAL_MEM -lt 490000 ]; then
|
||||||
if [ ! -d /vagrant ]; then
|
if [ ! -d /vagrant ]; then
|
||||||
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
TOTAL_PHYSICAL_MEM=$(expr \( \( $TOTAL_PHYSICAL_MEM \* 1024 \) / 1000 \) / 1000)
|
||||||
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
echo "Your Mail-in-a-Box needs more memory (RAM) to function properly."
|
||||||
echo "Please provision a machine with at least 768 MB, 1 GB recommended."
|
echo "Please provision a machine with at least 512 MB, 1 GB recommended."
|
||||||
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
echo "This machine has $TOTAL_PHYSICAL_MEM MB memory."
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
if [ $TOTAL_PHYSICAL_MEM -lt 750000 ]; then
|
||||||
|
echo "WARNING: Your Mail-in-a-Box has less than 768 MB of memory."
|
||||||
|
echo " It might run unreliably when under heavy load."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that tempfs is mounted with exec
|
||||||
|
MOUNTED_TMP_AS_NO_EXEC=$(grep "/tmp.*noexec" /proc/mounts || /bin/true)
|
||||||
|
if [ -n "$MOUNTED_TMP_AS_NO_EXEC" ]; then
|
||||||
|
echo "Mail-in-a-Box has to have exec rights on /tmp, please mount /tmp with exec"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that no .wgetrc exists
|
||||||
|
if [ -e ~/.wgetrc ]; then
|
||||||
|
echo "Mail-in-a-Box expects no overrides to wget defaults, ~/.wgetrc exists"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that we are running on x86_64 or i686 architecture, which are the only
|
||||||
|
# ones we support / test.
|
||||||
|
ARCHITECTURE=$(uname -m)
|
||||||
|
if [ "$ARCHITECTURE" != "x86_64" ] && [ "$ARCHITECTURE" != "i686" ]; then
|
||||||
|
echo
|
||||||
|
echo "WARNING:"
|
||||||
|
echo "Mail-in-a-Box has only been tested on x86_64 and i686 platform"
|
||||||
|
echo "architectures. Your architecture, $ARCHITECTURE, may not work."
|
||||||
|
echo "You are on your own."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
if [ -z "$NONINTERACTIVE" ]; then
|
if [ -z "${NONINTERACTIVE:-}" ]; then
|
||||||
# Install 'dialog' so we can ask the user questions. The original motivation for
|
# Install 'dialog' so we can ask the user questions. The original motivation for
|
||||||
# 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.
|
||||||
echo Installing packages needed for setup...
|
if [ ! -f /usr/bin/dialog ] || [ ! -f /usr/bin/python3 ] || [ ! -f /usr/bin/pip3 ]; then
|
||||||
apt_get_quiet install dialog python3 python3-pip || exit 1
|
echo Installing packages needed for setup...
|
||||||
|
apt-get -q -q update
|
||||||
|
apt_get_quiet install dialog python3 python3-pip || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# email_validator is repeated in setup/management.sh
|
# Installing email_validator is repeated in setup/management.sh, but in setup/management.sh
|
||||||
hide_output pip3 install "email_validator==0.1.0-rc5" || exit 1
|
# we install it inside a virtualenv. In this script, we don't have the virtualenv yet
|
||||||
|
# so we install the python package globally.
|
||||||
|
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.
|
||||||
if [ -z "$PRIMARY_HOSTNAME" ]; then
|
if [ -z "${PRIMARY_HOSTNAME:-}" ]; then
|
||||||
if [ -z "$DEFAULT_PRIMARY_HOSTNAME" ]; then
|
if [ -z "${DEFAULT_PRIMARY_HOSTNAME:-}" ]; then
|
||||||
# We recommend to use box.example.com as this hosts name. The
|
# We recommend to use box.example.com as this hosts name. The
|
||||||
# domain the user possibly wants to use is example.com then.
|
# domain the user possibly wants to use is example.com then.
|
||||||
# We strip the string "box." from the hostname to get the mail
|
# We strip the string "box." from the hostname to get the mail
|
||||||
@@ -45,7 +51,7 @@ you really want.
|
|||||||
# user hit ESC/cancel
|
# user hit ESC/cancel
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
while ! management/mailconfig.py validate-email "$EMAIL_ADDR"
|
while ! python3 management/mailconfig.py validate-email "$EMAIL_ADDR"
|
||||||
do
|
do
|
||||||
input_box "Your Email Address" \
|
input_box "Your Email Address" \
|
||||||
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
|
"That's not a valid email address.\n\nWhat email address are you setting this box up to manage?" \
|
||||||
@@ -80,30 +86,30 @@ fi
|
|||||||
# If the machine is behind a NAT, inside a VM, etc., it may not know
|
# If the machine is behind a NAT, inside a VM, etc., it may not know
|
||||||
# its IP address on the public network / the Internet. Ask the Internet
|
# its IP address on the public network / the Internet. Ask the Internet
|
||||||
# and possibly confirm with user.
|
# and possibly confirm with user.
|
||||||
if [ -z "$PUBLIC_IP" ]; then
|
if [ -z "${PUBLIC_IP:-}" ]; then
|
||||||
# Ask the Internet.
|
# Ask the Internet.
|
||||||
GUESSED_IP=$(get_publicip_from_web_service 4)
|
GUESSED_IP=$(get_publicip_from_web_service 4)
|
||||||
|
|
||||||
# On the first run, if we got an answer from the Internet then don't
|
# On the first run, if we got an answer from the Internet then don't
|
||||||
# ask the user.
|
# ask the user.
|
||||||
if [[ -z "$DEFAULT_PUBLIC_IP" && ! -z "$GUESSED_IP" ]]; then
|
if [[ -z "${DEFAULT_PUBLIC_IP:-}" && ! -z "$GUESSED_IP" ]]; then
|
||||||
PUBLIC_IP=$GUESSED_IP
|
PUBLIC_IP=$GUESSED_IP
|
||||||
|
|
||||||
# Otherwise on the first run at least provide a default.
|
# Otherwise on the first run at least provide a default.
|
||||||
elif [[ -z "$DEFAULT_PUBLIC_IP" ]]; then
|
elif [[ -z "${DEFAULT_PUBLIC_IP:-}" ]]; then
|
||||||
DEFAULT_PUBLIC_IP=$(get_default_privateip 4)
|
DEFAULT_PUBLIC_IP=$(get_default_privateip 4)
|
||||||
|
|
||||||
# On later runs, if the previous value matches the guessed value then
|
# On later runs, if the previous value matches the guessed value then
|
||||||
# don't ask the user either.
|
# don't ask the user either.
|
||||||
elif [ "$DEFAULT_PUBLIC_IP" == "$GUESSED_IP" ]; then
|
elif [ "${DEFAULT_PUBLIC_IP:-}" == "$GUESSED_IP" ]; then
|
||||||
PUBLIC_IP=$GUESSED_IP
|
PUBLIC_IP=$GUESSED_IP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$PUBLIC_IP" ]; then
|
if [ -z "${PUBLIC_IP:-}" ]; then
|
||||||
input_box "Public IP Address" \
|
input_box "Public IP Address" \
|
||||||
"Enter the public IP address of this machine, as given to you by your ISP.
|
"Enter the public IP address of this machine, as given to you by your ISP.
|
||||||
\n\nPublic IP address:" \
|
\n\nPublic IP address:" \
|
||||||
$DEFAULT_PUBLIC_IP \
|
${DEFAULT_PUBLIC_IP:-} \
|
||||||
PUBLIC_IP
|
PUBLIC_IP
|
||||||
|
|
||||||
if [ -z "$PUBLIC_IP" ]; then
|
if [ -z "$PUBLIC_IP" ]; then
|
||||||
@@ -115,27 +121,27 @@ fi
|
|||||||
|
|
||||||
# Same for IPv6. But it's optional. Also, if it looks like the system
|
# Same for IPv6. But it's optional. Also, if it looks like the system
|
||||||
# doesn't have an IPv6, don't ask for one.
|
# doesn't have an IPv6, don't ask for one.
|
||||||
if [ -z "$PUBLIC_IPV6" ]; then
|
if [ -z "${PUBLIC_IPV6:-}" ]; then
|
||||||
# Ask the Internet.
|
# Ask the Internet.
|
||||||
GUESSED_IP=$(get_publicip_from_web_service 6)
|
GUESSED_IP=$(get_publicip_from_web_service 6)
|
||||||
MATCHED=0
|
MATCHED=0
|
||||||
if [[ -z "$DEFAULT_PUBLIC_IPV6" && ! -z "$GUESSED_IP" ]]; then
|
if [[ -z "${DEFAULT_PUBLIC_IPV6:-}" && ! -z "$GUESSED_IP" ]]; then
|
||||||
PUBLIC_IPV6=$GUESSED_IP
|
PUBLIC_IPV6=$GUESSED_IP
|
||||||
elif [[ "$DEFAULT_PUBLIC_IPV6" == "$GUESSED_IP" ]]; then
|
elif [[ "${DEFAULT_PUBLIC_IPV6:-}" == "$GUESSED_IP" ]]; then
|
||||||
# No IPv6 entered and machine seems to have none, or what
|
# No IPv6 entered and machine seems to have none, or what
|
||||||
# the user entered matches what the Internet tells us.
|
# the user entered matches what the Internet tells us.
|
||||||
PUBLIC_IPV6=$GUESSED_IP
|
PUBLIC_IPV6=$GUESSED_IP
|
||||||
MATCHED=1
|
MATCHED=1
|
||||||
elif [[ -z "$DEFAULT_PUBLIC_IPV6" ]]; then
|
elif [[ -z "${DEFAULT_PUBLIC_IPV6:-}" ]]; then
|
||||||
DEFAULT_PUBLIC_IP=$(get_default_privateip 6)
|
DEFAULT_PUBLIC_IP=$(get_default_privateip 6)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$PUBLIC_IPV6" && $MATCHED == 0 ]]; then
|
if [[ -z "${PUBLIC_IPV6:-}" && $MATCHED == 0 ]]; then
|
||||||
input_box "IPv6 Address (Optional)" \
|
input_box "IPv6 Address (Optional)" \
|
||||||
"Enter the public IPv6 address of this machine, as given to you by your ISP.
|
"Enter the public IPv6 address of this machine, as given to you by your ISP.
|
||||||
\n\nLeave blank if the machine does not have an IPv6 address.
|
\n\nLeave blank if the machine does not have an IPv6 address.
|
||||||
\n\nPublic IPv6 address:" \
|
\n\nPublic IPv6 address:" \
|
||||||
$DEFAULT_PUBLIC_IPV6 \
|
${DEFAULT_PUBLIC_IPV6:-} \
|
||||||
PUBLIC_IPV6
|
PUBLIC_IPV6
|
||||||
|
|
||||||
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
|
if [ ! $PUBLIC_IPV6_EXITCODE ]; then
|
||||||
@@ -148,10 +154,10 @@ fi
|
|||||||
# Get the IP addresses of the local network interface(s) that are connected
|
# Get the IP addresses of the local network interface(s) that are connected
|
||||||
# to the Internet. We need these when we want to have services bind only to
|
# to the Internet. We need these when we want to have services bind only to
|
||||||
# the public network interfaces (not loopback, not tunnel interfaces).
|
# the public network interfaces (not loopback, not tunnel interfaces).
|
||||||
if [ -z "$PRIVATE_IP" ]; then
|
if [ -z "${PRIVATE_IP:-}" ]; then
|
||||||
PRIVATE_IP=$(get_default_privateip 4)
|
PRIVATE_IP=$(get_default_privateip 4)
|
||||||
fi
|
fi
|
||||||
if [ -z "$PRIVATE_IPV6" ]; then
|
if [ -z "${PRIVATE_IPV6:-}" ]; then
|
||||||
PRIVATE_IPV6=$(get_default_privateip 6)
|
PRIVATE_IPV6=$(get_default_privateip 6)
|
||||||
fi
|
fi
|
||||||
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
|
if [[ -z "$PRIVATE_IP" && -z "$PRIVATE_IPV6" ]]; then
|
||||||
@@ -165,31 +171,42 @@ 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)
|
||||||
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=2000\"" \
|
||||||
|
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
|
||||||
@@ -44,9 +61,61 @@ tools/editconf.py /etc/default/spampd \
|
|||||||
# content or execute scripts, and it is probably confusing to most users.
|
# content or execute scripts, and it is probably confusing to most users.
|
||||||
#
|
#
|
||||||
# Tell Spamassassin not to modify the original message except for adding
|
# Tell Spamassassin not to modify the original message except for adding
|
||||||
# the X-Spam-Status mail header and related headers.
|
# the X-Spam-Status & X-Spam-Score mail headers and related headers.
|
||||||
tools/editconf.py /etc/spamassassin/local.cf -s \
|
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||||
report_safe=0
|
report_safe=0 \
|
||||||
|
"add_header all Report"=_REPORT_ \
|
||||||
|
"add_header all Score"=_SCORE_
|
||||||
|
|
||||||
|
|
||||||
|
# Authentication-Results SPF/Dmarc checks
|
||||||
|
# ---------------------------------------
|
||||||
|
# OpenDKIM and OpenDMARC are configured to validate and add "Authentication-Results: ..."
|
||||||
|
# headers by checking the sender's SPF & DMARC policies. Instead of blocking mail that fails
|
||||||
|
# these checks, we can use these headers to evaluate the mail as spam.
|
||||||
|
#
|
||||||
|
# Our custom rules are added to their own file so that an update to the deb package config
|
||||||
|
# does not remove our changes.
|
||||||
|
#
|
||||||
|
# We need to escape period's in $PRIMARY_HOSTNAME since spamassassin config uses regex.
|
||||||
|
|
||||||
|
escapedprimaryhostname="${PRIMARY_HOSTNAME//./\\.}"
|
||||||
|
|
||||||
|
cat > /etc/spamassassin/miab_spf_dmarc.cf << EOF
|
||||||
|
# Evaluate DMARC Authentication-Results
|
||||||
|
header DMARC_PASS Authentication-Results =~ /$escapedprimaryhostname; dmarc=pass/
|
||||||
|
describe DMARC_PASS DMARC check passed
|
||||||
|
score DMARC_PASS -0.1
|
||||||
|
|
||||||
|
header DMARC_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=none/
|
||||||
|
describe DMARC_NONE DMARC record not found
|
||||||
|
score DMARC_NONE 0.1
|
||||||
|
|
||||||
|
header DMARC_FAIL_NONE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=none/
|
||||||
|
describe DMARC_FAIL_NONE DMARC check failed (p=none)
|
||||||
|
score DMARC_FAIL_NONE 2.0
|
||||||
|
|
||||||
|
header DMARC_FAIL_QUARANTINE Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=quarantine/
|
||||||
|
describe DMARC_FAIL_QUARANTINE DMARC check failed (p=quarantine)
|
||||||
|
score DMARC_FAIL_QUARANTINE 5.0
|
||||||
|
|
||||||
|
header DMARC_FAIL_REJECT Authentication-Results =~ /$escapedprimaryhostname; dmarc=fail \(p=reject/
|
||||||
|
describe DMARC_FAIL_REJECT DMARC check failed (p=reject)
|
||||||
|
score DMARC_FAIL_REJECT 10.0
|
||||||
|
|
||||||
|
# Evaluate SPF Authentication-Results
|
||||||
|
header SPF_PASS Authentication-Results =~ /$escapedprimaryhostname; spf=pass/
|
||||||
|
describe SPF_PASS SPF check passed
|
||||||
|
score SPF_PASS -0.1
|
||||||
|
|
||||||
|
header SPF_NONE Authentication-Results =~ /$escapedprimaryhostname; spf=none/
|
||||||
|
describe SPF_NONE SPF record not found
|
||||||
|
score SPF_NONE 2.0
|
||||||
|
|
||||||
|
header SPF_FAIL Authentication-Results =~ /$escapedprimaryhostname; spf=fail/
|
||||||
|
describe SPF_FAIL SPF check failed
|
||||||
|
score SPF_FAIL 5.0
|
||||||
|
EOF
|
||||||
|
|
||||||
# Bayesean learning
|
# Bayesean learning
|
||||||
# -----------------
|
# -----------------
|
||||||
@@ -61,9 +130,13 @@ tools/editconf.py /etc/spamassassin/local.cf -s \
|
|||||||
# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.
|
# * Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.
|
||||||
#
|
#
|
||||||
# We'll have these files owned by spampd and grant access to the other two processes.
|
# We'll have these files owned by spampd and grant access to the other two processes.
|
||||||
|
#
|
||||||
|
# Spamassassin will change the access rights back to the defaults, so we must also configure
|
||||||
|
# the filemode in the config file.
|
||||||
|
|
||||||
tools/editconf.py /etc/spamassassin/local.cf -s \
|
tools/editconf.py /etc/spamassassin/local.cf -s \
|
||||||
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes
|
bayes_path=$STORAGE_ROOT/mail/spamassassin/bayes \
|
||||||
|
bayes_file_mode=0666
|
||||||
|
|
||||||
mkdir -p $STORAGE_ROOT/mail/spamassassin
|
mkdir -p $STORAGE_ROOT/mail/spamassassin
|
||||||
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
|
chown -R spampd:spampd $STORAGE_ROOT/mail/spamassassin
|
||||||
@@ -82,6 +155,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 ports 465/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 "/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
|
||||||
|
|||||||
145
setup/start.sh
145
setup/start.sh
@@ -4,16 +4,17 @@
|
|||||||
|
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
|
|
||||||
# Check system setup: Are we running as root on Ubuntu 14.04 on a
|
# Check system setup: Are we running as root on Ubuntu 18.04 on a
|
||||||
# machine with enough memory? If not, this shows an error and exits.
|
# machine with enough memory? Is /tmp mounted with exec.
|
||||||
|
# If not, this shows an error and exits.
|
||||||
source setup/preflight.sh
|
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 ! locale -a | grep en_US.utf8 > /dev/null; then
|
||||||
# Generate locale if not exists
|
# Generate locale if not exists
|
||||||
hide_output locale-gen en_US.UTF-8
|
hide_output locale-gen en_US.UTF-8
|
||||||
fi
|
fi
|
||||||
@@ -23,6 +24,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,92 +38,60 @@ 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'
|
||||||
# in the first dialog prompt, so we should do this before that starts.
|
# in the first dialog prompt, so we should do this before that starts.
|
||||||
cat > /usr/local/bin/mailinabox << EOF;
|
cat > /usr/local/bin/mailinabox << EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd `pwd`
|
cd $(pwd)
|
||||||
source setup/start.sh
|
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.
|
||||||
if [ -z "$SKIP_NETWORK_CHECKS" ]; then
|
# Skip on existing installs since we don't want this to block the ability to
|
||||||
. setup/network-checks.sh
|
# upgrade, and these checks are also in the control panel status checks.
|
||||||
|
if [ -z "${DEFAULT_PRIMARY_HOSTNAME:-}" ]; then
|
||||||
|
if [ -z "${SKIP_NETWORK_CHECKS:-}" ]; then
|
||||||
|
source setup/network-checks.sh
|
||||||
|
fi
|
||||||
fi
|
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
|
|
||||||
# data (mailboxes, etc.) in that user's home directory.
|
|
||||||
#
|
#
|
||||||
# If the config file exists:
|
# Set the directory and all of its parent directories' permissions to world
|
||||||
# Apply the existing configuration options for STORAGE_USER/ROOT
|
# readable since it holds files owned by different processes.
|
||||||
if [ -z "$STORAGE_USER" ]; then
|
#
|
||||||
STORAGE_USER=$([[ -z "$DEFAULT_STORAGE_USER" ]] && echo "user-data" || echo "$DEFAULT_STORAGE_USER")
|
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
||||||
fi
|
# 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 [ -z "$STORAGE_ROOT" ]; then
|
# migration number for this version of Mail-in-a-Box.
|
||||||
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
|
||||||
|
f=$STORAGE_ROOT
|
||||||
# Create mailinabox.version file if not exists
|
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
|
||||||
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
echo $(setup/migrate.py --current) > $STORAGE_ROOT/mailinabox.version
|
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||||
# tools know where to look for data.
|
# tools know where to look for data. The default MTA_STS_MODE setting
|
||||||
|
# is blank unless set by an environment variable, but see web.sh for
|
||||||
|
# how that is interpreted.
|
||||||
cat > /etc/mailinabox.conf << EOF;
|
cat > /etc/mailinabox.conf << EOF;
|
||||||
STORAGE_USER=$STORAGE_USER
|
STORAGE_USER=$STORAGE_USER
|
||||||
STORAGE_ROOT=$STORAGE_ROOT
|
STORAGE_ROOT=$STORAGE_ROOT
|
||||||
@@ -128,7 +100,7 @@ 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
|
MTA_STS_MODE=${DEFAULT_MTA_STS_MODE:-enforce}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Start service configuration.
|
# Start service configuration.
|
||||||
@@ -142,21 +114,44 @@ source setup/dkim.sh
|
|||||||
source setup/spamassassin.sh
|
source setup/spamassassin.sh
|
||||||
source setup/web.sh
|
source setup/web.sh
|
||||||
source setup/webmail.sh
|
source setup/webmail.sh
|
||||||
source setup/owncloud.sh
|
source setup/nextcloud.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.
|
# Wait for the management daemon to start...
|
||||||
while [ ! -f /var/lib/mailinabox/api.key ]; do
|
until nc -z -w 4 127.0.0.1 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
|
||||||
|
|
||||||
|
# ...and then have it write the DNS and nginx configuration files and start those
|
||||||
|
# services.
|
||||||
tools/dns_update
|
tools/dns_update
|
||||||
tools/web_update
|
tools/web_update
|
||||||
|
|
||||||
|
# Give fail2ban another restart. The log files may not all have been present when
|
||||||
|
# fail2ban was first configured, but they should exist now.
|
||||||
|
restart_service fail2ban
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
# Register with Let's Encrypt, including agreeing to the Terms of Service.
|
||||||
|
# We'd let certbot ask the user interactively, but when this script is
|
||||||
|
# run in the recommended curl-pipe-to-bash method there is no TTY and
|
||||||
|
# certbot will fail if it tries to ask.
|
||||||
|
if [ ! -d $STORAGE_ROOT/ssl/lets_encrypt/accounts/acme-v02.api.letsencrypt.org/ ]; then
|
||||||
|
echo
|
||||||
|
echo "-----------------------------------------------"
|
||||||
|
echo "Mail-in-a-Box uses Let's Encrypt to provision free SSL/TLS certificates"
|
||||||
|
echo "to enable HTTPS connections to your box. We're automatically"
|
||||||
|
echo "agreeing you to their subscriber agreement. See https://letsencrypt.org."
|
||||||
|
echo
|
||||||
|
certbot register --register-unsafely-without-email --agree-tos --config-dir $STORAGE_ROOT/ssl/lets_encrypt
|
||||||
|
fi
|
||||||
|
|
||||||
# Done.
|
# Done.
|
||||||
echo
|
echo
|
||||||
echo "-----------------------------------------------"
|
echo "-----------------------------------------------"
|
||||||
@@ -169,17 +164,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 TLS fingerprint:"
|
||||||
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
||||||
|
| sed "s/SHA256 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
|
||||||
|
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint -sha256\
|
||||||
|
| sed "s/SHA256 Fingerprint=//"
|
||||||
|
echo
|
||||||
|
echo Then you can confirm the security exception and continue.
|
||||||
|
echo
|
||||||
fi
|
fi
|
||||||
openssl x509 -in $STORAGE_ROOT/ssl/ssl_certificate.pem -noout -fingerprint \
|
|
||||||
| sed "s/SHA1 Fingerprint=//"
|
|
||||||
echo
|
|
||||||
echo Then you can confirm the security exception and continue.
|
|
||||||
echo
|
|
||||||
|
|||||||
328
setup/system.sh
328
setup/system.sh
@@ -1,41 +1,257 @@
|
|||||||
|
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
|
# ### Set hostname of the box
|
||||||
|
|
||||||
# Update system packages to make sure we have the latest upstream versions of things from Ubuntu.
|
# If the hostname is not correctly resolvable sudo can't be used. This will result in
|
||||||
|
# errors during the install
|
||||||
|
#
|
||||||
|
# First set the hostname in the configuration file, then activate the setting
|
||||||
|
|
||||||
|
echo $PRIMARY_HOSTNAME > /etc/hostname
|
||||||
|
hostname $PRIMARY_HOSTNAME
|
||||||
|
|
||||||
|
# ### Fix permissions
|
||||||
|
|
||||||
|
# The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect
|
||||||
|
# permissions (group writeable) set on the following directories.
|
||||||
|
|
||||||
|
chmod g-w /etc /etc/default /usr
|
||||||
|
|
||||||
|
# ### Add swap space to the system
|
||||||
|
|
||||||
|
# If the physical memory of the system is below 2GB it is wise to create a
|
||||||
|
# swap file. This will make the system more resiliant to memory spikes and
|
||||||
|
# prevent for instance spam filtering from crashing
|
||||||
|
|
||||||
|
# We will create a 1G file, this should be a good balance between disk usage
|
||||||
|
# and buffers for the system. We will only allocate this file if there is more
|
||||||
|
# than 5GB of disk space available
|
||||||
|
|
||||||
|
# The following checks are performed:
|
||||||
|
# - Check if swap is currently mountend by looking at /proc/swaps
|
||||||
|
# - Check if the user intents to activate swap on next boot by checking fstab entries.
|
||||||
|
# - Check if a swapfile already exists
|
||||||
|
# - Check if the root file system is not btrfs, might be an incompatible version with
|
||||||
|
# swapfiles. User should hanle it them selves.
|
||||||
|
# - Check the memory requirements
|
||||||
|
# - Check available diskspace
|
||||||
|
|
||||||
|
# See https://www.digitalocean.com/community/tutorials/how-to-add-swap-on-ubuntu-14-04
|
||||||
|
# for reference
|
||||||
|
|
||||||
|
SWAP_MOUNTED=$(cat /proc/swaps | tail -n+2)
|
||||||
|
SWAP_IN_FSTAB=$(grep "swap" /etc/fstab || /bin/true)
|
||||||
|
ROOT_IS_BTRFS=$(grep "\/ .*btrfs" /proc/mounts || /bin/true)
|
||||||
|
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)
|
||||||
|
AVAILABLE_DISK_SPACE=$(df / --output=avail | tail -n 1)
|
||||||
|
if
|
||||||
|
[ -z "$SWAP_MOUNTED" ] &&
|
||||||
|
[ -z "$SWAP_IN_FSTAB" ] &&
|
||||||
|
[ ! -e /swapfile ] &&
|
||||||
|
[ -z "$ROOT_IS_BTRFS" ] &&
|
||||||
|
[ $TOTAL_PHYSICAL_MEM -lt 1900000 ] &&
|
||||||
|
[ $AVAILABLE_DISK_SPACE -gt 5242880 ]
|
||||||
|
then
|
||||||
|
echo "Adding a swap file to the system..."
|
||||||
|
|
||||||
|
# Allocate and activate the swap file. Allocate in 1KB chuncks
|
||||||
|
# doing it in one go, could fail on low memory systems
|
||||||
|
dd if=/dev/zero of=/swapfile bs=1024 count=$[1024*1024] status=none
|
||||||
|
if [ -e /swapfile ]; then
|
||||||
|
chmod 600 /swapfile
|
||||||
|
hide_output mkswap /swapfile
|
||||||
|
swapon /swapfile
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if swap is mounted then activate on boot
|
||||||
|
if swapon -s | grep -q "\/swapfile"; then
|
||||||
|
echo "/swapfile none swap sw 0 0" >> /etc/fstab
|
||||||
|
else
|
||||||
|
echo "ERROR: Swap allocation failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ### Set log retention policy.
|
||||||
|
|
||||||
|
# Set the systemd journal log retention from infinite to 10 days,
|
||||||
|
# since over time the logs take up a large amount of space.
|
||||||
|
# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)
|
||||||
|
tools/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day
|
||||||
|
|
||||||
|
# ### Add PPAs.
|
||||||
|
|
||||||
|
# We install some non-standard Ubuntu packages maintained by other
|
||||||
|
# third-party providers. First ensure add-apt-repository is installed.
|
||||||
|
|
||||||
|
if [ ! -f /usr/bin/add-apt-repository ]; then
|
||||||
|
echo "Installing add-apt-repository..."
|
||||||
|
hide_output apt-get update
|
||||||
|
apt_install software-properties-common
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the universe repository is enabled since some of our packages
|
||||||
|
# come from there and minimal Ubuntu installs may have it turned off.
|
||||||
|
hide_output add-apt-repository -y universe
|
||||||
|
|
||||||
|
# Install the duplicity PPA.
|
||||||
|
hide_output add-apt-repository -y ppa:duplicity-team/duplicity-release-git
|
||||||
|
|
||||||
|
# Stock PHP is now 8.1, but we're transitioning through 8.0 because
|
||||||
|
# of Nextcloud.
|
||||||
|
hide_output add-apt-repository --y ppa:ondrej/php
|
||||||
|
|
||||||
|
# ### Update Packages
|
||||||
|
|
||||||
|
# Update system packages to make sure we have the latest upstream versions
|
||||||
|
# of things from Ubuntu, as well as the directory of packages provide by the
|
||||||
|
# PPAs so we can install those packages later.
|
||||||
|
|
||||||
echo Updating system packages...
|
echo Updating system packages...
|
||||||
hide_output apt-get update
|
hide_output apt-get update
|
||||||
apt_get_quiet upgrade
|
apt_get_quiet upgrade
|
||||||
|
|
||||||
|
# Old kernels pile up over time and take up a lot of disk space, and because of Mail-in-a-Box
|
||||||
|
# changes there may be other packages that are no longer needed. Clear out anything apt knows
|
||||||
|
# is safe to delete.
|
||||||
|
|
||||||
|
apt_get_quiet autoremove
|
||||||
|
|
||||||
|
# ### Install System Packages
|
||||||
|
|
||||||
# Install basic utilities.
|
# Install basic utilities.
|
||||||
#
|
#
|
||||||
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
|
|
||||||
# when generating random numbers for private keys (e.g. during
|
|
||||||
# ldns-keygen).
|
|
||||||
# * unattended-upgrades: Apt tool to install security updates automatically.
|
# * unattended-upgrades: Apt tool to install security updates automatically.
|
||||||
# * 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
|
||||||
|
# * openssh-client: provides ssh-keygen
|
||||||
|
|
||||||
apt_install python3 python3-dev python3-pip \
|
echo Installing system packages...
|
||||||
wget curl git sudo coreutils bc \
|
apt_install python3 python3-dev python3-pip python3-setuptools \
|
||||||
haveged unattended-upgrades cron ntp fail2ban
|
netcat-openbsd wget curl git sudo coreutils bc file \
|
||||||
|
pollinate openssh-client unzip \
|
||||||
|
unattended-upgrades cron ntp fail2ban rsyslog
|
||||||
|
|
||||||
|
# ### Suppress Upgrade Prompts
|
||||||
|
# When Ubuntu 20 comes out, we don't want users to be prompted to upgrade,
|
||||||
|
# because we don't yet support it.
|
||||||
|
if [ -f /etc/update-manager/release-upgrades ]; then
|
||||||
|
tools/editconf.py /etc/update-manager/release-upgrades Prompt=never
|
||||||
|
rm -f /var/lib/ubuntu-release-upgrader/release-upgrade-available
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ### 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`)
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# We need an ssh key to store backups via rsync, if it doesn't exist create one
|
||||||
|
if [ ! -f /root/.ssh/id_rsa_miab ]; then
|
||||||
|
echo 'Creating SSH key for backup…'
|
||||||
|
ssh-keygen -t rsa -b 2048 -a 100 -f /root/.ssh/id_rsa_miab -N '' -q
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ### 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;
|
||||||
APT::Periodic::MaxAge "7";
|
APT::Periodic::MaxAge "7";
|
||||||
APT::Periodic::Update-Package-Lists "1";
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
APT::Periodic::Unattended-Upgrade "1";
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
APT::Periodic::Verbose "1";
|
APT::Periodic::Verbose "0";
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# ### Firewall
|
# ### Firewall
|
||||||
@@ -43,12 +259,12 @@ EOF
|
|||||||
# Various virtualized environments like Docker and some VPSs don't provide #NODOC
|
# Various virtualized environments like Docker and some VPSs don't provide #NODOC
|
||||||
# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC
|
# a kernel that supports iptables. To avoid error-like output in these cases, #NODOC
|
||||||
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
|
# we skip this if the user sets DISABLE_FIREWALL=1. #NODOC
|
||||||
if [ -z "$DISABLE_FIREWALL" ]; then
|
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
# Install `ufw` which provides a simple firewall configuration.
|
# Install `ufw` which provides a simple firewall configuration.
|
||||||
apt_install ufw
|
apt_install ufw
|
||||||
|
|
||||||
# Allow incoming connections to SSH.
|
# Allow incoming connections to SSH.
|
||||||
ufw_allow ssh;
|
ufw_limit ssh;
|
||||||
|
|
||||||
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
||||||
# settings, find the port it is supposedly running on, and open that port #NODOC
|
# settings, find the port it is supposedly running on, and open that port #NODOC
|
||||||
@@ -58,7 +274,7 @@ if [ -z "$DISABLE_FIREWALL" ]; then
|
|||||||
if [ "$SSH_PORT" != "22" ]; then
|
if [ "$SSH_PORT" != "22" ]; then
|
||||||
|
|
||||||
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
||||||
ufw_allow $SSH_PORT #NODOC
|
ufw_limit $SSH_PORT #NODOC
|
||||||
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -68,50 +284,92 @@ fi #NODOC
|
|||||||
|
|
||||||
# ### Local DNS Service
|
# ### Local DNS Service
|
||||||
|
|
||||||
# Install a local DNS server, rather than using the DNS server provided by the
|
# Install a local recursive DNS server --- i.e. for DNS queries made by
|
||||||
# ISP's network configuration.
|
# local services running on this machine.
|
||||||
#
|
#
|
||||||
# We do this to ensure that DNS queries
|
# (This is unrelated to the box's public, non-recursive DNS server that
|
||||||
# that *we* make (i.e. looking up other external domains) perform DNSSEC checks.
|
# answers remote queries about domain names hosted on this box. For that
|
||||||
# We could use Google's Public DNS, but we don't want to create a dependency on
|
# see dns.sh.)
|
||||||
# Google per our goals of decentralization. `bind9`, as packaged for Ubuntu, has
|
|
||||||
# DNSSEC enabled by default via "dnssec-validation auto".
|
|
||||||
#
|
#
|
||||||
# So we'll be running `bind9` bound to 127.0.0.1 for locally-issued DNS queries
|
# The default systemd-resolved service provides local DNS name resolution. By default it
|
||||||
# and `nsd` bound to the public ethernet interface for remote DNS queries asking
|
# is a recursive stub nameserver, which means it simply relays requests to an
|
||||||
# about our domain names. `nsd` is configured later.
|
# external nameserver, usually provided by your ISP or configured in /etc/systemd/resolved.conf.
|
||||||
|
#
|
||||||
|
# This won't work for us for three reasons.
|
||||||
|
#
|
||||||
|
# 1) We have higher security goals --- we want DNSSEC to be enforced on all
|
||||||
|
# DNS queries (some upstream DNS servers do, some don't).
|
||||||
|
# 2) We will configure postfix to use DANE, which uses DNSSEC to find TLS
|
||||||
|
# certificates for remote servers. DNSSEC validation *must* be performed
|
||||||
|
# locally because we can't trust an unencrypted connection to an external
|
||||||
|
# DNS server.
|
||||||
|
# 3) DNS-based mail server blacklists (RBLs) typically block large ISP
|
||||||
|
# DNS servers because they only provide free data to small users. Since
|
||||||
|
# we use RBLs to block incoming mail from blacklisted IP addresses,
|
||||||
|
# we have to run our own DNS server. See #1424.
|
||||||
|
#
|
||||||
|
# systemd-resolved has a setting to perform local DNSSEC validation on all
|
||||||
|
# requests (in /etc/systemd/resolved.conf, set DNSSEC=yes), but because it's
|
||||||
|
# a stub server the main part of a request still goes through an upstream
|
||||||
|
# DNS server, which won't work for RBLs. So we really need a local recursive
|
||||||
|
# nameserver.
|
||||||
|
#
|
||||||
|
# We'll install `bind9`, which as packaged for Ubuntu, has DNSSEC enabled by default via "dnssec-validation auto".
|
||||||
|
# We'll have it be bound to 127.0.0.1 so that it does not interfere with
|
||||||
|
# the public, recursive nameserver `nsd` bound to the public ethernet interfaces.
|
||||||
#
|
#
|
||||||
# About the settings:
|
# About the settings:
|
||||||
#
|
#
|
||||||
# * RESOLVCONF=yes will have `bind9` take over /etc/resolv.conf to tell
|
|
||||||
# local services that DNS queries are handled on localhost.
|
|
||||||
# * Adding -4 to OPTIONS will have `bind9` not listen on IPv6 addresses
|
# * Adding -4 to OPTIONS will have `bind9` not listen on IPv6 addresses
|
||||||
# so that we're sure there's no conflict with nsd, our public domain
|
# so that we're sure there's no conflict with nsd, our public domain
|
||||||
# name server, on IPV6.
|
# name server, on IPV6.
|
||||||
# * The listen-on directive in named.conf.options restricts `bind9` to
|
# * The listen-on directive in named.conf.options restricts `bind9` to
|
||||||
# binding to the loopback interface instead of all interfaces.
|
# binding to the loopback interface instead of all interfaces.
|
||||||
apt_install bind9 resolvconf
|
# * The max-recursion-queries directive increases the maximum number of iterative queries.
|
||||||
tools/editconf.py /etc/default/bind9 \
|
# If more queries than specified are sent, bind9 returns SERVFAIL. After flushing the cache during system checks,
|
||||||
RESOLVCONF=yes \
|
# we ran into the limit thus we are increasing it from 75 (default value) to 100.
|
||||||
|
apt_install bind9
|
||||||
|
tools/editconf.py /etc/default/named \
|
||||||
"OPTIONS=\"-u bind -4\""
|
"OPTIONS=\"-u bind -4\""
|
||||||
if ! grep -q "listen-on " /etc/bind/named.conf.options; then
|
if ! grep -q "listen-on " /etc/bind/named.conf.options; then
|
||||||
# Add a listen-on directive if it doesn't exist inside the options block.
|
# Add a listen-on directive if it doesn't exist inside the options block.
|
||||||
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
|
sed -i "s/^}/\n\tlisten-on { 127.0.0.1; };\n}/" /etc/bind/named.conf.options
|
||||||
fi
|
fi
|
||||||
if [ -f /etc/resolvconf/resolv.conf.d/original ]; then
|
if ! grep -q "max-recursion-queries " /etc/bind/named.conf.options; then
|
||||||
echo "Archiving old resolv.conf (was /etc/resolvconf/resolv.conf.d/original, now /etc/resolvconf/resolv.conf.original)." #NODOC
|
# Add a max-recursion-queries directive if it doesn't exist inside the options block.
|
||||||
mv /etc/resolvconf/resolv.conf.d/original /etc/resolvconf/resolv.conf.original #NODOC
|
sed -i "s/^}/\n\tmax-recursion-queries 100;\n}/" /etc/bind/named.conf.options
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# First we'll disable systemd-resolved's management of resolv.conf and its stub server.
|
||||||
|
# Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means
|
||||||
|
# systemd-resolved will read it for DNS servers to use. Put in 127.0.0.1,
|
||||||
|
# which is where bind9 will be running. Obviously don't do this before
|
||||||
|
# installing bind9 or else apt won't be able to resolve a server to
|
||||||
|
# download bind9 from.
|
||||||
|
rm -f /etc/resolv.conf
|
||||||
|
tools/editconf.py /etc/systemd/resolved.conf DNSStubListener=no
|
||||||
|
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||||
|
|
||||||
# Restart the DNS services.
|
# Restart the DNS services.
|
||||||
|
|
||||||
restart_service bind9
|
restart_service bind9
|
||||||
restart_service resolvconf
|
systemctl restart systemd-resolved
|
||||||
|
|
||||||
# ### 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, ssh, etc.
|
||||||
cp conf/fail2ban/jail.local /etc/fail2ban/jail.local
|
rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore
|
||||||
cp conf/fail2ban/dovecotimap.conf /etc/fail2ban/filter.d/dovecotimap.conf
|
rm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config
|
||||||
|
cat conf/fail2ban/jails.conf \
|
||||||
|
| sed "s/PUBLIC_IPV6/$PUBLIC_IPV6/g" \
|
||||||
|
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
|
||||||
|
| sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||||
|
> /etc/fail2ban/jail.d/mailinabox.conf
|
||||||
|
cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/
|
||||||
|
|
||||||
|
# On first installation, the log files that the jails look at don't all exist.
|
||||||
|
# e.g., The roundcube error log isn't normally created until someone logs into
|
||||||
|
# Roundcube for the first time. This causes fail2ban to fail to start. Later
|
||||||
|
# scripts will ensure the files exist and then fail2ban is given another
|
||||||
|
# restart at the very end of setup.
|
||||||
restart_service fail2ban
|
restart_service fail2ban
|
||||||
|
|||||||
106
setup/web.sh
106
setup/web.sh
@@ -17,24 +17,83 @@ fi
|
|||||||
#
|
#
|
||||||
# Turn off nginx's default website.
|
# Turn off nginx's default website.
|
||||||
|
|
||||||
apt_install nginx php5-fpm
|
echo "Installing Nginx (web server)..."
|
||||||
|
|
||||||
|
apt_install nginx php${PHP_VER}-cli php${PHP_VER}-fpm idn2
|
||||||
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
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.
|
||||||
|
#
|
||||||
|
# Drop TLSv1.0, TLSv1.1, following the Mozilla "Intermediate" recommendations
|
||||||
|
# at https://ssl-config.mozilla.org/#server=nginx&server-version=1.17.0&config=intermediate&openssl-version=1.1.1.
|
||||||
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;" \
|
||||||
|
ssl_protocols="TLSv1.2 TLSv1.3;"
|
||||||
|
|
||||||
# Bump up PHP's max_children to support more concurrent connections
|
# Tell PHP not to expose its version number in the X-Powered-By header.
|
||||||
tools/editconf.py /etc/php5/fpm/pool.d/www.conf -c ';' \
|
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
|
||||||
pm.max_children=8
|
expose_php=Off
|
||||||
|
|
||||||
|
# Set PHPs default charset to UTF-8, since we use it. See #367.
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/php.ini -c ';' \
|
||||||
|
default_charset="UTF-8"
|
||||||
|
|
||||||
|
# Configure the path environment for php-fpm
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
|
||||||
|
env[PATH]=/usr/local/bin:/usr/bin:/bin \
|
||||||
|
|
||||||
|
# Configure php-fpm based on the amount of memory the machine has
|
||||||
|
# This is based on the nextcloud manual for performance tuning: https://docs.nextcloud.com/server/17/admin_manual/installation/server_tuning.html
|
||||||
|
# Some synchronisation issues can occur when many people access the site at once.
|
||||||
|
# The pm=ondemand setting is used for memory constrained machines < 2GB, this is copied over from PR: 1216
|
||||||
|
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk '{print $2}' || /bin/true)
|
||||||
|
if [ $TOTAL_PHYSICAL_MEM -lt 1000000 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
|
||||||
|
pm=ondemand \
|
||||||
|
pm.max_children=8 \
|
||||||
|
pm.start_servers=2 \
|
||||||
|
pm.min_spare_servers=1 \
|
||||||
|
pm.max_spare_servers=3
|
||||||
|
elif [ $TOTAL_PHYSICAL_MEM -lt 2000000 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
|
||||||
|
pm=ondemand \
|
||||||
|
pm.max_children=16 \
|
||||||
|
pm.start_servers=4 \
|
||||||
|
pm.min_spare_servers=1 \
|
||||||
|
pm.max_spare_servers=6
|
||||||
|
elif [ $TOTAL_PHYSICAL_MEM -lt 3000000 ]
|
||||||
|
then
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
|
||||||
|
pm=dynamic \
|
||||||
|
pm.max_children=60 \
|
||||||
|
pm.start_servers=6 \
|
||||||
|
pm.min_spare_servers=3 \
|
||||||
|
pm.max_spare_servers=9
|
||||||
|
else
|
||||||
|
tools/editconf.py /etc/php/$PHP_VER/fpm/pool.d/www.conf -c ';' \
|
||||||
|
pm=dynamic \
|
||||||
|
pm.max_children=120 \
|
||||||
|
pm.start_servers=12 \
|
||||||
|
pm.min_spare_servers=6 \
|
||||||
|
pm.max_spare_servers=18
|
||||||
|
fi
|
||||||
|
|
||||||
# Other nginx settings will be configured by the management service
|
# Other nginx settings will be configured by the management service
|
||||||
# since it depends on what domains we're serving, which we don't know
|
# since it depends on what domains we're serving, which we don't know
|
||||||
@@ -63,6 +122,21 @@ cat conf/mozilla-autoconfig.xml \
|
|||||||
> /var/lib/mailinabox/mozilla-autoconfig.xml
|
> /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
|
|
||||||
|
# Create a generic mta-sts.txt file which is exposed via the
|
||||||
|
# nginx configuration at /.well-known/mta-sts.txt
|
||||||
|
# more documentation is available on:
|
||||||
|
# https://www.uriports.com/blog/mta-sts-explained/
|
||||||
|
# default mode is "enforce". In /etc/mailinabox.conf change
|
||||||
|
# "MTA_STS_MODE=testing" which means "Messages will be delivered
|
||||||
|
# as though there was no failure but a report will be sent if
|
||||||
|
# TLS-RPT is configured" if you are not sure you want this yet. Or "none".
|
||||||
|
PUNY_PRIMARY_HOSTNAME=$(echo "$PRIMARY_HOSTNAME" | idn2)
|
||||||
|
cat conf/mta-sts.txt \
|
||||||
|
| sed "s/MODE/${MTA_STS_MODE}/" \
|
||||||
|
| sed "s/PRIMARY_HOSTNAME/$PUNY_PRIMARY_HOSTNAME/" \
|
||||||
|
> /var/lib/mailinabox/mta-sts.txt
|
||||||
|
chmod a+r /var/lib/mailinabox/mta-sts.txt
|
||||||
|
|
||||||
# make a default homepage
|
# make a default homepage
|
||||||
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
if [ -d $STORAGE_ROOT/www/static ]; then mv $STORAGE_ROOT/www/static $STORAGE_ROOT/www/default; fi # migration #NODOC
|
||||||
mkdir -p $STORAGE_ROOT/www/default
|
mkdir -p $STORAGE_ROOT/www/default
|
||||||
@@ -71,26 +145,10 @@ if [ ! -f $STORAGE_ROOT/www/default/index.html ]; then
|
|||||||
fi
|
fi
|
||||||
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
chown -R $STORAGE_USER $STORAGE_ROOT/www
|
||||||
|
|
||||||
# We previously installed a custom init script to start the PHP FastCGI daemon. #NODOC
|
|
||||||
# Remove it now that we're using php5-fpm. #NODOC
|
|
||||||
if [ -L /etc/init.d/php-fastcgi ]; then
|
|
||||||
echo "Removing /etc/init.d/php-fastcgi, php5-cgi..." #NODOC
|
|
||||||
rm -f /etc/init.d/php-fastcgi #NODOC
|
|
||||||
hide_output update-rc.d php-fastcgi remove #NODOC
|
|
||||||
apt-get -y purge php5-cgi #NODOC
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove obsoleted scripts. #NODOC
|
|
||||||
# exchange-autodiscover is now handled by Z-Push. #NODOC
|
|
||||||
for f in webfinger exchange-autodiscover; do #NODOC
|
|
||||||
rm -f /usr/local/bin/mailinabox-$f.php #NODOC
|
|
||||||
done #NODOC
|
|
||||||
|
|
||||||
# Start services.
|
# Start services.
|
||||||
restart_service nginx
|
restart_service nginx
|
||||||
restart_service php5-fpm
|
restart_service php$PHP_VER-fpm
|
||||||
|
|
||||||
# Open ports.
|
# Open ports.
|
||||||
ufw_allow http
|
ufw_allow http
|
||||||
ufw_allow https
|
ufw_allow https
|
||||||
|
|
||||||
|
|||||||
185
setup/webmail.sh
185
setup/webmail.sh
@@ -19,123 +19,170 @@ 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 \
|
php${PHP_VER}-cli php${PHP_VER}-sqlite3 php${PHP_VER}-intl php${PHP_VER}-common php${PHP_VER}-curl php${PHP_VER}-imap \
|
||||||
tinymce libjs-jquery libjs-jquery-mousewheel libmagic1
|
php${PHP_VER}-gd php${PHP_VER}-pspell php${PHP_VER}-mbstring libjs-jquery libjs-jquery-mousewheel libmagic1
|
||||||
|
|
||||||
# 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
|
|
||||||
# Now that we're beyond that, get rid of those debs before installing from source. #NODOC
|
|
||||||
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 plugins to track
|
||||||
# whether we have the latest version.
|
# whether we have the latest version of everything.
|
||||||
VERSION=1.1.1
|
# For the latest versions, see:
|
||||||
HASH=08222f382a8dd89bba7dbbad595f48443bec0aa2
|
# https://github.com/roundcube/roundcubemail/releases
|
||||||
VACATION_SIEVE_VERSION=91ea6f52216390073d1f5b70b5f6bea0bfaee7e5
|
# https://github.com/mfreiholz/persistent_login/commits/master
|
||||||
PERSISTENT_LOGIN_VERSION=9a0bc59493beb573d515f82aec443e2098365d11
|
# https://github.com/stremlau/html5_notifier/commits/master
|
||||||
UPDATE_KEY=$VERSION:$VACATION_SIEVE_VERSION:$PERSISTENT_LOGIN_VERSION
|
# https://github.com/mstilkerich/rcmcarddav/releases
|
||||||
|
# The easiest way to get the package hashes is to run this script and get the hash from
|
||||||
|
# the error message.
|
||||||
|
VERSION=1.6.0
|
||||||
|
HASH=fd84b4fac74419bb73e7a3bcae1978d5589c52de
|
||||||
|
PERSISTENT_LOGIN_VERSION=bde7b6840c7d91de627ea14e81cf4133cbb3c07a # version 5.2
|
||||||
|
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
|
||||||
|
CARDDAV_VERSION=4.4.3
|
||||||
|
CARDDAV_HASH=74f8ba7aee33e78beb9de07f7f44b81f6071b644
|
||||||
|
|
||||||
|
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
|
||||||
|
|
||||||
|
# paths that are often reused.
|
||||||
|
RCM_DIR=/usr/local/lib/roundcubemail
|
||||||
|
RCM_PLUGIN_DIR=${RCM_DIR}/plugins
|
||||||
|
RCM_CONFIG=${RCM_DIR}/config/config.inc.php
|
||||||
|
|
||||||
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
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
elif [[ "$UPDATE_KEY" != `cat /usr/local/lib/roundcubemail/version` ]]; then
|
elif [[ "$UPDATE_KEY" != $(cat /usr/local/lib/roundcubemail/version) ]]; then
|
||||||
# checks if the version is what we want
|
# checks if the version is what we want
|
||||||
needs_update=1 #NODOC
|
needs_update=1 #NODOC
|
||||||
fi
|
fi
|
||||||
if [ $needs_update == 1 ]; then
|
if [ $needs_update == 1 ]; then
|
||||||
|
# if upgrading from 1.3.x, clear the temp_dir
|
||||||
|
if [ -f /usr/local/lib/roundcubemail/version ]; then
|
||||||
|
if [ "$(cat /usr/local/lib/roundcubemail/version | cut -c1-3)" == '1.3' ]; then
|
||||||
|
find /var/tmp/roundcubemail/ -type f ! -name 'RCMTEMP*' -delete
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 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://github.com/roundcube/roundcubemail/releases/download/$VERSION/roundcubemail-$VERSION-complete.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/ $RCM_DIR
|
||||||
rm -f /tmp/roundcube.tgz
|
rm -f /tmp/roundcube.tgz
|
||||||
|
|
||||||
# install roundcube autoreply/vacation plugin
|
|
||||||
git_clone https://github.com/arodier/Roundcube-Plugins.git $VACATION_SIEVE_VERSION plugins/vacation_sieve /usr/local/lib/roundcubemail/plugins/vacation_sieve
|
|
||||||
|
|
||||||
# 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 '' ${RCM_PLUGIN_DIR}/persistent_login
|
||||||
|
|
||||||
|
# install roundcube html5_notifier plugin
|
||||||
|
git_clone https://github.com/kitist/html5_notifier.git $HTML5_NOTIFIER_VERSION '' ${RCM_PLUGIN_DIR}/html5_notifier
|
||||||
|
|
||||||
|
# download and verify the full release of the carddav plugin
|
||||||
|
wget_verify \
|
||||||
|
https://github.com/mstilkerich/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-v${CARDDAV_VERSION}.tar.gz \
|
||||||
|
$CARDDAV_HASH \
|
||||||
|
/tmp/carddav.tar.gz
|
||||||
|
|
||||||
|
# unzip and cleanup
|
||||||
|
tar -C ${RCM_PLUGIN_DIR} -zxf /tmp/carddav.tar.gz
|
||||||
|
rm -f /tmp/carddav.tar.gz
|
||||||
|
|
||||||
# record the version we've installed
|
# record the version we've installed
|
||||||
echo $UPDATE_KEY > /usr/local/lib/roundcubemail/version
|
echo $UPDATE_KEY > ${RCM_DIR}/version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring Roundcube
|
# ### Configuring Roundcube
|
||||||
|
|
||||||
# Generate a safe 24-character secret key of safe characters.
|
# Generate a secret key of PHP-string-safe characters appropriate
|
||||||
SECRET_KEY=$(dd if=/dev/random bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)
|
# for the cipher algorithm selected below.
|
||||||
|
SECRET_KEY=$(dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | sed s/=//g)
|
||||||
|
|
||||||
# Create a configuration file.
|
# Create a configuration file.
|
||||||
#
|
#
|
||||||
# For security, temp and log files are not stored in the default locations
|
# For security, temp and log files are not stored in the default locations
|
||||||
# which are inside the roundcube sources directory. We put them instead
|
# which are inside the roundcube sources directory. We put them instead
|
||||||
# in normal places.
|
# in normal places.
|
||||||
cat > /usr/local/lib/roundcubemail/config/config.inc.php <<EOF;
|
cat > $RCM_CONFIG <<EOF;
|
||||||
<?php
|
<?php
|
||||||
/*
|
/*
|
||||||
* Do not edit. Written by Mail-in-a-Box. Regenerated on updates.
|
* Do not edit. Written by Mail-in-a-Box. Regenerated on updates.
|
||||||
*/
|
*/
|
||||||
\$config = array();
|
\$config = array();
|
||||||
\$config['log_dir'] = '/var/log/roundcubemail/';
|
\$config['log_dir'] = '/var/log/roundcubemail/';
|
||||||
\$config['temp_dir'] = '/tmp/roundcubemail/';
|
\$config['temp_dir'] = '/var/tmp/roundcubemail/';
|
||||||
\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';
|
\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';
|
||||||
\$config['default_host'] = 'ssl://localhost';
|
\$config['imap_host'] = 'ssl://localhost:993';
|
||||||
\$config['default_port'] = 993;
|
\$config['imap_conn_options'] = array(
|
||||||
|
'ssl' => array(
|
||||||
|
'verify_peer' => false,
|
||||||
|
'verify_peer_name' => false,
|
||||||
|
),
|
||||||
|
);
|
||||||
\$config['imap_timeout'] = 15;
|
\$config['imap_timeout'] = 15;
|
||||||
\$config['smtp_server'] = 'tls://localhost';
|
\$config['smtp_host'] = 'tls://127.0.0.1';
|
||||||
\$config['smtp_port'] = 587;
|
\$config['smtp_conn_options'] = array(
|
||||||
\$config['smtp_user'] = '%u';
|
'ssl' => array(
|
||||||
\$config['smtp_pass'] = '%p';
|
'verify_peer' => false,
|
||||||
|
'verify_peer_name' => false,
|
||||||
|
),
|
||||||
|
);
|
||||||
\$config['support_url'] = 'https://mailinabox.email/';
|
\$config['support_url'] = 'https://mailinabox.email/';
|
||||||
\$config['product_name'] = 'Mail-in-a-Box/Roundcube Webmail';
|
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
||||||
\$config['des_key'] = '$SECRET_KEY';
|
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
|
||||||
\$config['plugins'] = array('archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'vacation_sieve', 'persistent_login');
|
\$config['des_key'] = '$SECRET_KEY'; # 37 characters -> ~256 bits for AES-256, see above
|
||||||
\$config['skin'] = 'classic';
|
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav');
|
||||||
|
\$config['skin'] = 'elastic';
|
||||||
\$config['login_autocomplete'] = 2;
|
\$config['login_autocomplete'] = 2;
|
||||||
|
\$config['login_username_filter'] = 'email';
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
\$config['junk_mbox'] = 'Spam';
|
\$config['junk_mbox'] = 'Spam';
|
||||||
|
/* ensure roudcube session id's aren't leaked to other parts of the server */
|
||||||
|
\$config['session_path'] = '/mail/';
|
||||||
|
/* prevent CSRF, requires php 7.3+ */
|
||||||
|
\$config['session_samesite'] = 'Strict';
|
||||||
?>
|
?>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Configure vaction_sieve.
|
# Configure CardDav
|
||||||
cat > /usr/local/lib/roundcubemail/plugins/vacation_sieve/config.inc.php <<EOF;
|
cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
|
||||||
<?php
|
<?php
|
||||||
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
|
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
|
||||||
\$rcmail_config['vacation_sieve'] = array(
|
\$prefs['_GLOBAL']['hide_preferences'] = true;
|
||||||
'date_format' => 'd/m/Y',
|
\$prefs['_GLOBAL']['suppress_version_warning'] = true;
|
||||||
'working_hours' => array(8,18),
|
\$prefs['ownCloud'] = array(
|
||||||
'msg_format' => 'text',
|
'name' => 'ownCloud',
|
||||||
'logon_transform' => array('#([a-z])[a-z]+(\.|\s)([a-z])#i', '\$1\$3'),
|
'username' => '%u', // login username
|
||||||
'transfer' => array(
|
'password' => '%p', // login password
|
||||||
'mode' => 'managesieve',
|
'url' => 'https://${PRIMARY_HOSTNAME}/cloud/remote.php/dav/addressbooks/users/%u/contacts/',
|
||||||
'ms_activate_script' => true,
|
'active' => true,
|
||||||
'host' => 'localhost',
|
'readonly' => false,
|
||||||
'port' => '4190',
|
'refresh_time' => '02:00:00',
|
||||||
'usetls' => false,
|
'fixed' => array('username','password'),
|
||||||
'path' => 'vacation',
|
'preemptive_auth' => '1',
|
||||||
)
|
'hide' => false,
|
||||||
);
|
);
|
||||||
|
?>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create writable directories.
|
# Create writable directories.
|
||||||
mkdir -p /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
chown -R www-data.www-data /var/log/roundcubemail /tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORAGE_ROOT/mail/roundcube
|
||||||
|
|
||||||
|
# Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.
|
||||||
|
sudo -u www-data touch /var/log/roundcubemail/errors.log
|
||||||
|
|
||||||
# Password changing plugin settings
|
# Password changing plugin settings
|
||||||
# The config comes empty by default, so we need the settings
|
# The config comes empty by default, so we need the settings
|
||||||
# we're not planning to change in config.inc.dist...
|
# we're not planning to change in config.inc.dist...
|
||||||
cp /usr/local/lib/roundcubemail/plugins/password/config.inc.php.dist \
|
cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
|
||||||
/usr/local/lib/roundcubemail/plugins/password/config.inc.php
|
${RCM_PLUGIN_DIR}/password/config.inc.php
|
||||||
|
|
||||||
tools/editconf.py /usr/local/lib/roundcubemail/plugins/password/config.inc.php \
|
tools/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php \
|
||||||
"\$config['password_minimum_length']=6;" \
|
"\$config['password_minimum_length']=8;" \
|
||||||
"\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
|
"\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
|
||||||
"\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" \
|
"\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" \
|
||||||
"\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \
|
"\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \
|
||||||
@@ -149,9 +196,19 @@ usermod -a -G dovecot www-data
|
|||||||
# could use dovecot instead of www-data, but not sure it matters
|
# could use dovecot instead of www-data, but not sure it matters
|
||||||
chown root.www-data $STORAGE_ROOT/mail
|
chown root.www-data $STORAGE_ROOT/mail
|
||||||
chmod 775 $STORAGE_ROOT/mail
|
chmod 775 $STORAGE_ROOT/mail
|
||||||
chown root.www-data $STORAGE_ROOT/mail/users.sqlite
|
chown root.www-data $STORAGE_ROOT/mail/users.sqlite
|
||||||
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
chmod 664 $STORAGE_ROOT/mail/users.sqlite
|
||||||
|
|
||||||
|
# Fix Carddav permissions:
|
||||||
|
chown -f -R root.www-data ${RCM_PLUGIN_DIR}/carddav
|
||||||
|
# root.www-data need all permissions, others only read
|
||||||
|
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
|
||||||
|
|
||||||
|
# Run Roundcube database migration script (database is created if it does not exist)
|
||||||
|
php$PHP_VER ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
|
||||||
|
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
||||||
|
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
||||||
|
|
||||||
# Enable PHP modules.
|
# Enable PHP modules.
|
||||||
php5enmod mcrypt
|
phpenmod -v $PHP_VER imap
|
||||||
restart_service php5-fpm
|
restart_service php$PHP_VER-fpm
|
||||||
|
|||||||
@@ -15,33 +15,43 @@ 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${PHP_VER}-soap php${PHP_VER}-imap libawl-php php$PHP_VER-xml
|
||||||
|
|
||||||
php5enmod imap
|
phpenmod -v $PHP_VER imap
|
||||||
|
|
||||||
# Copy Z-Push into place.
|
# Copy Z-Push into place.
|
||||||
TARGETHASH=d0cd5a47c53afac5c3b287006dc8a48a1c4ffcd5
|
VERSION=2.6.2
|
||||||
|
TARGETHASH=f0e8091a8030e5b851f5ba1f9f0e1a05b8762d80
|
||||||
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
|
||||||
elif [[ $TARGETHASH != `cat /usr/local/lib/z-push/version` ]]; then
|
elif [[ $VERSION != $(cat /usr/local/lib/z-push/version) ]]; then
|
||||||
# checks if the version
|
# checks if the version
|
||||||
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\)...
|
# Download
|
||||||
git_clone https://github.com/fmbiete/Z-Push-contrib $TARGETHASH '' /usr/local/lib/z-push
|
wget_verify "https://github.com/Z-Hub/Z-Push/archive/refs/tags/$VERSION.zip" $TARGETHASH /tmp/z-push.zip
|
||||||
|
|
||||||
|
# Extract into place.
|
||||||
|
rm -rf /usr/local/lib/z-push /tmp/z-push
|
||||||
|
unzip -q /tmp/z-push.zip -d /tmp/z-push
|
||||||
|
mv /tmp/z-push/*/src /usr/local/lib/z-push
|
||||||
|
rm -rf /tmp/z-push.zip /tmp/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
|
echo $VERSION > /usr/local/lib/z-push/version
|
||||||
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
|
|
||||||
echo $TARGETHASH > /usr/local/lib/z-push/version
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Configure default config.
|
# Configure default config.
|
||||||
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
|
||||||
@@ -50,6 +60,7 @@ cp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config
|
|||||||
# Configure IMAP
|
# Configure IMAP
|
||||||
rm -f /usr/local/lib/z-push/backend/imap/config.php
|
rm -f /usr/local/lib/z-push/backend/imap/config.php
|
||||||
cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php
|
cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php
|
||||||
|
sed -i "s%STORAGE_ROOT%$STORAGE_ROOT%" /usr/local/lib/z-push/backend/imap/config.php
|
||||||
|
|
||||||
# Configure CardDav
|
# Configure CardDav
|
||||||
rm -f /usr/local/lib/z-push/backend/carddav/config.php
|
rm -f /usr/local/lib/z-push/backend/carddav/config.php
|
||||||
@@ -63,6 +74,7 @@ cp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php
|
|||||||
rm -f /usr/local/lib/z-push/autodiscover/config.php
|
rm -f /usr/local/lib/z-push/autodiscover/config.php
|
||||||
cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
|
cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
|
||||||
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php
|
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /usr/local/lib/z-push/autodiscover/config.php
|
||||||
|
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/autodiscover/config.php
|
||||||
|
|
||||||
# Some directories it will use.
|
# Some directories it will use.
|
||||||
|
|
||||||
@@ -73,6 +85,23 @@ 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 php$PHP_VER-fpm
|
||||||
|
|
||||||
|
# Fix states after upgrade
|
||||||
|
|
||||||
|
hide_output php$PHP_VER /usr/local/lib/z-push/z-push-admin.php -a fixstates
|
||||||
|
|||||||
244
tests/fail2ban.py
Normal file
244
tests/fail2ban.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Test that a box's fail2ban setting are working
|
||||||
|
# correctly by attempting a bunch of failed logins.
|
||||||
|
#
|
||||||
|
# Specify a SSH login command (which we use to reset
|
||||||
|
# fail2ban after each test) and the hostname to
|
||||||
|
# try to log in to.
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
import sys, os, time, functools
|
||||||
|
|
||||||
|
# parse command line
|
||||||
|
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("Usage: tests/fail2ban.py \"ssh user@hostname\" hostname owncloud_user")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ssh_command, hostname, owncloud_user = sys.argv[1:4]
|
||||||
|
|
||||||
|
# define some test types
|
||||||
|
|
||||||
|
import socket
|
||||||
|
socket.setdefaulttimeout(10)
|
||||||
|
|
||||||
|
class IsBlocked(Exception):
|
||||||
|
"""Tests raise this exception when it appears that a fail2ban
|
||||||
|
jail is in effect, i.e. on a connection refused error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def smtp_test():
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = smtplib.SMTP(hostname, 587)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
# looks like fail2ban worked
|
||||||
|
raise IsBlocked()
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo_or_helo_if_needed()
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.login("fakeuser", "fakepassword")
|
||||||
|
raise Exception("authentication didn't fail")
|
||||||
|
except smtplib.SMTPAuthenticationError:
|
||||||
|
# athentication should fail
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.quit()
|
||||||
|
except:
|
||||||
|
# ignore errors here
|
||||||
|
pass
|
||||||
|
|
||||||
|
def imap_test():
|
||||||
|
import imaplib
|
||||||
|
|
||||||
|
try:
|
||||||
|
M = imaplib.IMAP4_SSL(hostname)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
# looks like fail2ban worked
|
||||||
|
raise IsBlocked()
|
||||||
|
|
||||||
|
try:
|
||||||
|
M.login("fakeuser", "fakepassword")
|
||||||
|
raise Exception("authentication didn't fail")
|
||||||
|
except imaplib.IMAP4.error:
|
||||||
|
# authentication should fail
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
M.logout() # shuts down connection, has nothing to do with login()
|
||||||
|
|
||||||
|
|
||||||
|
def pop_test():
|
||||||
|
import poplib
|
||||||
|
try:
|
||||||
|
M = poplib.POP3_SSL(hostname)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
# looks like fail2ban worked
|
||||||
|
raise IsBlocked()
|
||||||
|
try:
|
||||||
|
M.user('fakeuser')
|
||||||
|
try:
|
||||||
|
M.pass_('fakepassword')
|
||||||
|
except poplib.error_proto as e:
|
||||||
|
# Authentication should fail.
|
||||||
|
M = None # don't .quit()
|
||||||
|
return
|
||||||
|
M.list()
|
||||||
|
raise Exception("authentication didn't fail")
|
||||||
|
finally:
|
||||||
|
if M:
|
||||||
|
M.quit()
|
||||||
|
|
||||||
|
def managesieve_test():
|
||||||
|
# We don't have a Python sieve client, so we'll
|
||||||
|
# just run the IMAP client and see what happens.
|
||||||
|
import imaplib
|
||||||
|
|
||||||
|
try:
|
||||||
|
M = imaplib.IMAP4(hostname, 4190)
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
# looks like fail2ban worked
|
||||||
|
raise IsBlocked()
|
||||||
|
|
||||||
|
try:
|
||||||
|
M.login("fakeuser", "fakepassword")
|
||||||
|
raise Exception("authentication didn't fail")
|
||||||
|
except imaplib.IMAP4.error:
|
||||||
|
# authentication should fail
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
M.logout() # shuts down connection, has nothing to do with login()
|
||||||
|
|
||||||
|
def http_test(url, expected_status, postdata=None, qsargs=None, auth=None):
|
||||||
|
import urllib.parse
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
|
# form request
|
||||||
|
url = urllib.parse.urljoin("https://" + hostname, url)
|
||||||
|
if qsargs: url += "?" + urllib.parse.urlencode(qsargs)
|
||||||
|
urlopen = requests.get if not postdata else requests.post
|
||||||
|
|
||||||
|
try:
|
||||||
|
# issue request
|
||||||
|
r = urlopen(
|
||||||
|
url,
|
||||||
|
auth=HTTPBasicAuth(*auth) if auth else None,
|
||||||
|
data=postdata,
|
||||||
|
headers={'User-Agent': 'Mail-in-a-Box fail2ban tester'},
|
||||||
|
timeout=8,
|
||||||
|
verify=False) # don't bother with HTTPS validation, it may not be configured yet
|
||||||
|
except requests.exceptions.ConnectTimeout as e:
|
||||||
|
raise IsBlocked()
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
if "Connection refused" in str(e):
|
||||||
|
raise IsBlocked()
|
||||||
|
raise # some other unexpected condition
|
||||||
|
|
||||||
|
# return response status code
|
||||||
|
if r.status_code != expected_status:
|
||||||
|
r.raise_for_status() # anything but 200
|
||||||
|
raise IOError("Got unexpected status code %s." % r.status_code)
|
||||||
|
|
||||||
|
# define how to run a test
|
||||||
|
|
||||||
|
def restart_fail2ban_service(final=False):
|
||||||
|
# Log in over SSH to restart fail2ban.
|
||||||
|
command = "sudo fail2ban-client reload"
|
||||||
|
if not final:
|
||||||
|
# Stop recidive jails during testing.
|
||||||
|
command += " && sudo fail2ban-client stop recidive"
|
||||||
|
os.system("%s \"%s\"" % (ssh_command, command))
|
||||||
|
|
||||||
|
def testfunc_runner(i, testfunc, *args):
|
||||||
|
print(i+1, end=" ", flush=True)
|
||||||
|
testfunc(*args)
|
||||||
|
|
||||||
|
def run_test(testfunc, args, count, within_seconds, parallel):
|
||||||
|
# Run testfunc count times in within_seconds seconds (and actually
|
||||||
|
# within a little less time so we're sure we're under the limit).
|
||||||
|
#
|
||||||
|
# Because some services are slow, like IMAP, we can't necessarily
|
||||||
|
# run testfunc sequentially and still get to count requests within
|
||||||
|
# the required time. So we split the requests across threads.
|
||||||
|
|
||||||
|
import requests.exceptions
|
||||||
|
from multiprocessing import Pool
|
||||||
|
|
||||||
|
restart_fail2ban_service()
|
||||||
|
|
||||||
|
# Log.
|
||||||
|
print(testfunc.__name__, " ".join(str(a) for a in args), "...")
|
||||||
|
|
||||||
|
# Record the start time so we can know how to evenly space our
|
||||||
|
# calls to testfunc.
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
with Pool(parallel) as p:
|
||||||
|
# Distribute the requests across the pool.
|
||||||
|
asyncresults = []
|
||||||
|
for i in range(count):
|
||||||
|
ar = p.apply_async(testfunc_runner, [i, testfunc] + list(args))
|
||||||
|
asyncresults.append(ar)
|
||||||
|
|
||||||
|
# Wait for all runs to finish.
|
||||||
|
p.close()
|
||||||
|
p.join()
|
||||||
|
|
||||||
|
# Check for errors.
|
||||||
|
for ar in asyncresults:
|
||||||
|
try:
|
||||||
|
ar.get()
|
||||||
|
except IsBlocked:
|
||||||
|
print("Test machine prematurely blocked!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Did we make enough requests within the limit?
|
||||||
|
if (time.time()-start_time) > within_seconds:
|
||||||
|
raise Exception("Test failed to make %s requests in %d seconds." % (count, within_seconds))
|
||||||
|
|
||||||
|
# Wait a moment for the block to be put into place.
|
||||||
|
time.sleep(4)
|
||||||
|
|
||||||
|
# The next call should fail.
|
||||||
|
print("*", end=" ", flush=True)
|
||||||
|
try:
|
||||||
|
testfunc(*args)
|
||||||
|
except IsBlocked:
|
||||||
|
# Success -- this one is supposed to be refused.
|
||||||
|
print("blocked [OK]")
|
||||||
|
return True # OK
|
||||||
|
|
||||||
|
print("not blocked!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# run tests
|
||||||
|
|
||||||
|
# SMTP bans at 10 even though we say 20 in the config because we get
|
||||||
|
# doubled-up warnings in the logs, we'll let that be for now
|
||||||
|
run_test(smtp_test, [], 10, 30, 8)
|
||||||
|
|
||||||
|
# IMAP
|
||||||
|
run_test(imap_test, [], 20, 30, 4)
|
||||||
|
|
||||||
|
# POP
|
||||||
|
run_test(pop_test, [], 20, 30, 4)
|
||||||
|
|
||||||
|
# Managesieve
|
||||||
|
run_test(managesieve_test, [], 20, 30, 4)
|
||||||
|
|
||||||
|
# Mail-in-a-Box control panel
|
||||||
|
run_test(http_test, ["/admin/login", 200], 20, 30, 1)
|
||||||
|
|
||||||
|
# Munin via the Mail-in-a-Box control panel
|
||||||
|
run_test(http_test, ["/admin/munin/", 401], 20, 30, 1)
|
||||||
|
|
||||||
|
# ownCloud
|
||||||
|
run_test(http_test, ["/cloud/remote.php/webdav", 401, None, None, [owncloud_user, "aa"]], 20, 120, 1)
|
||||||
|
|
||||||
|
# restart fail2ban so that this client machine is no longer blocked
|
||||||
|
restart_fail2ban_service(final=True)
|
||||||
@@ -30,7 +30,7 @@ def test(server, description):
|
|||||||
(hostname, "TXT", "\"v=spf1 mx -all\""),
|
(hostname, "TXT", "\"v=spf1 mx -all\""),
|
||||||
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""),
|
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""),
|
||||||
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
|
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
|
||||||
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine\""),
|
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine;\""),
|
||||||
]
|
]
|
||||||
return test2(tests, server, description)
|
return test2(tests, server, description)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def test2(tests, server, description):
|
|||||||
for qname, rtype, expected_answer in tests:
|
for qname, rtype, expected_answer in tests:
|
||||||
# do the query and format the result as a string
|
# do the query and format the result as a string
|
||||||
try:
|
try:
|
||||||
response = dns.resolver.query(qname, rtype)
|
response = dns.resolver.resolve(qname, rtype)
|
||||||
except dns.resolver.NoNameservers:
|
except dns.resolver.NoNameservers:
|
||||||
# host did not have an answer for this query
|
# host did not have an answer for this query
|
||||||
print("Could not connect to %s for DNS query." % server)
|
print("Could not connect to %s for DNS query." % server)
|
||||||
|
|||||||
@@ -41,15 +41,14 @@ This is a test message. It should be automatically deleted by the test script.""
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Connect to the server on the SMTP submission TLS port.
|
# Connect to the server on the SMTP submission TLS port.
|
||||||
server = smtplib.SMTP(host, 587)
|
server = smtplib.SMTP_SSL(host)
|
||||||
#server.set_debuglevel(1)
|
#server.set_debuglevel(1)
|
||||||
server.starttls()
|
|
||||||
|
|
||||||
# Verify that the EHLO name matches the server's reverse DNS.
|
# Verify that the EHLO name matches the server's reverse DNS.
|
||||||
ipaddr = socket.gethostbyname(host) # IPv4 only!
|
ipaddr = socket.gethostbyname(host) # IPv4 only!
|
||||||
reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
|
reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
|
||||||
try:
|
try:
|
||||||
reverse_dns = dns.resolver.query(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
|
reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
|
||||||
except dns.resolver.NXDOMAIN:
|
except dns.resolver.NXDOMAIN:
|
||||||
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
|
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
|
||||||
reverse_dns = None
|
reverse_dns = None
|
||||||
|
|||||||
10
tests/tls.py
10
tests/tls.py
@@ -17,7 +17,7 @@
|
|||||||
# through some other host you can ssh into (maybe the box
|
# through some other host you can ssh into (maybe the box
|
||||||
# itself?):
|
# itself?):
|
||||||
#
|
#
|
||||||
# python3 --proxy user@ssh_host yourservername
|
# python3 tls.py --proxy user@ssh_host yourservername
|
||||||
#
|
#
|
||||||
# (This will launch "ssh -N -L10023:yourservername:testport user@ssh_host"
|
# (This will launch "ssh -N -L10023:yourservername:testport user@ssh_host"
|
||||||
# to create a tunnel.)
|
# to create a tunnel.)
|
||||||
@@ -61,9 +61,9 @@ common_opts = ["--sslv2", "--sslv3", "--tlsv1", "--tlsv1_1", "--tlsv1_2", "--ren
|
|||||||
# Assumes TLSv1, TLSv1.1, TLSv1.2.
|
# Assumes TLSv1, TLSv1.1, TLSv1.2.
|
||||||
#
|
#
|
||||||
# The 'old' ciphers bring compatibility back to Win XP IE 6.
|
# The 'old' ciphers bring compatibility back to Win XP IE 6.
|
||||||
MOZILLA_CIPHERS_MODERN = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK"
|
MOZILLA_CIPHERS_MODERN = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"
|
||||||
MOZILLA_CIPHERS_INTERMEDIATE = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA"
|
MOZILLA_CIPHERS_INTERMEDIATE = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS"
|
||||||
MOZILLA_CIPHERS_OLD = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA"
|
MOZILLA_CIPHERS_OLD = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:SEED:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!RSAPSK:!aDH:!aECDH:!EDH-DSS-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA:!SRP"
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ def sslyze(opts, port, ok_ciphers):
|
|||||||
proxy_proc.terminate()
|
proxy_proc.terminate()
|
||||||
try:
|
try:
|
||||||
proxy_proc.wait(5)
|
proxy_proc.wait(5)
|
||||||
except TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proxy_proc.kill()
|
proxy_proc.kill()
|
||||||
|
|
||||||
# Get a list of OpenSSL cipher names.
|
# Get a list of OpenSSL cipher names.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user