Compare commits
1961 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4d68d1f08e | ||
![]() |
197aa5fdaa | ||
![]() |
f3994d8b89 | ||
![]() |
222ae0d960 | ||
![]() |
701e214929 | ||
![]() |
1d857c7f00 | ||
![]() |
d5f327755c | ||
![]() |
9193dbe56b | ||
![]() |
289f311c4f | ||
![]() |
465bd4dc57 | ||
![]() |
f9815662c1 | ||
![]() |
74d88787c8 | ||
![]() |
9215b011f8 | ||
![]() |
748adfebd9 | ||
![]() |
1f778bc94c | ||
![]() |
6abed54756 | ||
![]() |
b5bc886561 | ||
![]() |
1af976a093 | ||
![]() |
3451dadde5 | ||
![]() |
b961a2b74a | ||
![]() |
774ab4f1e8 | ||
![]() |
26714cee49 | ||
![]() |
1efa0ed408 | ||
![]() |
97683aa9ba | ||
![]() |
be678a126e | ||
![]() |
662639d905 | ||
![]() |
03b36c958c | ||
![]() |
3314c4f7de | ||
![]() |
1f60236985 | ||
![]() |
32c68874c5 | ||
![]() |
ed7859eab8 | ||
![]() |
003ff10d07 | ||
![]() |
8b4a49597e | ||
![]() |
01cf61deab | ||
![]() |
619d3781f5 | ||
![]() |
025494ed9c | ||
![]() |
824762d18d | ||
![]() |
9243ea9374 | ||
![]() |
132cfe6e32 | ||
![]() |
bc72c58ae5 | ||
![]() |
866c185a08 | ||
![]() |
97a84bc2b8 | ||
![]() |
6c1a98dc4b | ||
![]() |
a03707e5f8 | ||
![]() |
913a4a4585 | ||
![]() |
c30a18a30b | ||
![]() |
286a4bd9e7 | ||
![]() |
ddf8e857fd | ||
![]() |
4d5ff0210b | ||
![]() |
89cd9fb611 | ||
![]() |
22a6270657 | ||
![]() |
0a970f4bb2 | ||
![]() |
9b111e2493 | ||
![]() |
b8feb77ef4 | ||
![]() |
3c44604316 | ||
![]() |
1e1a054686 | ||
![]() |
d584a41e60 | ||
![]() |
56074ae035 | ||
![]() |
30631b0fc5 | ||
![]() |
84da4e6000 | ||
![]() |
58ded74181 | ||
![]() |
3fd2e3efa9 | ||
![]() |
7cda439c80 | ||
![]() |
9700b59cf8 | ||
![]() |
e7ee4bc5b4 | ||
![]() |
dc787b67b4 | ||
![]() |
09cf3b3755 | ||
![]() |
fc04c8d723 | ||
![]() |
2aa4229e0b | ||
![]() |
e1be9a5eeb | ||
![]() |
91fc74b408 | ||
![]() |
d7244ed920 | ||
![]() |
e0c0b5053c | ||
![]() |
268b31685d | ||
![]() |
ab71abbc7c | ||
![]() |
87e6df9e28 | ||
![]() |
558f2db31f | ||
![]() |
c23dd701f0 | ||
![]() |
0a7b9d5089 | ||
![]() |
1eddf9a220 | ||
![]() |
78d71498fa | ||
![]() |
b41a0ad80e | ||
![]() |
78569e9a88 | ||
![]() |
8cb360fe36 | ||
![]() |
f534a530d4 | ||
![]() |
2abcafd670 | ||
![]() |
3c3d62ac27 | ||
![]() |
d829d74048 | ||
![]() |
2aca421415 | ||
![]() |
99474b348f | ||
![]() |
8bebaf6a48 | ||
![]() |
a0d44f3d05 | ||
![]() |
55dbd9d93c | ||
![]() |
3503b5a1f0 | ||
![]() |
ddcc929a13 | ||
![]() |
9004bb6e8e | ||
![]() |
69d8fdef99 | ||
![]() |
eeee712cf3 | ||
![]() |
8f42d97b54 | ||
![]() |
04f4910b51 | ||
![]() |
e5ffae7791 | ||
![]() |
7df762b46b | ||
![]() |
c15c1d84d7 | ||
![]() |
f605e55997 | ||
![]() |
6e40c69cb5 | ||
![]() |
c0e54f87d7 | ||
![]() |
aa0994b1d7 | ||
![]() |
1fcc1a489b | ||
![]() |
9defa6c54d | ||
![]() |
53c0c3aec8 | ||
![]() |
6bcf1169bd | ||
![]() |
fece9355cf | ||
![]() |
83a109908e | ||
![]() |
e4cbbccfde | ||
![]() |
a56bb235d7 | ||
![]() |
6602d2c774 | ||
![]() |
2cee801002 | ||
![]() |
d8d742d74d | ||
![]() |
d5069fc81e | ||
![]() |
4da6f66b94 | ||
![]() |
307c678066 | ||
![]() |
c111a8920c | ||
![]() |
115fee4212 | ||
![]() |
51fa2a6fd9 | ||
![]() |
deaecbe0d2 | ||
![]() |
56b0fc02da | ||
![]() |
a8f5a78518 | ||
![]() |
14534fd0cb | ||
![]() |
a0abc9c0d2 | ||
![]() |
6b4ee8f576 | ||
![]() |
ed886ee934 | ||
![]() |
16b521c2f0 | ||
![]() |
6436bad483 | ||
![]() |
271b03a346 | ||
![]() |
37a97a394f | ||
![]() |
152d7c39bc | ||
![]() |
b622df6b03 | ||
![]() |
cd9bd51ed0 | ||
![]() |
3a7de051ee | ||
![]() |
f11cb04a72 | ||
![]() |
cb564a130a | ||
![]() |
d1d6318862 | ||
![]() |
34b7a02f4f | ||
![]() |
a312acc3bc | ||
![]() |
aab1ec691c | ||
![]() |
520caf6557 | ||
![]() |
c92fd02262 | ||
![]() |
a85c429a85 | ||
![]() |
50a5cb90bc | ||
![]() |
aac878dce5 | ||
![]() |
4812ffd6b3 | ||
![]() |
2e3b113756 | ||
![]() |
faacc1d674 | ||
![]() |
5e66af63f2 | ||
![]() |
972c413b19 | ||
![]() |
58b0323b36 | ||
![]() |
646f971d8b | ||
![]() |
86067be142 | ||
![]() |
c67ff241c4 | ||
![]() |
7b4cd443bf | ||
![]() |
34017548d5 | ||
![]() |
109267c7b0 | ||
![]() |
cb7cbe419e | ||
![]() |
dab920042e | ||
![]() |
402df4515b | ||
![]() |
7caa4f5c4e | ||
![]() |
856260bf29 | ||
![]() |
a6c8bbed20 | ||
![]() |
f6d7d5689c | ||
![]() |
b757e4c339 | ||
![]() |
8a0805dae8 | ||
![]() |
65861c68b7 | ||
![]() |
71a7a3e201 | ||
![]() |
1c3bca53bb | ||
![]() |
b643cb3478 | ||
![]() |
113b7bd827 | ||
![]() |
3e19f85fad | ||
![]() |
11e84d0d40 | ||
![]() |
79966e36e3 | ||
![]() |
66b15d42a5 | ||
![]() |
df46e1311b | ||
![]() |
353084ce67 | ||
![]() |
04e1c0647e | ||
![]() |
91079ab934 | ||
![]() |
4c4cce836e | ||
![]() |
e5909a6287 | ||
![]() |
26932ecb10 | ||
![]() |
e884c4774f | ||
![]() |
53ec0f39cb | ||
![]() |
700188c443 | ||
![]() |
09f635c1f9 | ||
![]() |
db1330795f | ||
![]() |
ba80d9e72d | ||
![]() |
aca49ea674 | ||
![]() |
d8c77527bd | ||
![]() |
d557885aab | ||
![]() |
a71a58e816 | ||
![]() |
67b5711c68 | ||
![]() |
20ccda8710 | ||
![]() |
0ba841c7b6 | ||
![]() |
daad122236 | ||
![]() |
2c975e43cc | ||
![]() |
a4c5f14237 | ||
![]() |
8b2f7f2e4c | ||
![]() |
aae5fb4b74 | ||
![]() |
fd15b28b3d | ||
![]() |
21ad26e452 | ||
![]() |
e98d830f7f | ||
![]() |
c171b6491a | ||
![]() |
9ab5733af4 | ||
![]() |
8cb60ecad7 | ||
![]() |
63081c647a | ||
![]() |
b2854c3afb | ||
![]() |
dc7bae04c7 | ||
![]() |
b53add2798 | ||
![]() |
e1aabc4504 | ||
![]() |
f55c0a68b6 | ||
![]() |
c125f462e1 | ||
![]() |
3018cdd698 | ||
![]() |
edfb1cf623 | ||
![]() |
d0b5794588 | ||
![]() |
afe7123f70 | ||
![]() |
4cb46ea465 | ||
![]() |
9f9eb920b3 | ||
![]() |
217b0b51ff | ||
![]() |
f382a55a0a | ||
![]() |
483817440e | ||
![]() |
35fa3fe891 | ||
![]() |
d510c8ae2a | ||
![]() |
e283a12047 | ||
![]() |
e421addf1c | ||
![]() |
354a774989 | ||
![]() |
aaa81ec879 | ||
![]() |
dbd6dae5ce | ||
![]() |
d4c5872547 | ||
![]() |
3701e05d92 | ||
![]() |
bc4ae51c2d | ||
![]() |
12aaebfc54 | ||
![]() |
49813534bd | ||
![]() |
16e81e1439 | ||
![]() |
b7b67e31b7 | ||
![]() |
2e7f2835e7 | ||
![]() |
8a5f9f464a | ||
![]() |
69fc2fdd3a | ||
![]() |
9b07d86bf7 | ||
![]() |
ae3feebd80 | ||
![]() |
2c295bcafd | ||
![]() |
9f13ee6d55 | ||
![]() |
40babe3e03 | ||
![]() |
b9bdf50628 | ||
![]() |
8cda58fb22 | ||
![]() |
178c587654 | ||
![]() |
34569d24a9 | ||
![]() |
a3851bd6be | ||
![]() |
f4df9a5e1a | ||
![]() |
c2f627ea82 | ||
![]() |
5e20a00e25 | ||
![]() |
103ff39500 | ||
![]() |
aa41702825 | ||
![]() |
a2193289e2 | ||
![]() |
aa0da22614 | ||
![]() |
071002b755 | ||
![]() |
127629611b | ||
![]() |
c260e164eb | ||
![]() |
c25a935f2b | ||
![]() |
013f6f2ed1 | ||
![]() |
0f5a5bfbb1 | ||
![]() |
200aefee00 | ||
![]() |
353645f8db | ||
![]() |
2ac999fca4 | ||
![]() |
e8aee7483e | ||
![]() |
676f3aaf24 | ||
![]() |
531e67bcdb | ||
![]() |
d3cf70db27 | ||
![]() |
13b1b31fb1 | ||
![]() |
2827d33597 | ||
![]() |
046dac6f8f | ||
![]() |
0085dfbbe2 | ||
![]() |
b570cca23c | ||
![]() |
c575aea958 | ||
![]() |
b3a3136859 | ||
![]() |
6078a994f2 | ||
![]() |
dca9cc09cd | ||
![]() |
96dec08fca | ||
![]() |
2e47d84972 | ||
![]() |
e5eb47b765 | ||
![]() |
c663363668 | ||
![]() |
0a7d7c41d1 | ||
![]() |
6653dbb2e2 | ||
![]() |
5fc1162355 | ||
![]() |
a839602cba | ||
![]() |
f21a41dc84 | ||
![]() |
055ac07663 | ||
![]() |
c7b295f403 | ||
![]() |
d36a2cc938 | ||
![]() |
82ca54df96 | ||
![]() |
966ded762e | ||
![]() |
8567011f9d | ||
![]() |
3862892568 | ||
![]() |
8764a69ea2 | ||
![]() |
d11d0a1708 | ||
![]() |
a5d7ffe29d | ||
![]() |
36dd640610 | ||
![]() |
9b50d05751 | ||
![]() |
708e53adf0 | ||
![]() |
ca96f5f5ba | ||
![]() |
b165e6157d | ||
![]() |
40f73985ea | ||
![]() |
06d210fdc3 | ||
![]() |
4e6aa8f0d2 | ||
![]() |
7e4325afd8 | ||
![]() |
75fa24c8e9 | ||
![]() |
ff71bd2c93 | ||
![]() |
824b368891 | ||
![]() |
2fe8522585 | ||
![]() |
4f3002090e | ||
![]() |
d218f771a9 | ||
![]() |
7a2edb11a0 | ||
![]() |
4641ec112e | ||
![]() |
49394741d3 | ||
![]() |
704335dc1c | ||
![]() |
3d777d86d2 | ||
![]() |
f942be515f | ||
![]() |
867be9ba37 | ||
![]() |
108f619880 | ||
![]() |
1ea90a5dbb | ||
![]() |
bd38f5bcca | ||
![]() |
a4675b821a | ||
![]() |
cd0aae6bbf | ||
![]() |
77bd9e1ab5 | ||
![]() |
4d055e38b3 | ||
![]() |
4ce497deb7 | ||
![]() |
18237029a0 | ||
![]() |
25bd251d10 | ||
![]() |
1d657191eb | ||
![]() |
378ebcf9a4 | ||
![]() |
1ad7ad855a | ||
![]() |
071f1581c0 | ||
![]() |
4bdd4c7478 | ||
![]() |
af62e7a99b | ||
![]() |
a002ee9355 | ||
![]() |
f41eeb37c1 | ||
![]() |
ba68bd9941 | ||
![]() |
e6f22c53e5 | ||
![]() |
18d36831dc | ||
![]() |
f47cdbaee1 | ||
![]() |
4829e687ff | ||
![]() |
90d63fd208 | ||
![]() |
e81963e585 | ||
![]() |
b1d703a5e7 | ||
![]() |
f036389645 | ||
![]() |
e3d98b781e | ||
![]() |
50d50ba653 | ||
![]() |
879467d358 | ||
![]() |
8025c41ee4 | ||
![]() |
7a5d729a53 | ||
![]() |
e2f9cd845a | ||
![]() |
8085fe149b | ||
![]() |
52be85d0cc | ||
![]() |
012605b5b2 | ||
![]() |
e26cf4512c | ||
![]() |
c7280055a8 | ||
![]() |
003e8b7bb1 | ||
![]() |
492cfd0309 | ||
![]() |
7744b33e2b | ||
![]() |
b9445f61f4 | ||
![]() |
9be5d2b454 | ||
![]() |
3422cc61ce | ||
![]() |
6a08551402 | ||
![]() |
05172328d5 | ||
![]() |
3e489f59f2 | ||
![]() |
ef58b897d6 | ||
![]() |
15ac7988f3 | ||
![]() |
7f2c43219e | ||
![]() |
36794269b4 | ||
![]() |
682550f6c5 | ||
![]() |
19e2066969 | ||
![]() |
8664afa997 | ||
![]() |
82229ce04b | ||
![]() |
f66e609d3f | ||
![]() |
e519cea350 | ||
![]() |
822cc9957f | ||
![]() |
392f05b975 | ||
![]() |
fbd0df6fad | ||
![]() |
570a22903a | ||
![]() |
ef282fc7d0 | ||
![]() |
ef116f13de | ||
![]() |
a35b885fac | ||
![]() |
4fcde9223e | ||
![]() |
b85b86e6de | ||
![]() |
7fd35bbd11 | ||
![]() |
3d58edacdb | ||
![]() |
13ef53de71 | ||
![]() |
2f50c5e6c2 | ||
![]() |
c767f9eebe | ||
![]() |
a67a57913d | ||
![]() |
7ce41e3865 | ||
![]() |
92221f9efb | ||
![]() |
69e6299662 | ||
![]() |
0bd3977cde | ||
![]() |
6a979f4f52 | ||
![]() |
545e7a52e4 | ||
![]() |
48c233ebe5 | ||
![]() |
9a588de754 | ||
![]() |
ac9ecc3bd3 | ||
![]() |
c4050964fe | ||
![]() |
a2ead96bf3 | ||
![]() |
8b166f3041 | ||
![]() |
5509420637 | ||
![]() |
38ac127344 | ||
![]() |
c13343ec7f | ||
![]() |
a210261e34 | ||
![]() |
d46b98dcda | ||
![]() |
295fd32ade | ||
![]() |
f04a0da39f | ||
![]() |
dcbc320502 | ||
![]() |
8519b7fc0e | ||
![]() |
7d6c7b6610 | ||
![]() |
1f0e493b8c | ||
![]() |
ada2167d08 | ||
![]() |
be5032ffbe | ||
![]() |
00b3a3b0a9 | ||
![]() |
6d82c0035a | ||
![]() |
4dced10a3f | ||
![]() |
b80f225691 | ||
![]() |
7f0f28f8e3 | ||
![]() |
59f36b4dd6 | ||
![]() |
d3b5ddf891 | ||
![]() |
5caaf4fd98 | ||
![]() |
2bfa65329a | ||
![]() |
81c64f0a8f | ||
![]() |
4b7f6e20da | ||
![]() |
7725e6efe6 | ||
![]() |
689df9cff5 | ||
![]() |
6c608ea868 | ||
![]() |
a8ea456b49 | ||
![]() |
7de99aa690 | ||
![]() |
03bff5292b | ||
![]() |
e891a9a3f3 | ||
![]() |
51aedcf6c3 | ||
![]() |
853008ddcc | ||
![]() |
7d6427904f | ||
![]() |
dcb93d071c | ||
![]() |
2ea97f0643 | ||
![]() |
4791c2fc62 | ||
![]() |
49c333221a | ||
![]() |
481a333dc0 | ||
![]() |
b0df35eba0 | ||
![]() |
08ae3d2b7f | ||
![]() |
7c4eb0fb70 | ||
![]() |
ee01eae55e | ||
![]() |
89b301afc7 | ||
![]() |
ce70f44c58 | ||
![]() |
6594e19a1f | ||
![]() |
8597646a12 | ||
![]() |
f205c48564 | ||
![]() |
3c3683429b | ||
![]() |
a7a66929aa | ||
![]() |
a336931c95 | ||
![]() |
d7d3561768 | ||
![]() |
0d72566c99 | ||
![]() |
62db58eaaf | ||
![]() |
891de8d6c3 | ||
![]() |
62b9b1f15f | ||
![]() |
03a1e57de6 | ||
![]() |
24c5d54f49 | ||
![]() |
3d9f0e2135 | ||
![]() |
94da7bb088 | ||
![]() |
ee22d4a4b9 | ||
![]() |
0cf4ed9a24 | ||
![]() |
1ba62c6112 | ||
![]() |
65983b8ac7 | ||
![]() |
56d0289ed9 | ||
![]() |
f253c40012 | ||
![]() |
4bbe4af377 | ||
![]() |
2c34a6df2b | ||
![]() |
1098e2b48e | ||
![]() |
c50170b816 | ||
![]() |
cd518e6820 | ||
![]() |
dd7899acca | ||
![]() |
5e597bb536 | ||
![]() |
60911515fd | ||
![]() |
ac8c0ae762 | ||
![]() |
16ae3038b3 | ||
![]() |
fc0bd12631 | ||
![]() |
311e6c63e8 | ||
![]() |
a0da88834c | ||
![]() |
967409b157 | ||
![]() |
1b2711fc42 | ||
![]() |
20b4f26e42 | ||
![]() |
c8fbe2dd5d | ||
![]() |
515a74ba11 | ||
![]() |
b562e7eefa | ||
![]() |
ccf60c7017 | ||
![]() |
e224b6b3b2 | ||
![]() |
79e2398d71 | ||
![]() |
af9ef186b3 | ||
![]() |
e6102eacfb | ||
![]() |
199c2c50ba | ||
![]() |
4a85250242 | ||
![]() |
dd017c44c7 | ||
![]() |
e346ccfb02 | ||
![]() |
3dfdb9a309 | ||
![]() |
1d4d03637f | ||
![]() |
b98111b4e1 | ||
![]() |
3876cbac8a | ||
![]() |
ffc7e8d77e | ||
![]() |
7f305ee02e | ||
![]() |
fcb44dafa3 | ||
![]() |
7af4ab0f4f | ||
![]() |
7864055490 | ||
![]() |
7b357fa71b | ||
![]() |
9a4cf4d7af | ||
![]() |
022a11e159 | ||
![]() |
74554bcbf3 | ||
![]() |
5d6c23cff9 | ||
![]() |
0ccbf1b809 | ||
![]() |
6fd3195275 | ||
![]() |
d01069f7f2 | ||
![]() |
224242dfde | ||
![]() |
049bfb6f7f | ||
![]() |
12d60d102b | ||
![]() |
9db2fc7f05 | ||
![]() |
e03a6541ce | ||
![]() |
41642f2f59 | ||
![]() |
df9bb263dc | ||
![]() |
339c330b4f | ||
![]() |
cfc8fb484c | ||
![]() |
bc1be9d70a | ||
![]() |
3a4b8da8fd | ||
![]() |
37dad9d4bb | ||
![]() |
b805f8695e | ||
![]() |
10bedad3a3 | ||
![]() |
afc9f9686a | ||
![]() |
8ca58798e4 | ||
![]() |
235ebe9a4a | ||
![]() |
211d3ff8a8 | ||
![]() |
332d2c3feb | ||
![]() |
c152fe6312 | ||
![]() |
a51e968d31 | ||
![]() |
c95b91af5a | ||
![]() |
117bdb7464 | ||
![]() |
baa5d32dea | ||
![]() |
1513655bc4 | ||
![]() |
959281c635 | ||
![]() |
98b50ce333 | ||
![]() |
b66ade73c8 | ||
![]() |
7de8fc9bc0 | ||
![]() |
de115fe01e | ||
![]() |
c87b62b8c2 | ||
![]() |
8fe33da85d | ||
![]() |
c202a5cbc6 | ||
![]() |
1353949e42 | ||
![]() |
c19f8c9ee6 | ||
![]() |
ad9979f9c6 | ||
![]() |
e75d89113a | ||
![]() |
7984d103a4 | ||
![]() |
4309a6a875 | ||
![]() |
32e42f14fb | ||
![]() |
d9567c0035 | ||
![]() |
7328990921 | ||
![]() |
8ca34e18bd | ||
![]() |
3589ac2c06 | ||
![]() |
2d228c0520 | ||
![]() |
8ba078c04b | ||
![]() |
372d5d9783 | ||
![]() |
8aa1ea23c8 | ||
![]() |
2176d59727 | ||
![]() |
d98825f609 | ||
![]() |
ab9dbdf270 | ||
![]() |
502a4d2128 | ||
![]() |
7ff5a336a6 | ||
![]() |
c401625a01 | ||
![]() |
5f15c2e53b | ||
![]() |
49da79cbd9 | ||
![]() |
7f5a939e50 | ||
![]() |
3396bdbb22 | ||
![]() |
8c5ff2b523 | ||
![]() |
b2c2d61867 | ||
![]() |
72070ee7bd | ||
![]() |
830248bf66 | ||
![]() |
b6342d34d2 | ||
![]() |
2ce037af5e | ||
![]() |
9a6a35cadc | ||
![]() |
ecb575e686 | ||
![]() |
4ed014a50c | ||
![]() |
3bfdb0d77b | ||
![]() |
a8be38fcc0 | ||
![]() |
47622d5e0b | ||
![]() |
ad3a78a300 | ||
![]() |
da7fe68daa | ||
![]() |
eee5dbf755 | ||
![]() |
8f247e3b70 | ||
![]() |
10e4b79423 | ||
![]() |
625eca8ea4 | ||
![]() |
858251045d | ||
![]() |
52e9afcf2f | ||
![]() |
c96c04b39d | ||
![]() |
6f76ae68fd | ||
![]() |
2c439abb64 | ||
![]() |
b9dec64ea1 | ||
![]() |
ca3d794c80 | ||
![]() |
60294a876d | ||
![]() |
51a65a3a22 | ||
![]() |
02c2657569 | ||
![]() |
df8bacd0ed | ||
![]() |
c653f660bb | ||
![]() |
25900758d1 | ||
![]() |
0e583b9e4f | ||
![]() |
6d8e2a5bb9 | ||
![]() |
9b6781685a | ||
![]() |
5e080bedb6 | ||
![]() |
430f6dab38 | ||
![]() |
d9397a026b | ||
![]() |
7ffc889c08 | ||
![]() |
785280c86b | ||
![]() |
fcf5544fc8 | ||
![]() |
09b3c37885 | ||
![]() |
bf83bd6ff7 | ||
![]() |
03472788fd | ||
![]() |
7b60c0850c | ||
![]() |
68768ed112 | ||
![]() |
21196620b6 | ||
![]() |
bb26a2d12c | ||
![]() |
687721caf8 | ||
![]() |
14ee44e8e2 | ||
![]() |
40b21c466d | ||
![]() |
109d8735c7 | ||
![]() |
ee688eb184 | ||
![]() |
c004e55c76 | ||
![]() |
6e462f6523 | ||
![]() |
51c288dcd5 | ||
![]() |
6393075f11 | ||
![]() |
9d23f67e8a | ||
![]() |
10993b9154 | ||
![]() |
3278c8cf94 | ||
![]() |
aa62c6349e | ||
![]() |
30221bdb11 | ||
![]() |
664267357a | ||
![]() |
4a20d50eea | ||
![]() |
cd4c478986 | ||
![]() |
4aa671c20b | ||
![]() |
974c9bba61 | ||
![]() |
0d17caccfe | ||
![]() |
8d7f6bfb19 | ||
![]() |
edb03b7862 | ||
![]() |
fa6e941e56 | ||
![]() |
8667a574d4 | ||
![]() |
5f1343864c | ||
![]() |
6cee029d15 | ||
![]() |
5c93d698b4 | ||
![]() |
0568bcc5d3 | ||
![]() |
435b01dda4 | ||
![]() |
9fb02090bf | ||
![]() |
f52749b403 | ||
![]() |
d67e09f334 | ||
![]() |
fcff1f5d2c | ||
![]() |
e224fc6656 | ||
![]() |
5e47677f7a | ||
![]() |
db9637ce4f | ||
![]() |
f908bc364e | ||
![]() |
9b96b93260 | ||
![]() |
30c2c60f59 | ||
![]() |
ab5ce01bdd | ||
![]() |
ddadb6c28a | ||
![]() |
23be1031b8 | ||
![]() |
faee29ba8b | ||
![]() |
b86bf07d57 | ||
![]() |
e6294049bc | ||
![]() |
30885bcc8a | ||
![]() |
a67f90593d | ||
![]() |
385340da46 | ||
![]() |
0271e549bb | ||
![]() |
f53b18ebb9 | ||
![]() |
8567a9b719 | ||
![]() |
ad9d732608 | ||
![]() |
aa15670dc2 | ||
![]() |
81176c8e4b | ||
![]() |
960b5d5bbd | ||
![]() |
802e7a1f4d | ||
![]() |
52c68c6510 | ||
![]() |
54b1ee9a3d | ||
![]() |
6e3dee8b3b | ||
![]() |
cd62fd9826 | ||
![]() |
91638c7fe0 | ||
![]() |
ff8170d5ab | ||
![]() |
8e94402282 | ||
![]() |
f6f75f6fab | ||
![]() |
2f54f39f31 | ||
![]() |
fa792f664e | ||
![]() |
b50dfb7f93 | ||
![]() |
cde4e0caca | ||
![]() |
df80b9fc71 | ||
![]() |
7558ffd4f3 | ||
![]() |
50e9e8af30 | ||
![]() |
ed02e2106b | ||
![]() |
0860a93e84 | ||
![]() |
e04f358cc4 | ||
![]() |
473d4616f2 | ||
![]() |
3a23c8f7cf | ||
![]() |
e0626a4304 | ||
![]() |
24a567c3be | ||
![]() |
70f05e9d52 | ||
![]() |
889118aeb6 | ||
![]() |
a05a33051e | ||
![]() |
0bef62743c | ||
![]() |
dbf29cf71a | ||
![]() |
a70ba94b0c | ||
![]() |
105a07e276 | ||
![]() |
4686c8cd25 | ||
![]() |
fa3c3236d8 | ||
![]() |
9e29564f48 | ||
![]() |
5aeced5c2e | ||
![]() |
46f64e0e0a | ||
![]() |
4971b63501 | ||
![]() |
3ff9817325 | ||
![]() |
d6becddbe5 | ||
![]() |
1d6793d124 | ||
![]() |
c7377e602d | ||
![]() |
08021ea19f | ||
![]() |
295d481603 | ||
![]() |
7636a7b40b | ||
![]() |
32a0b0467f | ||
![]() |
c4cb828f65 | ||
![]() |
0657f9e875 | ||
![]() |
e37768ca86 | ||
![]() |
bea5eb0dda | ||
![]() |
fd5b11823c | ||
![]() |
5fc1944f04 | ||
![]() |
39fd4ce16c | ||
![]() |
c0f4d5479f | ||
![]() |
193763f8f0 | ||
![]() |
79759ea5a3 | ||
![]() |
6e5ceab0f8 | ||
![]() |
c6fa0d23df | ||
![]() |
85e59245fd | ||
![]() |
4232a1205c | ||
![]() |
0d4c693792 | ||
![]() |
77b2246010 | ||
![]() |
aff80ac58c | ||
![]() |
e10bc30b4f | ||
![]() |
da3fd95b15 | ||
![]() |
9f59c056b8 | ||
![]() |
27f5085af7 | ||
![]() |
4e8e106ca4 | ||
![]() |
9e2d82b78f | ||
![]() |
9386b4280c | ||
![]() |
173aece6cd | ||
![]() |
f3572e51cf | ||
![]() |
102a6f6403 | ||
![]() |
c44eb9aaf5 | ||
![]() |
3320432d59 | ||
![]() |
eb9cea03d6 | ||
![]() |
bac849bde8 | ||
![]() |
0f7bbe0dde | ||
![]() |
25fec63a03 | ||
![]() |
9b46637aff | ||
![]() |
fb25013334 | ||
![]() |
a1536c7386 | ||
![]() |
57473ef39c | ||
![]() |
39d55f2770 | ||
![]() |
67b2c0c0a4 | ||
![]() |
ac42628a94 | ||
![]() |
5d5a68f097 | ||
![]() |
a6b9808e2e | ||
![]() |
e64595fc67 | ||
![]() |
8fb4b092be | ||
![]() |
4c9548edaa | ||
![]() |
e8cb17c586 | ||
![]() |
0100801a24 | ||
![]() |
773276f4fe | ||
![]() |
761bb054ce | ||
![]() |
6b207b6bb4 | ||
![]() |
48f167876c | ||
![]() |
99776c1513 | ||
![]() |
bb96ee8269 | ||
![]() |
d2d8586f21 | ||
![]() |
7a0c5ea910 | ||
![]() |
075bdc7063 | ||
![]() |
2c50c1876e | ||
![]() |
a7f62f281b | ||
![]() |
36101208fe | ||
![]() |
5167b3a623 | ||
![]() |
77143e6b24 | ||
![]() |
ed68f6d64a | ||
![]() |
7600e727c1 | ||
![]() |
e29e3a5cba | ||
![]() |
6883a60f5d | ||
![]() |
dce4058705 | ||
![]() |
5b5087c9dd | ||
![]() |
8967758413 | ||
![]() |
5ffa71999a | ||
![]() |
d41cdb844c | ||
![]() |
fd239db7c1 | ||
![]() |
fefb5ebc33 | ||
![]() |
5615031ef8 | ||
![]() |
8180707cc6 | ||
![]() |
69a50ed954 | ||
![]() |
887e292748 | ||
![]() |
68f4d1c426 | ||
![]() |
176e8272a0 | ||
![]() |
adf7a31866 | ||
![]() |
4cbf05187c | ||
![]() |
aa234a504e | ||
![]() |
dd7a2aa8a6 | ||
![]() |
149552f79b | ||
![]() |
adddd95e38 | ||
![]() |
2b70277a35 | ||
![]() |
bad38840d8 | ||
![]() |
7f8336e459 | ||
![]() |
514619b44a | ||
![]() |
fc1f211af5 | ||
![]() |
10050aa601 | ||
![]() |
ae6394c879 | ||
![]() |
53d1c1e4e9 | ||
![]() |
4bed222162 | ||
![]() |
44a31733ac | ||
![]() |
e44dd93778 | ||
![]() |
19f204a9af | ||
![]() |
d1378a5512 | ||
![]() |
a60e1c274a | ||
![]() |
686a074fab | ||
![]() |
937ef47c2a | ||
![]() |
6964ed238a | ||
![]() |
d51a32bcfe | ||
![]() |
2c6aea24cb | ||
![]() |
7874683618 | ||
![]() |
fa66585b6b | ||
![]() |
51f60c15bb | ||
![]() |
9139b917cf | ||
![]() |
f64fffe6b8 | ||
![]() |
1a67c94db0 | ||
![]() |
70c607e256 | ||
![]() |
d1906bd055 | ||
![]() |
6de34b0464 | ||
![]() |
9a9e0116a1 | ||
![]() |
f58745c04d | ||
![]() |
1dee849498 | ||
![]() |
863e8895a0 | ||
![]() |
e40566967e | ||
![]() |
ede5e09ad0 | ||
![]() |
98e04bb11f | ||
![]() |
ebb5896301 | ||
![]() |
2bebd727c2 | ||
![]() |
74665d09e8 | ||
![]() |
fbf990092c | ||
![]() |
a23f186c65 | ||
![]() |
a8f02c1eb0 | ||
![]() |
c302606de4 | ||
![]() |
8bd9cf38ab | ||
![]() |
b3f9063ae7 | ||
![]() |
f68ef70b94 | ||
![]() |
c5c7de8b2b | ||
![]() |
3840443159 | ||
![]() |
832d2ca375 | ||
![]() |
ccad47937e | ||
![]() |
8b95fac8c5 | ||
![]() |
e2d71d4636 | ||
![]() |
b557e69313 | ||
![]() |
dad22f7261 | ||
![]() |
587f33b6c0 | ||
![]() |
3cd14bd4b8 | ||
![]() |
d443135155 | ||
![]() |
0b68bf8760 | ||
![]() |
907c05299c | ||
![]() |
c60e3dc842 | ||
![]() |
2fce29d775 | ||
![]() |
2303ac3394 | ||
![]() |
4a23a522e1 | ||
![]() |
a3d7e0dfae | ||
![]() |
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 | ||
![]() |
47a5a44b9e | ||
![]() |
a0e6c7ceb6 | ||
![]() |
49aa367ffa | ||
![]() |
83b36f2c3a | ||
![]() |
2b341d884f | ||
![]() |
141a09b31e | ||
![]() |
6378ec4bbd | ||
![]() |
603fb1c698 | ||
![]() |
67b4ea947b | ||
![]() |
4075b7c78a | ||
![]() |
6499eba0cb | ||
![]() |
980626aa40 | ||
![]() |
3f329bc1a8 | ||
![]() |
69de67b1c2 | ||
![]() |
7158f9a8d9 | ||
![]() |
bb75bd7167 | ||
![]() |
4fa58169f1 | ||
![]() |
564040897f | ||
![]() |
f78bbab289 | ||
![]() |
d3c82d7363 | ||
![]() |
7b9b978a6d | ||
![]() |
45d47818ca | ||
![]() |
202c4a948b | ||
![]() |
b5269bb28e | ||
![]() |
d6c5f09a1a | ||
![]() |
11546b97bb | ||
![]() |
cac6a251cc | ||
![]() |
31d26a7bad | ||
![]() |
a9ed9ae936 | ||
![]() |
a9892efe38 | ||
![]() |
f02e0a3ccb | ||
![]() |
d6f26609fc | ||
![]() |
05438d047d | ||
![]() |
32f5632620 | ||
![]() |
005cc08b40 | ||
![]() |
6a659fe10d | ||
![]() |
6941ca2f63 | ||
![]() |
e4eba49c1b | ||
![]() |
f289439d1d | ||
![]() |
a5ef64919a | ||
![]() |
e132125cf3 | ||
![]() |
01b5512ac7 | ||
![]() |
a0c7e63d78 | ||
![]() |
8ba5f2ffa7 | ||
![]() |
2c44333679 | ||
![]() |
610be9cf17 | ||
![]() |
eb5e8fe388 | ||
![]() |
c999c6082f | ||
![]() |
3b86b3fe66 | ||
![]() |
0a71dca825 | ||
![]() |
4f98d470a0 | ||
![]() |
57abae3999 | ||
![]() |
202e49a897 | ||
![]() |
13093f1732 | ||
![]() |
837d327c1e | ||
![]() |
e39b777abc | ||
![]() |
7ca42489ae | ||
![]() |
8c6363f792 | ||
![]() |
cbb7f29f96 | ||
![]() |
8886c9b6bc | ||
![]() |
a07de38e80 | ||
![]() |
1f08997a9e | ||
![]() |
1b2d07d81d | ||
![]() |
fc32cf5bcc | ||
![]() |
ce94ef38b2 | ||
![]() |
1e9c587b92 | ||
![]() |
9f1d633ae4 | ||
![]() |
f01189631a | ||
![]() |
542877ee46 | ||
![]() |
f1760b516d | ||
![]() |
f0143fd6c9 | ||
![]() |
febfa72d60 | ||
![]() |
c03e00035f | ||
![]() |
2f8866ef32 | ||
![]() |
f98afac6df | ||
![]() |
5efd5abbe4 | ||
![]() |
6bb8f5d889 | ||
![]() |
35f4a49d10 | ||
![]() |
a31d713fcc | ||
![]() |
6f38f7afc3 | ||
![]() |
e514ca0009 | ||
![]() |
8c3aed2846 | ||
![]() |
2cab9d5514 | ||
![]() |
c38bdbb0c5 | ||
![]() |
2a1704a0dc | ||
![]() |
36168b4609 | ||
![]() |
bd498def76 | ||
![]() |
d8279c48ac | ||
![]() |
4232245546 | ||
![]() |
2d1186e55d | ||
![]() |
072aeca1be | ||
![]() |
cb656f9ef4 | ||
![]() |
322a5779f1 | ||
![]() |
e41df28bf2 | ||
![]() |
d3239b49ce | ||
![]() |
d11be61d94 | ||
![]() |
916063a79b | ||
![]() |
5aa0bf2d14 | ||
![]() |
f3ad6b4acc | ||
![]() |
ec039719de | ||
![]() |
6989df0af3 | ||
![]() |
14b16b2f36 | ||
![]() |
cbc7e280d6 | ||
![]() |
f4fa9c93a0 | ||
![]() |
6c64723d7c | ||
![]() |
3d21f2223e | ||
![]() |
710a69b812 | ||
![]() |
dd6a8d9998 | ||
![]() |
9f32e5af0a | ||
![]() |
298e19598b | ||
![]() |
680191d7cb | ||
![]() |
81d6d69b85 | ||
![]() |
6df72bf4ac | ||
![]() |
01f2451349 | ||
![]() |
dcd971d079 | ||
![]() |
4d22fb9b2a | ||
![]() |
c18d58b13f | ||
![]() |
b539c2df70 | ||
![]() |
64fdb4ddc1 | ||
![]() |
a8669197dd | ||
![]() |
2412c92772 | ||
![]() |
7c0ca42145 | ||
![]() |
c443524ee2 | ||
![]() |
e2fa01e0cf | ||
![]() |
6558f05d1d | ||
![]() |
1be0f39be0 | ||
![]() |
d01001f2a5 | ||
![]() |
7c85694d60 | ||
![]() |
b2fcd4c9e5 | ||
![]() |
ba8123f08a | ||
![]() |
e2879a8eb1 | ||
![]() |
eab8652225 | ||
![]() |
ead6f96513 | ||
![]() |
7ec662c83f | ||
![]() |
348d2b8701 | ||
![]() |
12f0dcb23b | ||
![]() |
449a538e6b | ||
![]() |
3c50c9a18b | ||
![]() |
3c10ec70a5 | ||
![]() |
1a59f343c0 | ||
![]() |
fba4d4702e | ||
![]() |
143bbf37f4 | ||
![]() |
fd3ad267ba | ||
![]() |
330583f71d | ||
![]() |
d775f90f0c | ||
![]() |
e096144713 | ||
![]() |
7ce30ba888 | ||
![]() |
6a3ec1d874 | ||
![]() |
575d3a66c6 | ||
![]() |
cc333b3965 | ||
![]() |
351758b3bd | ||
![]() |
94053d8432 | ||
![]() |
e14b2826e0 | ||
![]() |
150611123a | ||
![]() |
abfc17ee62 | ||
![]() |
97be9c94b9 | ||
![]() |
21b00e8fbb | ||
![]() |
01636c2e4b | ||
![]() |
005315cd29 | ||
![]() |
20d20df829 | ||
![]() |
f945a1bc6b | ||
![]() |
3a09b04786 | ||
![]() |
82e752395b | ||
![]() |
e330abd587 | ||
![]() |
16422b4055 | ||
![]() |
b9ca74c915 | ||
![]() |
7e05d7478f | ||
![]() |
8fd98d7db3 | ||
![]() |
1039a08be6 | ||
![]() |
023b38df50 | ||
![]() |
3187053b3a | ||
![]() |
a3e526e818 | ||
![]() |
f6d4621834 | ||
![]() |
d075113c1a | ||
![]() |
63f2abd923 | ||
![]() |
f78cff225b | ||
![]() |
31eec9fa1c | ||
![]() |
624cc7876a | ||
![]() |
d3059c810f | ||
![]() |
85a40da83c | ||
![]() |
1bf8f1991f | ||
![]() |
d155aa8745 | ||
![]() |
c0bfd6d15f | ||
![]() |
24cc108147 | ||
![]() |
b02d7d990e | ||
![]() |
87f82addbc | ||
![]() |
09713e8eab | ||
![]() |
0aa3941832 | ||
![]() |
fea77e41df | ||
![]() |
74ef9ab7c5 | ||
![]() |
6499c82d7f | ||
![]() |
80e97feee2 | ||
![]() |
fddab5d432 | ||
![]() |
c4e4805160 | ||
![]() |
c75950125d | ||
![]() |
f141af4b61 | ||
![]() |
3d8ea0e6ed | ||
![]() |
6efeff6fce | ||
![]() |
399f9d9bdf | ||
![]() |
2b76fd299e | ||
![]() |
90592bb157 | ||
![]() |
5cf38b950a | ||
![]() |
3bc5361491 | ||
![]() |
c3a7e3413b | ||
![]() |
d390bfb215 | ||
![]() |
ceba53f1c4 | ||
![]() |
be59bcd47d | ||
![]() |
cfe0fa912a | ||
![]() |
31d6128a2b | ||
![]() |
82cf5b72e4 | ||
![]() |
8ec8c42441 | ||
![]() |
7e36e1fd90 | ||
![]() |
a7710e9058 | ||
![]() |
3133dcd5a3 | ||
![]() |
057c1dd913 | ||
![]() |
06f2477cfd | ||
![]() |
1abc8ed469 | ||
![]() |
cdaa2c847d | ||
![]() |
b04addda9a | ||
![]() |
7e7abf3b53 | ||
![]() |
9b9f5abf8f | ||
![]() |
7db80458dd | ||
![]() |
5775cab175 | ||
![]() |
c872e6a9f0 | ||
![]() |
995b7c4d2b | ||
![]() |
f797eecaca | ||
![]() |
de0ccd0632 | ||
![]() |
be9d97902f | ||
![]() |
20c5471a89 | ||
![]() |
ec73c171c7 | ||
![]() |
f9acf0adec | ||
![]() |
8b65c11cdf | ||
![]() |
34fca29dd3 | ||
![]() |
b75fbf22ca | ||
![]() |
d790cae0e2 | ||
![]() |
a68703dfb3 | ||
![]() |
f35b2081a1 | ||
![]() |
f0508d8cc9 | ||
![]() |
47dd59c2a7 | ||
![]() |
c2fe1bc2e3 | ||
![]() |
cce1184090 | ||
![]() |
1adb1d8307 | ||
![]() |
c2174e10a6 | ||
![]() |
18283c7df0 | ||
![]() |
3ff74c8dc5 | ||
![]() |
e997114d6e | ||
![]() |
e9aecba4df | ||
![]() |
6585384daa | ||
![]() |
6bc821676c | ||
![]() |
f38ef0223d | ||
![]() |
8902e9d1fc | ||
![]() |
86a5394f07 | ||
![]() |
df5df18820 | ||
![]() |
0b5bf602aa | ||
![]() |
06e074bd32 | ||
![]() |
b5b3fca137 | ||
![]() |
048e35a80f | ||
![]() |
fb3045f456 | ||
![]() |
495790d81d | ||
![]() |
dfe0a9f187 | ||
![]() |
7ca54a2bfb | ||
![]() |
a81bf93fdc | ||
![]() |
54d01746fe | ||
![]() |
57f8ee0b09 | ||
![]() |
64220292f1 | ||
![]() |
82851d6d2d | ||
![]() |
2f952a7915 | ||
![]() |
f9eceec746 | ||
![]() |
b26abc947e | ||
![]() |
b78eae1351 | ||
![]() |
ca57560f11 | ||
![]() |
17331e7d82 | ||
![]() |
5130b279d8 | ||
![]() |
aac6e49b94 | ||
![]() |
2f4eccd9a9 | ||
![]() |
ac49912b39 | ||
![]() |
0441a2e2e3 | ||
![]() |
8566b78202 | ||
![]() |
06a8ce1c9d | ||
![]() |
443b084a17 | ||
![]() |
990649af2d | ||
![]() |
6f4d29a410 | ||
![]() |
6ab29c3244 | ||
![]() |
bf9b770255 | ||
![]() |
9210ebdb9f | ||
![]() |
a56bb984d6 | ||
![]() |
7d1c0b3834 | ||
![]() |
20892b5d5b | ||
![]() |
4cf53cd8ee | ||
![]() |
711db9352c | ||
![]() |
f42a1c5a74 | ||
![]() |
092c842a87 | ||
![]() |
d9ecc50119 | ||
![]() |
7c2092d48f | ||
![]() |
5fd107cae5 | ||
![]() |
db0967446b | ||
![]() |
2ff5038c84 | ||
![]() |
4ae76aa2dd | ||
![]() |
ba33669a62 | ||
![]() |
779d921410 | ||
![]() |
68efef1164 | ||
![]() |
6ecada7eed | ||
![]() |
94c4352f45 | ||
![]() |
6dd6353d41 | ||
![]() |
d06bfa6c1b | ||
![]() |
5c7ba2a4c7 | ||
![]() |
e9cc3fdaab | ||
![]() |
8bd37ea53c | ||
![]() |
698ae03505 | ||
![]() |
a4c70f7a92 | ||
![]() |
39bca053ed | ||
![]() |
c2eb8e5330 | ||
![]() |
ab47144ae3 | ||
![]() |
9b6f9859d1 | ||
![]() |
4e6d572de9 | ||
![]() |
145186a6b6 | ||
![]() |
5714b3c6b7 | ||
![]() |
8e0967dd8e | ||
![]() |
5a89f3c633 | ||
![]() |
ed8fb2d06d | ||
![]() |
8c8d9304ac | ||
![]() |
b5bb12d0d2 | ||
![]() |
93a722f85b |
121 changed files with 23079 additions and 4163 deletions
26
.editorconfig
Normal file
26
.editorconfig
Normal file
|
@ -0,0 +1,26 @@
|
|||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[Vagrantfile]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.rb]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[**.html]
|
||||
indent_style = tab
|
||||
|
||||
[**.py]
|
||||
indent_style = tab
|
||||
|
||||
[**]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
# All text should use Unix-style Line-endings
|
||||
* text eol=lf
|
||||
|
||||
# Except mta-sts.txt (RFC 8461)
|
||||
mta-sts.txt text eol=crlf
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
ko_fi: davness
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,3 +4,6 @@ management/__pycache__/
|
|||
tools/__pycache__/
|
||||
externals/
|
||||
.env
|
||||
.vagrant
|
||||
api/docs/api-docs.html
|
||||
mailinabox-ca.crt
|
||||
|
|
3
.style.yapf
Normal file
3
.style.yapf
Normal file
|
@ -0,0 +1,3 @@
|
|||
[style]
|
||||
based_on_style = pep8
|
||||
use_tabs = True
|
1245
CHANGELOG.md
Normal file
1245
CHANGELOG.md
Normal file
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
|
||||
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
|
112
README.md
112
README.md
|
@ -1,43 +1,101 @@
|
|||
Mail-in-a-Box
|
||||
=============
|
||||
# Power Mail-in-a-Box
|
||||
## **[Installation](#installation)** (current version: v60.5)
|
||||
## **[Upgrading Quick Start](#upgrading)**
|
||||
|
||||
By [@JoshData](https://github.com/JoshData) and [contributors](https://github.com/mail-in-a-box/mailinabox/graphs/contributors).
|
||||
[](https://ko-fi.com/davness)
|
||||
|
||||
Mail-in-a-Box helps individuals take back control of their email by defining a one-click, easy-to-deploy SMTP+everything else server: a mail server in a box.
|
||||
Power Mail-in-a-Box (a fork of [Mail-in-a-Box](https://mailinabox.email/)) is a complete pre-configured mail appliance, quickly deployable in a matter of minutes.
|
||||
|
||||
**Please see [https://mailinabox.email](https://mailinabox.email) for the project's website and setup guide!**
|
||||
It's main difference to the main project is focused on ad-hoc, advanced features. While Mail-in-a-Box caters to beginners by providing sane configurations, Power Mail-in-a-Box also attempts to cater to advanced users that want deeper levels of customization.
|
||||
|
||||
* * *
|
||||
## Features
|
||||
- Configure Power Mail-in-a-Box to use an external SMTP relay;
|
||||
- Brand new admin panel (with up-to-date libraries);
|
||||
- Perform backups right away from the admin panel;
|
||||
- Account quotas support (thanks to **@[jrsupplee](https://github.com/jrsupplee/mailinabox)**!)
|
||||
- Customize TTL's for custom DNS records;
|
||||
- Publish OpenPGP keys authoritatively via a WKD server;
|
||||
- - **In the future:** Allow usage of OpenPGP keys to encrypt backups;
|
||||
- Per-domain nginx configuration;
|
||||
|
||||
I am trying to:
|
||||
## Goals
|
||||
- **Easy of use** - deployment shouldn't take too many technical details to understand. Power Mail-in-a-Box already comes with default configurations which should be good for most users.
|
||||
- **Privacy, security and independence** - keeping your mail and data safe from advertisers and prying eyes.
|
||||
- **Accessible customizability** - bring the features closer to the people instead of tucking them away in configuration files.
|
||||
- **Customizability potential** - allow for deep customization by power users.
|
||||
- **Concentration** - all the services you need in just one box.
|
||||
- **Support** - support a wide range of operating systems when possible, without compromising the codebase as a whole.
|
||||
- **Lightweight** - should be able to run even with very limited resources.
|
||||
|
||||
* Make deploying a good mail server easy.
|
||||
* 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.
|
||||
* **Not** be a mail server that the NSA cannot hack.
|
||||
* **Not** be customizable by power users.
|
||||
## Non-goals
|
||||
- **Scalability** - this appliance is geared towards individuals and small/mid-sized organizations. If your use case is mission-critical it probably is a better idea to shop for a product that provides support.
|
||||
- **Portability** - I didn't figure out yet a way to easily transition from Mail-in-a-Box to Power Mail-in-a-Box.
|
||||
|
||||
The long-term goal is to have this be a one-click email appliance with *no* user-configurable setup options.
|
||||
# Minimum Pre-requisites
|
||||
The machine this appliance will be installed on needs to have the following specs (or better). Most cloud providers are able to provide VM's that satisfy these specs at relatively low cost.
|
||||
|
||||
For more background, see [The Rationale](https://github.com/mail-in-a-box/mailinabox/wiki).
|
||||
<small>_These specs depend on the number of users being served and/or amount of traffic_</small>
|
||||
- 1 CPU core;
|
||||
- 512MB of RAM (**at least 1GB** is recommended);
|
||||
- 10GB of disk;
|
||||
- **One of the following operating systems:**
|
||||
- - Debian GNU/Linux 11 (Bullseye)
|
||||
- - Ubuntu LTS 20.04 (Focal Fossa)
|
||||
- - Ubuntu LTS 22.04 (Jammy Jellyfish)
|
||||
|
||||
This setup is what has been powering my own personal email since September 2013.
|
||||
## Legacy Support
|
||||
The following distributions are no longer supported for the latest version, but they used to be supported at a earlier time:
|
||||
- **Debian 10 (Buster)** <= **v56.5**
|
||||
|
||||
The Box
|
||||
-------
|
||||
<small>_These network requirements are usually not provided by residential ISP's. They are not **strictly required** for Power Mail-in-a-Box to install, but it will take more work to get it running as intended._</small>
|
||||
- Static, public IPv4 (most residential connections **do not** provide static addresses);
|
||||
- - If the machine is behind a NAT, manual configuration might be required.
|
||||
- Reverse DNS for that IPv4 address (**Caution:** some cloud providers do not provide this);
|
||||
- You should be able to edit the firewall for that address. **In particular, outbound port 25 should not be blocked.**
|
||||
|
||||
Mail-in-a-Box turns a fresh Ubuntu 14.04 LTS 64-bit machine into a working mail server, including SMTP ([postfix](http://www.postfix.org/)), IMAP ([dovecot](http://dovecot.org/)), Exchange ActiveSync ([z-push](https://github.com/fmbiete/Z-Push-contrib)), webmail ([Roundcube](http://roundcube.net/)), spam filtering ([spamassassin](https://spamassassin.apache.org/)), greylisting ([postgrey](http://postgrey.schweikert.ch/)), CardDAV/CalDAV ([ownCloud](http://owncloud.org/)), DNS, [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), [SSHFP](https://tools.ietf.org/html/rfc4255), and basic system services like a firewall, intrusion protection, and setting the system clock.
|
||||
# Firewall
|
||||
If the machine is behind an external firewall or NAT, the following **inbound ports SHOULD** be open to external traffic:
|
||||
|
||||
The Acknowledgements
|
||||
--------------------
|
||||
- `25/tcp`
|
||||
- `53/tcp`
|
||||
- `53/udp`
|
||||
- `80/tcp`
|
||||
- `443/tcp`
|
||||
- `465/tcp`
|
||||
- `587/tcp`
|
||||
- `993/tcp`
|
||||
- `995/tcp`
|
||||
- `4190/tcp`
|
||||
|
||||
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>.
|
||||
# Installation
|
||||
|
||||
Mail-in-a-Box is similar to [iRedMail](http://www.iredmail.org/).
|
||||
1. Power Mail-in-a-Box uses `ufw` to configure it's internal firewall. If your cloud provider requires you to use another tool (usually it does not, but <small>\*cough\* _Oracle Cloud_ \*cough\*</small>), you can follow [these instructions](https://github.com/ddavness/power-mailinabox/discussions/21).
|
||||
|
||||
The History
|
||||
-----------
|
||||
2. Make sure `curl` is installed and locales are configured correctly - you'll want to make sure the primary locale is set to `en_US.UTF-8`:
|
||||
```
|
||||
sudo apt install curl locales
|
||||
sudo dpkg-reconfigure locales
|
||||
```
|
||||
|
||||
* 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).
|
||||
* 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) and [September](https://news.ycombinator.com/item?id=8276171) 2014.
|
||||
3. Run the following command, and then follow the instructions that appear on the screen:
|
||||
```
|
||||
curl -L https://power-mailinabox.net/setup.sh | sudo bash
|
||||
```
|
||||
|
||||
# Upgrading
|
||||
|
||||
To upgrade an existing box to the latest version, run the same command as you do to perform a new installation:
|
||||
|
||||
```
|
||||
curl -L https://power-mailinabox.net/setup.sh | sudo bash
|
||||
```
|
||||
|
||||
## Installing or upgrading to a different version
|
||||
If for some reason you wish to install a different version (for example, an older version for a workaround, or a beta/release candidate version for testing), you can use the following command.
|
||||
|
||||
```
|
||||
curl -L https://power-mailinabox.net/<VERSION>/setup.sh | sudo bash
|
||||
```
|
||||
Where `<VERSION>` is the version you want to install. (**Example:** `v60.0`).
|
||||
|
||||
> ⚠️ **Downgrading might not always be possible and is not supported!** Make sure you know what you're doing before doing so.
|
||||
|
|
83
Vagrantfile
vendored
83
Vagrantfile
vendored
|
@ -1,32 +1,75 @@
|
|||
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
ip = 2
|
||||
machines = [
|
||||
{
|
||||
'iso' => "debian/buster64",
|
||||
'host' => "buster"
|
||||
},
|
||||
{
|
||||
'iso' => "generic/ubuntu2004",
|
||||
'host' => "focal"
|
||||
},
|
||||
{
|
||||
'iso' => "debian/bullseye64",
|
||||
'host' => "bullseye"
|
||||
},
|
||||
{
|
||||
'iso' => "generic/ubuntu2204",
|
||||
'host' => "jammy"
|
||||
},
|
||||
]
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
config.vm.box = "ubuntu14.04"
|
||||
config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box"
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.customize ["modifyvm", :id, "--cpus", 1, "--memory", 768]
|
||||
end
|
||||
config.vm.provider :libvirt do |v|
|
||||
v.memory = 768
|
||||
v.cpus = 1
|
||||
v.nested = true
|
||||
end
|
||||
config.vm.provider :kvm do |kvm|
|
||||
kvm.memory_size = '768m'
|
||||
end
|
||||
|
||||
# Network config: Since it's a mail server, the machine must be connected
|
||||
# to the public web. However, we currently don't want to expose SSH since
|
||||
# the machine's box will let anyone log into it. So instead we'll put the
|
||||
# machine on a private network.
|
||||
config.vm.hostname = "mailinabox"
|
||||
config.vm.network "private_network", ip: "192.168.50.4"
|
||||
config.vm.synced_folder ".", "/vagrant", nfs_version: "4"
|
||||
|
||||
config.vm.provision :shell, :inline => <<-SH
|
||||
# Set environment variables so that the setup script does
|
||||
# not ask any questions during provisioning. We'll let the
|
||||
# machine figure out its own public IP and it'll take a
|
||||
# subdomain on our justtesting.email domain so we can get
|
||||
# started quickly.
|
||||
export NONINTERACTIVE=1
|
||||
export PUBLIC_IP=auto
|
||||
export PUBLIC_IPV6=auto
|
||||
export PRIMARY_HOSTNAME=auto-easy
|
||||
export CSR_COUNTRY=US
|
||||
#export SKIP_NETWORK_CHECKS=1
|
||||
(0..(machines.size - 1)).each do |n|
|
||||
node = machines[n]
|
||||
config.vm.define node['host'] do |m|
|
||||
m.vm.box = node['iso']
|
||||
m.vm.hostname = "#{node['host']}.mailinabox.lan"
|
||||
m.vm.network "private_network", ip: "192.168.168.#{ip+n}"
|
||||
|
||||
# Start the setup script.
|
||||
cd /vagrant
|
||||
setup/start.sh
|
||||
SH
|
||||
m.vm.provision "shell", :inline => <<-SH
|
||||
git config --global --add safe.directory /vagrant
|
||||
|
||||
# Set environment variables so that the setup script does
|
||||
# not ask any questions during provisioning. We'll let the
|
||||
# machine figure out its own public IP.
|
||||
export NONINTERACTIVE=1
|
||||
export PUBLIC_IP=192.168.168.#{ip+n}
|
||||
export PUBLIC_IPV6=auto
|
||||
export PRIVATE_IP=192.168.168.#{ip+n}
|
||||
export PRIMARY_HOSTNAME=\"#{node['host']}.mailinabox.lan\"
|
||||
export SKIP_NETWORK_CHECKS=1
|
||||
# Start the setup script.
|
||||
cd /vagrant
|
||||
setup/start.sh
|
||||
# After setup is done, fully open the ssh ports again
|
||||
ufw allow ssh
|
||||
SH
|
||||
|
||||
m.vm.provision "shell", run: "always", :inline => <<-SH
|
||||
service mailinabox restart
|
||||
SH
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
23
api/docs/generate-docs.sh
Executable file
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>
|
3424
api/mailinabox.yml
Normal file
3424
api/mailinabox.yml
Normal file
File diff suppressed because it is too large
Load diff
63
conf/dovecot/conf.d/15-mailboxes.conf
Normal file
63
conf/dovecot/conf.d/15-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
|
||||
}
|
||||
}
|
94
conf/dovecot/conf.d/20-imap.conf
Normal file
94
conf/dovecot/conf.d/20-imap.conf
Normal file
|
@ -0,0 +1,94 @@
|
|||
##
|
||||
## IMAP specific settings
|
||||
##
|
||||
|
||||
# If nothing happens for this long while client is IDLEing, move the connection
|
||||
# to imap-hibernate process and close the old imap process. This saves memory,
|
||||
# because connections use very little memory in imap-hibernate process. The
|
||||
# downside is that recreating the imap process back uses some resources.
|
||||
#imap_hibernate_timeout = 0
|
||||
|
||||
# Maximum IMAP command line length. Some clients generate very long command
|
||||
# lines with huge mailboxes, so you may need to raise this if you get
|
||||
# "Too long argument" or "IMAP command line too large" errors often.
|
||||
#imap_max_line_length = 64k
|
||||
|
||||
# IMAP logout format string:
|
||||
# %i - total number of bytes read from client
|
||||
# %o - total number of bytes sent to client
|
||||
# %{fetch_hdr_count} - Number of mails with mail header data sent to client
|
||||
# %{fetch_hdr_bytes} - Number of bytes with mail header data sent to client
|
||||
# %{fetch_body_count} - Number of mails with mail body data sent to client
|
||||
# %{fetch_body_bytes} - Number of bytes with mail body data sent to client
|
||||
# %{deleted} - Number of mails where client added \Deleted flag
|
||||
# %{expunged} - Number of mails that client expunged, which does not
|
||||
# include automatically expunged mails
|
||||
# %{autoexpunged} - Number of mails that were automatically expunged after
|
||||
# client disconnected
|
||||
# %{trashed} - Number of mails that client copied/moved to the
|
||||
# special_use=\Trash mailbox.
|
||||
# %{appended} - Number of mails saved during the session
|
||||
#imap_logout_format = in=%i out=%o
|
||||
|
||||
# Override the IMAP CAPABILITY response. If the value begins with '+',
|
||||
# add the given capabilities on top of the defaults (e.g. +XFOO XBAR).
|
||||
#imap_capability =
|
||||
|
||||
# How long to wait between "OK Still here" notifications when client is
|
||||
# IDLEing.
|
||||
#imap_idle_notify_interval = 2 mins
|
||||
imap_idle_notify_interval=4 mins
|
||||
|
||||
# ID field names and values to send to clients. Using * as the value makes
|
||||
# Dovecot use the default value. The following fields have default values
|
||||
# currently: name, version, os, os-version, support-url, support-email.
|
||||
#imap_id_send =
|
||||
|
||||
# ID fields sent by client to log. * means everything.
|
||||
#imap_id_log =
|
||||
|
||||
# Workarounds for various client bugs:
|
||||
# delay-newmail:
|
||||
# Send EXISTS/RECENT new mail notifications only when replying to NOOP
|
||||
# and CHECK commands. Some clients ignore them otherwise, for example OSX
|
||||
# Mail (<v2.1). Outlook Express breaks more badly though, without this it
|
||||
# may show user "Message no longer in server" errors. Note that OE6 still
|
||||
# breaks even with this workaround if synchronization is set to
|
||||
# "Headers Only".
|
||||
# tb-extra-mailbox-sep:
|
||||
# Thunderbird gets somehow confused with LAYOUT=fs (mbox and dbox) and
|
||||
# adds extra '/' suffixes to mailbox names. This option causes Dovecot to
|
||||
# ignore the extra '/' instead of treating it as invalid mailbox name.
|
||||
# tb-lsub-flags:
|
||||
# Show \Noselect flags for LSUB replies with LAYOUT=fs (e.g. mbox).
|
||||
# This makes Thunderbird realize they aren't selectable and show them
|
||||
# greyed out, instead of only later giving "not selectable" popup error.
|
||||
#
|
||||
# The list is space-separated.
|
||||
#imap_client_workarounds =
|
||||
|
||||
# Host allowed in URLAUTH URLs sent by client. "*" allows all.
|
||||
#imap_urlauth_host =
|
||||
|
||||
# What happens when FETCH fails due to some internal error:
|
||||
# disconnect-immediately:
|
||||
# The FETCH is aborted immediately and the IMAP client is disconnected.
|
||||
# disconnect-after:
|
||||
# The FETCH runs for all the requested mails returning as much data as
|
||||
# possible. The client is finally disconnected without a tagged reply.
|
||||
# no-after:
|
||||
# Same as disconnect-after, but tagged NO reply is sent instead of
|
||||
# disconnecting the client. If the client attempts to FETCH the same failed
|
||||
# mail more than once, the client is disconnected. This is to avoid clients
|
||||
# from going into infinite loops trying to FETCH a broken mail.
|
||||
#imap_fetch_failure = disconnect-immediately
|
||||
|
||||
mail_plugins = $mail_plugins quota
|
||||
protocol imap {
|
||||
# Space separated list of plugins to load (default is global mail_plugins).
|
||||
mail_plugins = $mail_plugins antispam imap_quota
|
||||
|
||||
# Maximum number of IMAP connections allowed for a user from each IP address.
|
||||
# NOTE: The username is compared case-sensitively.
|
||||
#mail_max_userip_connections = 10
|
||||
}
|
98
conf/dovecot/conf.d/90-quota.conf
Normal file
98
conf/dovecot/conf.d/90-quota.conf
Normal file
|
@ -0,0 +1,98 @@
|
|||
##
|
||||
## Quota configuration.
|
||||
##
|
||||
|
||||
# Note that you also have to enable quota plugin in mail_plugins setting.
|
||||
# <doc/wiki/Quota.txt>
|
||||
|
||||
##
|
||||
## Quota limits
|
||||
##
|
||||
|
||||
# Quota limits are set using "quota_rule" parameters. To get per-user quota
|
||||
# limits, you can set/override them by returning "quota_rule" extra field
|
||||
# from userdb. It's also possible to give mailbox-specific limits, for example
|
||||
# to give additional 100 MB when saving to Trash:
|
||||
|
||||
plugin {
|
||||
#quota_rule = *:storage=1G
|
||||
#quota_rule2 = Trash:storage=+100M
|
||||
|
||||
# LDA/LMTP allows saving the last mail to bring user from under quota to
|
||||
# over quota, if the quota doesn't grow too high. Default is to allow as
|
||||
# long as quota will stay under 10% above the limit. Also allowed e.g. 10M.
|
||||
#quota_grace = 10%%
|
||||
|
||||
# Quota plugin can also limit the maximum accepted mail size.
|
||||
#quota_max_mail_size = 100M
|
||||
}
|
||||
|
||||
##
|
||||
## Quota warnings
|
||||
##
|
||||
|
||||
# You can execute a given command when user exceeds a specified quota limit.
|
||||
# Each quota root has separate limits. Only the command for the first
|
||||
# exceeded limit is excecuted, so put the highest limit first.
|
||||
# The commands are executed via script service by connecting to the named
|
||||
# UNIX socket (quota-warning below).
|
||||
# Note that % needs to be escaped as %%, otherwise "% " expands to empty.
|
||||
|
||||
plugin {
|
||||
#quota_warning = storage=95%% quota-warning 95 %u
|
||||
#quota_warning2 = storage=80%% quota-warning 80 %u
|
||||
}
|
||||
|
||||
# Example quota-warning service. The unix listener's permissions should be
|
||||
# set in a way that mail processes can connect to it. Below example assumes
|
||||
# that mail processes run as vmail user. If you use mode=0666, all system users
|
||||
# can generate quota warnings to anyone.
|
||||
#service quota-warning {
|
||||
# executable = script /usr/local/bin/quota-warning.sh
|
||||
# user = dovecot
|
||||
# unix_listener quota-warning {
|
||||
# user = vmail
|
||||
# }
|
||||
#}
|
||||
|
||||
##
|
||||
## Quota backends
|
||||
##
|
||||
|
||||
# Multiple backends are supported:
|
||||
# dirsize: Find and sum all the files found from mail directory.
|
||||
# Extremely SLOW with Maildir. It'll eat your CPU and disk I/O.
|
||||
# dict: Keep quota stored in dictionary (eg. SQL)
|
||||
# maildir: Maildir++ quota
|
||||
# fs: Read-only support for filesystem quota
|
||||
|
||||
plugin {
|
||||
quota = maildir
|
||||
|
||||
quota_grace = 10%
|
||||
|
||||
quota_status_success = DUNNO
|
||||
quota_status_nouser = DUNNO
|
||||
quota_status_overquota = "522 5.2.2 Mailbox is full"
|
||||
|
||||
#quota = dirsize:User quota
|
||||
#quota = maildir:User quota
|
||||
#quota = dict:User quota::proxy::quota
|
||||
#quota = fs:User quota
|
||||
}
|
||||
|
||||
service quota-status {
|
||||
executable = quota-status -p postfix
|
||||
inet_listener {
|
||||
port = 12340
|
||||
}
|
||||
}
|
||||
|
||||
# Multiple quota roots are also possible, for example this gives each user
|
||||
# their own 100MB quota and one shared 1GB quota within the domain:
|
||||
plugin {
|
||||
#quota = dict:user::proxy::quota
|
||||
#quota2 = dict:domain:%d:proxy::quota_domain
|
||||
#quota_rule = *:storage=102400
|
||||
#quota2_rule = *:storage=1048576
|
||||
}
|
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 =
|
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
|
126
conf/ios-profile.xml
Normal file
126
conf/ios-profile.xml
Normal file
|
@ -0,0 +1,126 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
iOS/OS X Configuration Profile
|
||||
|
||||
Mobileconfig for iOS/OS X users to setup IMAP, SMTP, Contacts & Calendar
|
||||
|
||||
https://developer.apple.com/library/ios/featuredarticles/iPhoneConfigurationProfileRef/Introduction/Introduction.html
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CalDAVAccountDescription</key>
|
||||
<string>PRIMARY_HOSTNAME calendar</string>
|
||||
<key>CalDAVHostName</key>
|
||||
<string>PRIMARY_HOSTNAME</string>
|
||||
<key>CalDAVPort</key>
|
||||
<real>443</real>
|
||||
<key>CalDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>PayloadDescription</key>
|
||||
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>PRIMARY_HOSTNAME calendar</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.CalDAV</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.caldav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>UUID1</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>EmailAccountDescription</key>
|
||||
<string>PRIMARY_HOSTNAME mail</string>
|
||||
<key>EmailAccountType</key>
|
||||
<string>EmailTypeIMAP</string>
|
||||
<key>IncomingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>IncomingMailServerHostName</key>
|
||||
<string>PRIMARY_HOSTNAME</string>
|
||||
<key>IncomingMailServerPortNumber</key>
|
||||
<integer>993</integer>
|
||||
<key>IncomingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>OutgoingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>OutgoingMailServerHostName</key>
|
||||
<string>PRIMARY_HOSTNAME</string>
|
||||
<key>OutgoingMailServerPortNumber</key>
|
||||
<integer>465</integer>
|
||||
<key>OutgoingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||
<true/>
|
||||
<key>PayloadDescription</key>
|
||||
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>PRIMARY_HOSTNAME mail</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.E-Mail</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.mail.managed</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>UUID2</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PreventAppSheet</key>
|
||||
<false/>
|
||||
<key>PreventMove</key>
|
||||
<false/>
|
||||
<key>SMIMEEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CardDAVAccountDescription</key>
|
||||
<string>PRIMARY_HOSTNAME contacts</string>
|
||||
<key>CardDAVHostName</key>
|
||||
<string>PRIMARY_HOSTNAME</string>
|
||||
<key>CardDAVPort</key>
|
||||
<integer>443</integer>
|
||||
<key>CardDAVPrincipalURL</key>
|
||||
<string>/cloud/remote.php/carddav/addressbooks/</string>
|
||||
<key>CardDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>PayloadDescription</key>
|
||||
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>PRIMARY_HOSTNAME contacts</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME.carddav</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.carddav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>UUID3</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>PRIMARY_HOSTNAME (Mail-in-a-Box)</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>PRIMARY_HOSTNAME</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>email.mailinabox.mobileconfig.PRIMARY_HOSTNAME</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>UUID4</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
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,125 +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
|
||||
|
||||
# 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
|
44
conf/mozilla-autoconfig.xml
Normal file
44
conf/mozilla-autoconfig.xml
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="PRIMARY_HOSTNAME">
|
||||
<domain>PRIMARY_HOSTNAME</domain>
|
||||
|
||||
<displayName>PRIMARY_HOSTNAME (Mail-in-a-Box)</displayName>
|
||||
<displayShortName>PRIMARY_HOSTNAME</displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>PRIMARY_HOSTNAME</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<addThisServer>true</addThisServer>
|
||||
<useGlobalPreferredServer>false</useGlobalPreferredServer>
|
||||
</outgoingServer>
|
||||
|
||||
<documentation url="https://PRIMARY_HOSTNAME/">
|
||||
<descr lang="en">PRIMARY_HOSTNAME website.</descr>
|
||||
</documentation>
|
||||
</emailProvider>
|
||||
|
||||
<webMail>
|
||||
<loginPage url="https://PRIMARY_HOSTNAME/mail/" />
|
||||
<loginPageInfo url="https://PRIMARY_HOSTNAME/mail/" >
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<usernameField id="rcmloginuser" name="_user" />
|
||||
<passwordField id="rcmloginpwd" name="_pass" />
|
||||
<loginButton id="rcmloginsubmit" />
|
||||
</loginPageInfo>
|
||||
</webMail>
|
||||
|
||||
<clientConfigUpdate url="https://PRIMARY_HOSTNAME/.well-known/autoconfig/mail/config-v1.1.xml" />
|
||||
|
||||
</clientConfig>
|
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
|
58
conf/nginx-alldomains.conf
Normal file
58
conf/nginx-alldomains.conf
Normal file
|
@ -0,0 +1,58 @@
|
|||
root $ROOT;
|
||||
|
||||
# ADDITIONAL DIRECTIVES HERE
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
# 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-default;
|
||||
|
||||
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||
# file upload limit to match the corresponding Postfix limit.
|
||||
client_max_body_size 128M;
|
||||
}
|
||||
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-default;
|
||||
}
|
||||
|
||||
# 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;
|
||||
}
|
15
conf/nginx-openpgpkey.conf
Normal file
15
conf/nginx-openpgpkey.conf
Normal file
|
@ -0,0 +1,15 @@
|
|||
# ADDITIONAL DIRECTIVES HERE
|
||||
|
||||
# WKD Locations
|
||||
location ~ ^/.well-known/openpgpkey/(?<domain>.+)/policy$ {
|
||||
add_header Access-Control-Allow-origin * always;
|
||||
if (-d /var/lib/mailinabox/wkd/$domain/) {
|
||||
return 204;
|
||||
}
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ ^/.well-known/openpgpkey/(?<domain>.+)/hu/(?<keyid>[ybndrfg8ejkmcpqxot1uwisza345h769]+)$ {
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
alias /var/lib/mailinabox/wkd/$domain/$keyid;
|
||||
}
|
|
@ -1,49 +1,100 @@
|
|||
# ADDITIONAL DIRECTIVES HERE
|
||||
|
||||
# Control Panel
|
||||
# Proxy /admin to our Python based control panel daemon. It is
|
||||
# listening on IPv4 only so use an IP address and not 'localhost'.
|
||||
location /admin/assets {
|
||||
alias /usr/local/lib/mailinabox/vendor/assets;
|
||||
}
|
||||
rewrite ^/admin$ /admin/;
|
||||
rewrite ^/admin/munin$ /admin/munin/ redirect;
|
||||
location /admin/ {
|
||||
proxy_pass http://localhost:10222/;
|
||||
proxy_pass http://127.0.0.1:10222/;
|
||||
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.
|
||||
# Roundcube Webmail configuration.
|
||||
rewrite ^/mail$ /mail/ redirect;
|
||||
rewrite ^/mail/$ /mail/index.php;
|
||||
location /mail/ {
|
||||
index index.php;
|
||||
alias /usr/local/lib/roundcubemail/;
|
||||
}
|
||||
location ~ /mail/config/.* {
|
||||
# A ~-style location is needed to give this precedence over the next block.
|
||||
return 403;
|
||||
}
|
||||
location ~ /mail/.*\.php {
|
||||
# note: ~ has precendence over a regular location block
|
||||
include fastcgi_params;
|
||||
fastcgi_split_path_info ^/mail(/.*)()$;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/roundcubemail/$fastcgi_script_name;
|
||||
fastcgi_pass php-default;
|
||||
|
||||
# Outgoing mail also goes through this endpoint, so increase the maximum
|
||||
# file upload limit to match the corresponding Postfix limit.
|
||||
client_max_body_size 128M;
|
||||
}
|
||||
|
||||
# Nextcloud configuration.
|
||||
rewrite ^/cloud$ /cloud/ redirect;
|
||||
rewrite ^/cloud/$ /cloud/index.php;
|
||||
rewrite ^/cloud/(contacts|calendar|files)$ /cloud/index.php/apps/$1/ redirect;
|
||||
rewrite ^(/cloud/core/doc/[^\/]+/)$ $1/index.html;
|
||||
rewrite ^(/cloud/oc[sm]-provider)/$ $1/index.php redirect;
|
||||
location /cloud/ {
|
||||
alias /usr/local/lib/owncloud/;
|
||||
location ~ ^/(data|config|\.ht|db_structure\.xml|README) {
|
||||
location ~ ^/cloud/(build|tests|config|lib|3rdparty|templates|data|README)/ {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
location ~ ^/cloud/(?:\.|autotest|occ|issue|indie|db_|console) {
|
||||
deny all;
|
||||
}
|
||||
# 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-default;
|
||||
}
|
||||
}
|
||||
location ~ ^(/cloud)(/[^/]+\.php)(/.*)?$ {
|
||||
location ~ ^(/cloud)((?:/ocs)?/[^/]+\.php)(/.*)?$ {
|
||||
# note: ~ has precendence over a regular location block
|
||||
# Accept URLs like:
|
||||
# /cloud/index.php/apps/files/
|
||||
# /cloud/index.php/apps/files/ajax/scan.php (it's really index.php; see 6fdef379adfdeac86cc2220209bdf4eb9562268d)
|
||||
# /cloud/ocs/v1.php/apps/files_sharing/api/v1 (see #240)
|
||||
# /cloud/remote.php/webdav/yourfilehere...
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/lib/owncloud/$2;
|
||||
fastcgi_param SCRIPT_NAME $1$2;
|
||||
fastcgi_param PATH_INFO $3;
|
||||
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
|
||||
fastcgi_param MOD_X_ACCEL_REDIRECT_PREFIX /owncloud-xaccel;
|
||||
fastcgi_read_timeout 630;
|
||||
fastcgi_pass php-fpm;
|
||||
error_page 403 /cloud/core/templates/403.php;
|
||||
error_page 404 /cloud/core/templates/404.php;
|
||||
fastcgi_pass php-default;
|
||||
client_max_body_size 1G;
|
||||
fastcgi_buffers 64 4K;
|
||||
}
|
||||
location ^~ /cloud/data {
|
||||
# In order to support MOD_X_ACCEL_REDIRECT_ENABLED, we need to expose
|
||||
# the data directory but only allow 'internal' redirects within nginx
|
||||
# so that this is not exposed to the world.
|
||||
location ^~ /owncloud-xaccel/ {
|
||||
# This directory is for MOD_X_ACCEL_REDIRECT_ENABLED. Nextcloud sends the full file
|
||||
# path on disk as a subdirectory under this virtual path.
|
||||
# We must only allow 'internal' redirects within nginx so that the filesystem
|
||||
# is not exposed to the world.
|
||||
internal;
|
||||
alias $STORAGE_ROOT/owncloud;
|
||||
alias /;
|
||||
}
|
||||
location ~ ^/((caldav|carddav|webdav).*)$ {
|
||||
# Z-Push doesn't like getting a redirect, and a plain rewrite didn't work either.
|
||||
# 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.json /cloud/public.php?service=host-meta-json last;
|
||||
rewrite ^/.well-known/carddav /cloud/remote.php/carddav/ redirect;
|
||||
rewrite ^/.well-known/caldav /cloud/remote.php/caldav/ redirect;
|
||||
|
||||
|
|
|
@ -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
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
keepalive_timeout 70;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# Buffer size of 1400 bytes fits in one MTU.
|
||||
# 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 /path/to/dhparam2048.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_verify on;
|
||||
resolver 8.8.8.8 8.8.4.4 valid=86400;
|
||||
resolver 127.0.0.1 valid=86400;
|
||||
resolver_timeout 10;
|
||||
#ssl_trusted_certificate /path/to/all-certs-in-chain.crt;
|
||||
|
||||
# h/t https://gist.github.com/konklone/6532544
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
## If you modify any system configuration you are on
|
||||
## your own --- please do not ask for help from us.
|
||||
|
||||
upstream php-fpm {
|
||||
server unix:/var/run/php5-fpm.sock;
|
||||
# This socket is reserved for Mail-in-a-Box-specific services
|
||||
upstream php-default {
|
||||
server unix:/var/run/php/php-default.sock;
|
||||
}
|
||||
|
||||
upstream php-fpm {
|
||||
server unix:/var/run/php/php{{phpver}}-fpm.sock;
|
||||
}
|
||||
|
|
|
@ -1,73 +1,47 @@
|
|||
## $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 {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name $HOSTNAME;
|
||||
root /tmp/invalid-path-nothing-here;
|
||||
rewrite ^/(.*)$ https://$HOSTNAME/$1 permanent;
|
||||
|
||||
# Improve privacy: Hide version an OS information on
|
||||
# error pages and in the "Server" HTTP-Header.
|
||||
server_tokens off;
|
||||
|
||||
location / {
|
||||
# Redirect using the 'return' directive and the built-in
|
||||
# variable '$request_uri' to avoid any capturing, matching
|
||||
# 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.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
server_name $HOSTNAME;
|
||||
|
||||
# Improve privacy: Hide version an OS information on
|
||||
# error pages and in the "Server" HTTP-Header.
|
||||
server_tokens off;
|
||||
|
||||
ssl_certificate $SSL_CERTIFICATE;
|
||||
ssl_certificate_key $SSL_KEY;
|
||||
include /etc/nginx/nginx-ssl.conf;
|
||||
|
||||
# Expose this directory as static files.
|
||||
root $ROOT;
|
||||
index index.html index.htm;
|
||||
|
||||
# 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;
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
# Webfinger configuration.
|
||||
location = /.well-known/webfinger {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/local/bin/mailinabox-webfinger.php;
|
||||
fastcgi_pass php-fpm;
|
||||
}
|
||||
|
||||
# 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;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
|
||||
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is
|
||||
# where the user's home IP address would be.
|
||||
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (unknown [127.0.0.1])$1
|
||||
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user (PRIMARY_HOSTNAME [PUBLIC_IP])$1
|
||||
|
||||
# Remove other typically private information.
|
||||
/^\s*User-Agent:/ IGNORE
|
||||
/^\s*X-Enigmail:/ IGNORE
|
||||
/^\s*X-Mailer:/ IGNORE
|
||||
/^\s*X-Originating-IP:/ IGNORE
|
||||
/^\s*X-Pgp-Agent:/ IGNORE
|
||||
|
||||
# The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)).
|
||||
/^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
require ["regex", "fileinto", "imap4flags"];
|
||||
|
||||
if allof (header :regex "X-Spam-Status" "^Yes") {
|
||||
setflag "\\Seen";
|
||||
fileinto "Spam";
|
||||
stop;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,106 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>this is a mail-in-a-box</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>this is a mail-in-a-box</h1>
|
||||
<p>take control of your email at <a href="https://mailinabox.email/">https://mailinabox.email/</a></p>
|
||||
</body>
|
||||
|
||||
<head>
|
||||
<title>Power Mail-in-a-Box</title>
|
||||
<meta charset="utf-8" name="robots" content="noindex">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
li {
|
||||
font-size: 15pt
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
font-size: 20pt;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#small td {
|
||||
font-size: 12.5pt;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 3.5rem;
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
footer p {
|
||||
font-size: 10pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id=content>
|
||||
<h1 align=center>It's working! 🎉🎉🎉</h1>
|
||||
<p align=center>It is running! Now you can begin your <b>very</b> awesome thing!
|
||||
<p>
|
||||
|
||||
<h2 align=center>What is this Power Mail-in-a-Box able to do for you?</h2>
|
||||
<ul>
|
||||
<li>Send and receive mail (duh);</li>
|
||||
<li>Be your <a href="https://wiki.gnupg.org/WKD">Web Key Directory</a> server, if you're into GnuPG and all
|
||||
that jazz. No need to rely on public keyservers!</li>
|
||||
<li>Also be your web server - if you want:</li>
|
||||
<ul>
|
||||
<li>Static sites? No problem!</li>
|
||||
<li>Front-end + back-end? No problem!</li>
|
||||
<li>PHP? <b>Did I stutter?</b> We got you!</li>
|
||||
<li>How many sites can I host at the same time? How many you want (provided your server can handle all
|
||||
of them, of course)</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<h2 align=center>Let's begin, then?</h2>
|
||||
<p align=center>This Power Mail-in-a-Box was configured with the name <code>{{PRIMARY_HOSTNAME}}</code>.</p>
|
||||
<table align=center>
|
||||
<thead>
|
||||
<th>To access the admin panel...</th>
|
||||
<th>To access the webmail...</th>
|
||||
<th>To access NextCloud...</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/admin">Go here</a></b></td>
|
||||
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/mail">Go here</a></b></td>
|
||||
<td align=center><b><a href="https://{{PRIMARY_HOSTNAME}}/cloud">Go here</a></b></td>
|
||||
</tr>
|
||||
<tr id=small>
|
||||
<td align=center><code>{{PRIMARY_HOSTNAME}}/admin</code></td>
|
||||
<td align=center><code>{{PRIMARY_HOSTNAME}}/mail</code></td>
|
||||
<td align=center><code>{{PRIMARY_HOSTNAME}}/cloud</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br><br>
|
||||
|
||||
<h2 align=center>Oh and by the way: thank you for sticking by!</h2>
|
||||
<p align=center>This project is maintained <a href="https://power-mailinabox.net">on GitHub</a>.
|
||||
Feel free to take a peek and maybe leave a star. It is very much appreciated! ❤</p>
|
||||
<p align=center>Also feel free report any issues you may find, or leave your ideas.</p>
|
||||
</div>
|
||||
<footer>
|
||||
<p align=center>
|
||||
Unless you moved the folder elsewhere, or you manually added this file, it is located at
|
||||
<code>{{STORAGE_ROOT}}/www/default/index.html</code> on the server - feel free to remove it whenever you
|
||||
want.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
@ -5,11 +5,12 @@
|
|||
* Descr : Autodiscover configuration file
|
||||
************************************************/
|
||||
|
||||
define('TIMEZONE', '');
|
||||
|
||||
// Defines the base path on the server
|
||||
define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']). '/');
|
||||
|
||||
// The Z-Push server location for the autodiscover response
|
||||
define('SERVERURL', 'https://PRIMARY_HOSTNAME/Microsoft-Server-ActiveSync');
|
||||
define('ZPUSH_HOST', 'PRIMARY_HOSTNAME');
|
||||
|
||||
define('USE_FULLEMAIL_FOR_LOGIN', true);
|
||||
|
||||
|
@ -18,6 +19,7 @@ define('LOGFILE', LOGFILEDIR . 'autodiscover.log');
|
|||
define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');
|
||||
define('LOGLEVEL', LOGLEVEL_INFO);
|
||||
define('LOGUSERLEVEL', LOGLEVEL);
|
||||
$specialLogUsers = array();
|
||||
|
||||
// the backend data provider
|
||||
define('BACKEND_PROVIDER', 'BackendCombined');
|
||||
|
|
|
@ -5,14 +5,12 @@
|
|||
* 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_PATH', '/caldav/calendars/%u/');
|
||||
define('CALDAV_PERSONAL', '');
|
||||
|
||||
// If the CalDAV server supports the sync-collection operation
|
||||
// DAViCal and SOGo support it
|
||||
// Setting this to false will work with most servers, but it will be slower
|
||||
define('CALDAV_PERSONAL', 'PRINCIPAL');
|
||||
define('CALDAV_SUPPORTS_SYNC', false);
|
||||
define('CALDAV_MAX_SYNC_PERIOD', 2147483647);
|
||||
|
||||
?>
|
||||
|
|
|
@ -7,23 +7,17 @@
|
|||
|
||||
|
||||
define('CARDDAV_PROTOCOL', 'https'); /* http or https */
|
||||
define('CARDDAV_SERVER', 'localhost');
|
||||
define('CARDDAV_SERVER', '127.0.0.1');
|
||||
define('CARDDAV_PORT', '443');
|
||||
define('CARDDAV_PATH', '/carddav/addressbooks/%u/');
|
||||
define('CARDDAV_DEFAULT_PATH', '/carddav/addressbooks/%u/contacts/'); /* subdirectory of the main path */
|
||||
define('CARDDAV_GAL_PATH', ''); /* readonly, searchable, not syncd */
|
||||
define('CARDDAV_GAL_MIN_LENGTH', 5);
|
||||
define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
|
||||
|
||||
|
||||
// If the CardDAV server supports the sync-collection operation
|
||||
// DAViCal supports it, but SabreDav, Owncloud, SOGo don't
|
||||
// Setting this to false will work with most servers, but it will be slower: 1 petition for the href of vcards, and 1 petition for each vcard
|
||||
define('CARDDAV_SUPPORTS_SYNC', false);
|
||||
|
||||
|
||||
// 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
|
||||
// It's safe to leave it as false
|
||||
define('CARDDAV_SUPPORTS_FN_SEARCH', false);
|
||||
|
|
|
@ -5,19 +5,37 @@
|
|||
* Descr : IMAP backend configuration file
|
||||
************************************************/
|
||||
|
||||
define('IMAP_SERVER', 'localhost');
|
||||
define('IMAP_SERVER', '127.0.0.1');
|
||||
define('IMAP_PORT', 993);
|
||||
define('IMAP_OPTIONS', '/ssl/norsh/novalidate-cert');
|
||||
define('IMAP_DEFAULTFROM', '');
|
||||
define('IMAP_DEFAULTFROM', 'sql');
|
||||
|
||||
// not used
|
||||
define('IMAP_FROM_SQL_DSN', '');
|
||||
define('SYSTEM_MIME_TYPES_MAPPING', '/etc/mime.types');
|
||||
define('IMAP_AUTOSEEN_ON_DELETE', false);
|
||||
define('IMAP_FOLDER_CONFIGURED', true);
|
||||
define('IMAP_FOLDER_PREFIX', '');
|
||||
define('IMAP_FOLDER_PREFIX_IN_INBOX', false);
|
||||
// see our conf/dovecot-mailboxes.conf file for IMAP special flags settings
|
||||
define('IMAP_FOLDER_INBOX', 'INBOX');
|
||||
define('IMAP_FOLDER_SENT', 'SENT');
|
||||
define('IMAP_FOLDER_DRAFT', 'DRAFTS');
|
||||
define('IMAP_FOLDER_TRASH', 'TRASH');
|
||||
define('IMAP_FOLDER_SPAM', 'SPAM');
|
||||
define('IMAP_FOLDER_ARCHIVE', 'ARCHIVE');
|
||||
|
||||
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_PASSWORD', '');
|
||||
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_FIELDS', serialize(array('first_name', 'last_name', 'mail_address')));
|
||||
define('IMAP_FROM_SQL_FROM', '#first_name #last_name <#mail_address>');
|
||||
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('name', 'email')));
|
||||
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_PORT', '389');
|
||||
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_FIELDS', serialize(array('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');
|
||||
|
||||
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('IMAP_MEETING_USE_CALDAV', true);
|
||||
|
||||
?>
|
||||
|
|
|
@ -1,109 +1,180 @@
|
|||
import base64, os, os.path
|
||||
import base64
|
||||
import os
|
||||
import os.path
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import make_response
|
||||
from expiringdict import ExpiringDict
|
||||
|
||||
import utils
|
||||
from mailconfig import get_mail_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'
|
||||
|
||||
class KeyAuthService:
|
||||
"""Generate an API key for authenticating clients
|
||||
|
||||
Clients must read the key from the key file and send the key with all HTTP
|
||||
requests. The key is passed as the username field in the standard HTTP
|
||||
Basic Auth header.
|
||||
"""
|
||||
class AuthService:
|
||||
|
||||
def __init__(self):
|
||||
self.auth_realm = DEFAULT_AUTH_REALM
|
||||
self.key = self._generate_key()
|
||||
self.key_path = DEFAULT_KEY_PATH
|
||||
self.max_session_duration = timedelta(days=2)
|
||||
|
||||
def write_key(self):
|
||||
"""Write key to file so authorized clients can get the key
|
||||
self.init_system_api_key()
|
||||
self.sessions = ExpiringDict(
|
||||
max_len=64,
|
||||
max_age_seconds=self.max_session_duration.total_seconds())
|
||||
|
||||
The key file is created with mode 0640 so that additional users can be
|
||||
authorized to access the API by granting group/ACL read permissions on
|
||||
the key file.
|
||||
"""
|
||||
def create_file_with_mode(path, mode):
|
||||
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748
|
||||
old_umask = os.umask(0)
|
||||
try:
|
||||
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
def init_system_api_key(self):
|
||||
"""Write an API key to a local file so local processes can use the API"""
|
||||
|
||||
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
|
||||
with open(self.key_path, 'r') as file:
|
||||
self.key = file.read()
|
||||
|
||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
||||
key_file.write(self.key + '\n')
|
||||
def authenticate(self, request, env, login_only=False, logout=False):
|
||||
"""Test if the HTTP Authorization header's username matches the system key, a session key,
|
||||
or if the username/password passed in the header matches a local user.
|
||||
Returns a tuple of the user's email address and list of user privileges (e.g.
|
||||
('my@email', []) or ('my@email', ['admin']); raises a ValueError on login failure.
|
||||
If the user used the system API key, the user's email is returned as None since
|
||||
this key is not associated with a user."""
|
||||
|
||||
def is_authenticated(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 'OK' if the key is good or the user is an administrator, otherwise an error message."""
|
||||
def parse_http_authorization_basic(header):
|
||||
|
||||
def decode(s):
|
||||
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:
|
||||
return None, None
|
||||
scheme, credentials = header.split(maxsplit=1)
|
||||
if scheme != 'Basic':
|
||||
return None, None
|
||||
|
||||
credentials = decode(credentials)
|
||||
if ":" not in credentials:
|
||||
return None, None
|
||||
username, password = credentials.split(':', maxsplit=1)
|
||||
return username, password
|
||||
|
||||
header = request.headers.get('Authorization')
|
||||
if not header:
|
||||
return "No authorization header provided."
|
||||
|
||||
username, password = parse_basic_auth(header)
|
||||
|
||||
username, password = parse_http_authorization_basic(
|
||||
request.headers.get('Authorization', ''))
|
||||
if username in (None, ""):
|
||||
return "Authorization header invalid."
|
||||
elif username == self.key:
|
||||
return "OK"
|
||||
raise ValueError("Authorization header invalid.")
|
||||
|
||||
if username.strip() == "" and password.strip() == "":
|
||||
raise ValueError(
|
||||
"No email address, password, session key, or API key provided."
|
||||
)
|
||||
|
||||
# If user passed the system API key, grant administrative privs. This key
|
||||
# is not associated with a user.
|
||||
if username == self.key and not login_only:
|
||||
return (None, ["admin"])
|
||||
|
||||
# If the password corresponds with a session token for the user, grant access for that user.
|
||||
if self.get_session(username, password, "login",
|
||||
env) and not login_only:
|
||||
sessionid = password
|
||||
session = self.sessions[sessionid]
|
||||
if logout:
|
||||
# Clear the session.
|
||||
del self.sessions[sessionid]
|
||||
else:
|
||||
# Re-up the session so that it does not expire.
|
||||
self.sessions[sessionid] = session
|
||||
|
||||
# If no password was given, but a username was given, we're missing some information.
|
||||
elif password.strip() == "":
|
||||
raise ValueError("Enter a password.")
|
||||
|
||||
else:
|
||||
return self.check_imap_login( username, password, env)
|
||||
# The user is trying to log in with a username and a password
|
||||
# (and possibly a MFA token). On failure, an exception is raised.
|
||||
self.check_user_auth(username, password, request, env)
|
||||
|
||||
def check_imap_login(self, email, pw, env):
|
||||
# Validate a user's credentials.
|
||||
# Get privileges for authorization. This call should never fail because by this
|
||||
# point we know the email address is a valid user --- unless the user has been
|
||||
# deleted after the session was granted. On error the call will return a tuple
|
||||
# of an error message and an HTTP status code.
|
||||
privs = get_mail_user_privileges(username, env)
|
||||
if isinstance(privs, tuple):
|
||||
raise ValueError(privs[0])
|
||||
|
||||
# Sanity check.
|
||||
if email == "" or pw == "":
|
||||
return "Enter an email address and password."
|
||||
# Return the authorization information.
|
||||
return (username, privs)
|
||||
|
||||
def check_user_auth(self, email, pw, request, env):
|
||||
# Validate a user's login email address and password. If MFA is enabled,
|
||||
# check the MFA token in the X-Auth-Token header.
|
||||
#
|
||||
# On login failure, raises a ValueError with a login error message. On
|
||||
# success, nothing is returned.
|
||||
|
||||
# Authenticate.
|
||||
try:
|
||||
# Use doveadm to check credentials. doveadm will return
|
||||
# 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",
|
||||
"auth", "test",
|
||||
email, pw
|
||||
])
|
||||
"pw",
|
||||
"-p",
|
||||
pw,
|
||||
"-t",
|
||||
pw_hash,
|
||||
])
|
||||
except:
|
||||
# Login failed.
|
||||
return "Invalid email address or password."
|
||||
raise ValueError("Incorrect email address or password.")
|
||||
|
||||
# Authorize.
|
||||
# (This call should never fail on a valid user.)
|
||||
privs = get_mail_user_privileges(email, env)
|
||||
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
||||
if "admin" not in privs:
|
||||
return "You are not an administrator for this system."
|
||||
# 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))
|
||||
|
||||
return "OK"
|
||||
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")
|
||||
|
||||
def _generate_key(self):
|
||||
raw_key = os.urandom(32)
|
||||
return base64.b64encode(raw_key).decode('ascii')
|
||||
# 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,95 +1,190 @@
|
|||
#!/usr/bin/python3
|
||||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
|
||||
# This script performs a backup of all user data:
|
||||
# 1) System services are stopped while a copy of user data is made.
|
||||
# 2) An incremental backup is made using duplicity into the
|
||||
# directory STORAGE_ROOT/backup/duplicity.
|
||||
# 3) The stopped services are restarted.
|
||||
# 4) The backup files are encrypted with a long password (stored in
|
||||
# backup/secret_key.txt) to STORAGE_ROOT/backup/encrypted.
|
||||
# 5) STORAGE_ROOT/backup/after-backup is executd if it exists.
|
||||
# 1) System services are stopped.
|
||||
# 2) STORAGE_ROOT/backup/before-backup is executed if it exists.
|
||||
# 3) An incremental encrypted backup is made using duplicity.
|
||||
# 4) The stopped services are restarted.
|
||||
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
|
||||
|
||||
import os, os.path, shutil, glob, re, datetime
|
||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import glob
|
||||
import re
|
||||
import datetime
|
||||
import sys
|
||||
import dateutil.parser
|
||||
import dateutil.relativedelta
|
||||
import dateutil.tz
|
||||
import rtyaml
|
||||
from exclusiveprocess import Lock, CannotAcquireLock
|
||||
|
||||
from utils import exclusive_process, load_environment, shell
|
||||
|
||||
# destroy backups when the most recent increment in the chain
|
||||
# that depends on it is this many days old.
|
||||
keep_backups_for_days = 14
|
||||
from utils import load_environment, shell, wait_for_service, get_php_version
|
||||
|
||||
def backup_status(env):
|
||||
# What is the current status of backups?
|
||||
# Loop through all of the files in STORAGE_ROOT/backup/duplicity to
|
||||
# get a list of all of the backups taken and sum up file sizes to
|
||||
# see how large the storage is.
|
||||
# If backups are disabled, return no status.
|
||||
config = get_backup_config(env)
|
||||
if config["target"] == "off":
|
||||
return {}
|
||||
|
||||
# Query duplicity to get a list of all full and incremental
|
||||
# backups available.
|
||||
|
||||
backups = {}
|
||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||
def reldate(date, ref):
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||
|
||||
def reldate(date, ref, clip):
|
||||
if ref < date:
|
||||
return clip
|
||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||
if rd.days >= 7: return "%d days" % rd.days
|
||||
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
|
||||
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||
if rd.years > 1:
|
||||
return "%d years, %d months" % (rd.years, rd.months)
|
||||
if rd.years == 1:
|
||||
return "%d year, %d months" % (rd.years, rd.months)
|
||||
if rd.months > 1:
|
||||
return "%d months, %d days" % (rd.months, rd.days)
|
||||
if rd.months == 1:
|
||||
return "%d month, %d days" % (rd.months, rd.days)
|
||||
if rd.days >= 7:
|
||||
return "%d days" % rd.days
|
||||
if rd.days > 1:
|
||||
return "%d days, %d hours" % (rd.days, rd.hours)
|
||||
if rd.days == 1:
|
||||
return "%d day, %d hours" % (rd.days, rd.hours)
|
||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||
|
||||
backups = { }
|
||||
basedir = os.path.join(env['STORAGE_ROOT'], 'backup/duplicity/')
|
||||
encdir = os.path.join(env['STORAGE_ROOT'], 'backup/encrypted/')
|
||||
for fn in os.listdir(basedir):
|
||||
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)
|
||||
# Get duplicity collection status and parse for a list of backups.
|
||||
def parse_line(line):
|
||||
keys = line.strip().split()
|
||||
date = dateutil.parser.parse(keys[1]).astimezone(dateutil.tz.tzlocal())
|
||||
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
|
||||
# number of archive volumes for this backup (not really helpful)
|
||||
"volumes": int(keys[2]),
|
||||
}
|
||||
|
||||
code, collection_status = shell('check_output', [
|
||||
"/usr/local/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)
|
||||
if not m:
|
||||
continue # not a part of a current backup chain
|
||||
key = m.group("date")
|
||||
if key not in backups:
|
||||
date = dateutil.parser.parse(m.group("date"))
|
||||
backups[key] = {
|
||||
"date": m.group("date"),
|
||||
"date_str": date.strftime("%x %X"),
|
||||
"date_delta": reldate(date, now),
|
||||
"full": m.group("incbase") is None,
|
||||
"previous": m.group("incbase"),
|
||||
"size": 0,
|
||||
"encsize": 0,
|
||||
}
|
||||
|
||||
backups[key]["size"] += os.path.getsize(os.path.join(basedir, fn))
|
||||
|
||||
# Also check encrypted size.
|
||||
encfn = os.path.join(encdir, fn + ".enc")
|
||||
if os.path.exists(encfn):
|
||||
backups[key]["encsize"] += os.path.getsize(encfn)
|
||||
if key in backups:
|
||||
backups[key]["size"] += size
|
||||
else:
|
||||
unmatched_file_size += size
|
||||
|
||||
# Ensure the rows are sorted reverse chronologically.
|
||||
# This is relied on by should_force_full() and the next step.
|
||||
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
||||
backups = sorted(backups.values(), key=lambda b: b["date"], reverse=True)
|
||||
|
||||
# When will a backup be deleted?
|
||||
saw_full = False
|
||||
# Get the average size of incremental backups, the size of the
|
||||
# most recent full backup, and the date of the most recent
|
||||
# backup and the most recent full backup.
|
||||
incremental_count = 0
|
||||
incremental_size = 0
|
||||
first_date = None
|
||||
first_full_size = None
|
||||
first_full_date = None
|
||||
for bak in backups:
|
||||
if first_date is None:
|
||||
first_date = dateutil.parser.parse(bak["date"])
|
||||
if bak["full"]:
|
||||
first_full_size = bak["size"]
|
||||
first_full_date = dateutil.parser.parse(bak["date"])
|
||||
break
|
||||
incremental_count += 1
|
||||
incremental_size += bak["size"]
|
||||
|
||||
# When will the most recent backup be deleted? It won't be deleted if the next
|
||||
# backup is incremental, because the increments rely on all past increments.
|
||||
# So first guess how many more incremental backups will occur until the next
|
||||
# full backup. That full backup frees up this one to be deleted. But, the backup
|
||||
# must also be at least min_age_in_days old too.
|
||||
deleted_in = None
|
||||
days_ago = now - datetime.timedelta(days=keep_backups_for_days)
|
||||
if incremental_count > 0 and incremental_size > 0 and first_full_size is not None:
|
||||
# How many days until the next incremental backup? First, the part of
|
||||
# the algorithm based on increment sizes:
|
||||
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (
|
||||
incremental_size / incremental_count)
|
||||
est_time_of_next_full = first_date + \
|
||||
datetime.timedelta(days=est_days_to_next_full)
|
||||
|
||||
# ...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
|
||||
for bak in backups:
|
||||
if deleted_in:
|
||||
# Subsequent backups are deleted when the most recent increment
|
||||
# in the chain would be deleted.
|
||||
# The most recent increment in a chain and all of the previous backups
|
||||
# it relies on are deleted at the same time.
|
||||
bak["deleted_in"] = deleted_in
|
||||
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
|
||||
deleted_in = None
|
||||
elif saw_full and not deleted_in:
|
||||
# Mark deleted_in only on the first increment after a full backup.
|
||||
deleted_in = reldate(days_ago, dateutil.parser.parse(bak["date"]))
|
||||
# We're now on backups prior to the most recent full backup. These are
|
||||
# free to be deleted as soon as they are min_age_in_days old.
|
||||
deleted_in = reldate(
|
||||
now,
|
||||
dateutil.parser.parse(bak["date"]) +
|
||||
datetime.timedelta(days=config["min_age_in_days"]),
|
||||
"on next daily backup")
|
||||
bak["deleted_in"] = deleted_in
|
||||
|
||||
return {
|
||||
"directory": basedir,
|
||||
"encpwfile": os.path.join(env['STORAGE_ROOT'], 'backup/secret_key.txt'),
|
||||
"encdirectory": encdir,
|
||||
"tz": now.tzname(),
|
||||
"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
|
||||
# since the last full backup is greater than half the size
|
||||
# of that full backup.
|
||||
|
@ -101,108 +196,491 @@ def should_force_full(env):
|
|||
inc_size += bak["size"]
|
||||
else:
|
||||
# ...until we reach the most recent full backup.
|
||||
# Return if we should to a full backup.
|
||||
return inc_size > .5*bak["size"]
|
||||
# Return if we should to a full backup, which is based
|
||||
# on the size of the increments relative to the full
|
||||
# backup, as well as the age of the full backup.
|
||||
if inc_size > .5 * bak["size"]:
|
||||
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:
|
||||
# If we got here there are no (full) backups, so make one.
|
||||
# (I love for/else blocks. Here it's just to show off.)
|
||||
return True
|
||||
|
||||
def perform_backup(full_backup):
|
||||
|
||||
def get_passphrase(env):
|
||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||
# bits base64-encoded and with line breaks every 65 characters.
|
||||
# gpg will only take the first line of text, so sanity check that
|
||||
# that line is long enough to be a reasonable passphrase. It
|
||||
# only needs to be 43 base64-characters to match AES256's key
|
||||
# length of 32 bytes.
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||
passphrase = f.readline().strip()
|
||||
if len(passphrase) < 43:
|
||||
raise Exception("secret_key.txt's first line is too short!")
|
||||
|
||||
return passphrase
|
||||
|
||||
def get_duplicity_target_url(config):
|
||||
target = config["target"]
|
||||
|
||||
if get_target_type(config) == "s3":
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
target = list(urlsplit(target))
|
||||
|
||||
# Although we store the S3 hostname in the target URL,
|
||||
# duplicity no longer accepts it in the target URL. The hostname in
|
||||
# the target URL must be the bucket name. The hostname is passed
|
||||
# via get_duplicity_additional_args. Move the first part of the
|
||||
# path (the bucket name) into the hostname URL component, and leave
|
||||
# the rest for the path.
|
||||
target_bucket = target[2].lstrip('/').split('/', 1)
|
||||
target[1] = target_bucket[0]
|
||||
target[2] = target_bucket[1] if len(target_bucket) > 1 else ''
|
||||
|
||||
target = urlunsplit(target)
|
||||
|
||||
return target
|
||||
|
||||
def get_duplicity_additional_args(env):
|
||||
config = get_backup_config(env)
|
||||
port = 0
|
||||
|
||||
try:
|
||||
port = int(config["target_rsync_port"])
|
||||
except Exception:
|
||||
port = 22
|
||||
|
||||
if get_target_type(config) == 'rsync':
|
||||
return [
|
||||
f"--ssh-options= -i /root/.ssh/id_rsa_miab -p {port}",
|
||||
f"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"",
|
||||
]
|
||||
elif get_target_type(config) == 's3':
|
||||
# See note about hostname in get_duplicity_target_url.
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
target = urlsplit(config["target"])
|
||||
endpoint_url = urlunsplit(("https", target.netloc, '', '', ''))
|
||||
return ["--s3-endpoint-url", endpoint_url]
|
||||
|
||||
return []
|
||||
|
||||
def get_duplicity_env_vars(env):
|
||||
config = get_backup_config(env)
|
||||
|
||||
env = {"PASSPHRASE": get_passphrase(env)}
|
||||
|
||||
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, user_initiated=False):
|
||||
env = load_environment()
|
||||
php_fpm = f"php{get_php_version()}-fpm"
|
||||
|
||||
exclusive_process("backup")
|
||||
# Create an global exclusive lock so that the backup script
|
||||
# cannot be run more than one.
|
||||
lock = Lock(name="mailinabox_backup_daemon", die=(not user_initiated))
|
||||
if user_initiated:
|
||||
# God forgive me for what I'm about to do
|
||||
try:
|
||||
lock._acquire()
|
||||
except CannotAcquireLock:
|
||||
return "Another backup is already being done!"
|
||||
else:
|
||||
lock.forever()
|
||||
|
||||
# Ensure the backup directory exists.
|
||||
backup_dir = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
backup_duplicity_dir = os.path.join(backup_dir, 'duplicity')
|
||||
os.makedirs(backup_duplicity_dir, exist_ok=True)
|
||||
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.
|
||||
full_backup = full_backup or should_force_full(env)
|
||||
try:
|
||||
full_backup = full_backup or should_force_full(config, env)
|
||||
except Exception as e:
|
||||
# This was the first call to duplicity, and there might
|
||||
# be an error already.
|
||||
print(e)
|
||||
sys.exit(1)
|
||||
|
||||
# Stop services.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "stop"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "stop"])
|
||||
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)
|
||||
|
||||
# Update the backup mirror directory which mirrors the current
|
||||
# STORAGE_ROOT (but excluding the backups themselves!).
|
||||
service_command(php_fpm, "stop", quit=True)
|
||||
service_command("postfix", "stop", quit=True)
|
||||
service_command("dovecot", "stop", quit=True)
|
||||
service_command("postgrey", "stop", quit=True)
|
||||
|
||||
# Execute a pre-backup script that copies files outside the homedir.
|
||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||
# 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!).
|
||||
# --allow-source-mismatch is needed in case the box's hostname is changed
|
||||
# after the first backup. See #396.
|
||||
try:
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"/usr/local/bin/duplicity",
|
||||
"full" if full_backup else "incr",
|
||||
"--no-encryption",
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"--exclude", backup_dir,
|
||||
"--volsize", "100",
|
||||
"--verbosity", "warning",
|
||||
"--verbosity", "warning", "--no-print-statistics",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--exclude", backup_root,
|
||||
"--volsize", "250",
|
||||
"--gpg-options", "--cipher-algo=AES256",
|
||||
env["STORAGE_ROOT"],
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
get_duplicity_target_url(config),
|
||||
"--allow-source-mismatch"
|
||||
] + get_duplicity_additional_args(env),
|
||||
get_duplicity_env_vars(env))
|
||||
finally:
|
||||
# Start services again.
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "start"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "start"])
|
||||
service_command("postgrey", "start", quit=False)
|
||||
service_command("dovecot", "start", quit=False)
|
||||
service_command("postfix", "start", quit=False)
|
||||
service_command(php_fpm, "start", quit=False)
|
||||
|
||||
# Remove old backups. This deletes all backup data no longer needed
|
||||
# from more than 31 days ago. Must do this before destroying the
|
||||
# cache directory or else this command will re-create it.
|
||||
# from more than 3 days ago.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"/usr/local/bin/duplicity",
|
||||
"remove-older-than",
|
||||
"%dD" % keep_backups_for_days,
|
||||
"--archive-dir", "/tmp/duplicity-archive-dir",
|
||||
"--name", "mailinabox",
|
||||
"%dD" % config["min_age_in_days"],
|
||||
"--verbosity", "error",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--force",
|
||||
"--verbosity", "warning",
|
||||
"file://" + backup_duplicity_dir
|
||||
])
|
||||
get_duplicity_target_url(config)
|
||||
] + get_duplicity_additional_args(env),
|
||||
get_duplicity_env_vars(env))
|
||||
|
||||
# Remove duplicity's cache directory because it's redundant with our backup directory.
|
||||
shutil.rmtree("/tmp/duplicity-archive-dir")
|
||||
# From duplicity's manual:
|
||||
# "This should only be necessary after a duplicity session fails or is
|
||||
# aborted prematurely."
|
||||
# That may be unlikely here but we may as well ensure we tidy up if
|
||||
# that does happen - it might just have been a poorly timed reboot.
|
||||
shell('check_call', [
|
||||
"/usr/local/bin/duplicity",
|
||||
"cleanup",
|
||||
"--verbosity", "error",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--force",
|
||||
get_duplicity_target_url(config)
|
||||
] + get_duplicity_additional_args(env),
|
||||
get_duplicity_env_vars(env))
|
||||
|
||||
# Encrypt all of the new files.
|
||||
backup_encrypted_dir = os.path.join(backup_dir, 'encrypted')
|
||||
os.makedirs(backup_encrypted_dir, exist_ok=True)
|
||||
for fn in os.listdir(backup_duplicity_dir):
|
||||
fn2 = os.path.join(backup_encrypted_dir, fn) + ".enc"
|
||||
if os.path.exists(fn2): continue
|
||||
|
||||
# Encrypt the backup using the backup private key.
|
||||
shell('check_call', [
|
||||
"/usr/bin/openssl",
|
||||
"enc",
|
||||
"-aes-256-cbc",
|
||||
"-a",
|
||||
"-salt",
|
||||
"-in", os.path.join(backup_duplicity_dir, fn),
|
||||
"-out", fn2,
|
||||
"-pass", "file:%s" % os.path.join(backup_dir, "secret_key.txt"),
|
||||
])
|
||||
|
||||
# The backup can be decrypted with:
|
||||
# openssl enc -d -aes-256-cbc -a -in latest.tgz.enc -out /dev/stdout -pass file:secret_key.txt | tar -z
|
||||
|
||||
# Remove encrypted backups that are no longer needed.
|
||||
for fn in os.listdir(backup_encrypted_dir):
|
||||
fn2 = os.path.join(backup_duplicity_dir, fn.replace(".enc", ""))
|
||||
if os.path.exists(fn2): continue
|
||||
os.unlink(os.path.join(backup_encrypted_dir, fn))
|
||||
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||
# script can access them.
|
||||
if get_target_type(config) == 'file':
|
||||
shell('check_call',
|
||||
["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||
|
||||
# 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
|
||||
# environment variables so the script has access to STORAGE_ROOT.
|
||||
post_script = os.path.join(backup_dir, 'after-backup')
|
||||
post_script = os.path.join(backup_root, 'after-backup')
|
||||
if os.path.exists(post_script):
|
||||
shell('check_call',
|
||||
['su', env['STORAGE_USER'], '-c', post_script],
|
||||
['su', env['STORAGE_USER'], '-c', post_script, config["target"]],
|
||||
env=env)
|
||||
|
||||
# Our nightly cron job executes system status checks immediately after this
|
||||
# backup. Since it checks that dovecot and postfix are running, block for a
|
||||
# bit (maximum of 10 seconds each) to give each a chance to finish restarting
|
||||
# before the status checks might catch them down. See #381.
|
||||
if user_initiated:
|
||||
# God forgive me for what I'm about to do
|
||||
lock._release()
|
||||
# We don't need to wait for the services to be up in this case
|
||||
else:
|
||||
wait_for_service(25, True, env, 10)
|
||||
wait_for_service(993, True, env, 10)
|
||||
|
||||
|
||||
def run_duplicity_verification():
|
||||
env = load_environment()
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
config = get_backup_config(env)
|
||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||
|
||||
shell('check_call', [
|
||||
"/usr/local/bin/duplicity",
|
||||
"--verbosity",
|
||||
"info",
|
||||
"verify",
|
||||
"--compare-data",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--exclude", backup_root,
|
||||
get_duplicity_target_url(config),
|
||||
env["STORAGE_ROOT"],
|
||||
] + 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/local/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',
|
||||
f"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {int(config.get('target_rsync_port', 22))} -i /root/.ssh/id_rsa_miab",
|
||||
'--list-only', '-r',
|
||||
rsync_target.format(host=target.netloc, path=target_path)
|
||||
]
|
||||
|
||||
code, listing = shell('check_output',
|
||||
rsync_command,
|
||||
trap=True,
|
||||
capture_stderr=True)
|
||||
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).get("Contents", [])
|
||||
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
|
||||
except ClientError as e:
|
||||
raise ValueError(e)
|
||||
return backup_list
|
||||
elif target.scheme == 'b2':
|
||||
InMemoryAccountInfo = None
|
||||
B2Api = None
|
||||
NonExistentBucket = None
|
||||
|
||||
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, target_rsync_port, min_age):
|
||||
config = get_backup_config(env, for_save=True)
|
||||
|
||||
# min_age must be an int
|
||||
if isinstance(min_age, str):
|
||||
min_age = int(min_age)
|
||||
|
||||
if isinstance(target_rsync_port, str):
|
||||
try:
|
||||
target_rsync_port = int(target_rsync_port)
|
||||
except:
|
||||
target_rsync_port = 22
|
||||
|
||||
config["target"] = target
|
||||
config["target_user"] = target_user
|
||||
config["target_pass"] = target_pass
|
||||
config["target_rsync_port"] = target_rsync_port
|
||||
config["min_age_in_days"] = min_age
|
||||
|
||||
# Validate.
|
||||
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", "target_rsync_port": 22}
|
||||
|
||||
# Merge in anything written to custom.yaml.
|
||||
try:
|
||||
custom_config = rtyaml.load(
|
||||
open(os.path.join(backup_root, 'custom.yaml')))
|
||||
if not isinstance(custom_config, dict):
|
||||
raise ValueError() # caught below
|
||||
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__":
|
||||
import sys
|
||||
full_backup = "--full" in sys.argv
|
||||
perform_backup(full_backup)
|
||||
if sys.argv[-1] == "--verify":
|
||||
# Run duplicity's verification command to check a) the backup files
|
||||
# are readable, and b) report if they are up to date.
|
||||
run_duplicity_verification()
|
||||
|
||||
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:
|
||||
# Perform a backup. Add --full to force a full backup rather than
|
||||
# possibly performing an incremental backup.
|
||||
full_backup = "--full" in sys.argv
|
||||
perform_backup(full_backup)
|
||||
|
|
|
@ -1,155 +0,0 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
# Helps you purchase a SSL certificate from Gandi.net using
|
||||
# their API.
|
||||
#
|
||||
# Before you begin:
|
||||
# 1) Create an account on Gandi.net.
|
||||
# 2) Pre-pay $16 into your account at https://www.gandi.net/prepaid/operations. Wait until the payment goes through.
|
||||
# 3) Activate your API key first on the test platform (wait a while, refresh the page) and then activate the production API at https://www.gandi.net/admin/api_key.
|
||||
|
||||
import sys, re, os.path, urllib.request
|
||||
import xmlrpc.client
|
||||
import rtyaml
|
||||
|
||||
from utils import load_environment, shell
|
||||
from web_update import get_web_domains, get_domain_ssl_files, get_web_root
|
||||
from status_checks import check_certificate
|
||||
|
||||
def buy_ssl_certificate(api_key, domain, command, env):
|
||||
if domain != env['PRIMARY_HOSTNAME'] \
|
||||
and domain not in get_web_domains(env):
|
||||
raise ValueError("Domain is not %s or a domain we're serving a website for." % env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Initialize.
|
||||
|
||||
gandi = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/')
|
||||
|
||||
try:
|
||||
existing_certs = gandi.cert.list(api_key)
|
||||
except Exception as e:
|
||||
if "Invalid API key" in str(e):
|
||||
print("Invalid API key. Check that you copied the API Key correctly from https://www.gandi.net/admin/api_key.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Where is the SSL cert stored?
|
||||
|
||||
ssl_key, ssl_certificate, ssl_csr_path = get_domain_ssl_files(domain, env)
|
||||
|
||||
# Have we already created a cert for this domain?
|
||||
|
||||
for cert in existing_certs:
|
||||
if cert['cn'] == domain:
|
||||
break
|
||||
else:
|
||||
# No existing cert found. Purchase one.
|
||||
if command != 'purchase':
|
||||
print("No certificate or order found yet. If you haven't yet purchased a certificate, run ths script again with the 'purchase' command. Otherwise wait a moment and try again.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Start an order for a single standard SSL certificate.
|
||||
# Use DNS validation. Web-based validation won't work because they
|
||||
# require a file on HTTP but not HTTPS w/o redirects and we don't
|
||||
# serve anything plainly over HTTP. Email might be another way but
|
||||
# DNS is easier to automate.
|
||||
op = gandi.cert.create(api_key, {
|
||||
"csr": open(ssl_csr_path).read(),
|
||||
"dcv_method": "dns",
|
||||
"duration": 1, # year?
|
||||
"package": "cert_std_1_0_0",
|
||||
})
|
||||
print("An SSL certificate has been ordered.")
|
||||
print()
|
||||
print(op)
|
||||
print()
|
||||
print("In a moment please run this script again with the 'setup' command.")
|
||||
|
||||
if cert['status'] == 'pending':
|
||||
# Get the information we need to update our DNS with a code so that
|
||||
# Gandi can verify that we own the domain.
|
||||
|
||||
dcv = gandi.cert.get_dcv_params(api_key, {
|
||||
"csr": open(ssl_csr_path).read(),
|
||||
"cert_id": cert['id'],
|
||||
"dcv_method": "dns",
|
||||
"duration": 1, # year?
|
||||
"package": "cert_std_1_0_0",
|
||||
})
|
||||
if dcv["dcv_method"] != "dns":
|
||||
raise Exception("Certificate ordered with an unknown validation method.")
|
||||
|
||||
# Update our DNS data.
|
||||
|
||||
dns_config = env['STORAGE_ROOT'] + '/dns/custom.yaml'
|
||||
if os.path.exists(dns_config):
|
||||
dns_records = rtyaml.load(open(dns_config))
|
||||
else:
|
||||
dns_records = { }
|
||||
|
||||
qname = dcv['md5'] + '.' + domain
|
||||
value = dcv['sha1'] + '.comodoca.com.'
|
||||
dns_records[qname] = { "CNAME": value }
|
||||
|
||||
with open(dns_config, 'w') as f:
|
||||
f.write(rtyaml.dump(dns_records))
|
||||
|
||||
shell('check_call', ['tools/dns_update'])
|
||||
|
||||
# Okay, done with this step.
|
||||
|
||||
print("DNS has been updated. Gandi will check within 60 minutes.")
|
||||
print()
|
||||
print("See https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
|
||||
|
||||
elif cert['status'] == 'valid':
|
||||
# The certificate is ready.
|
||||
|
||||
# Check before we overwrite something we shouldn't.
|
||||
if os.path.exists(ssl_certificate):
|
||||
cert_status = check_certificate(None, ssl_certificate, None)
|
||||
if cert_status != "SELF-SIGNED":
|
||||
print("Please back up and delete the file %s so I can save your new certificate." % ssl_certificate)
|
||||
sys.exit(1)
|
||||
|
||||
# Form the certificate.
|
||||
|
||||
# The certificate comes as a long base64-encoded string. Break in
|
||||
# into lines in the usual way.
|
||||
pem = "-----BEGIN CERTIFICATE-----\n"
|
||||
pem += "\n".join(chunk for chunk in re.split(r"(.{64})", cert['cert']) if chunk != "")
|
||||
pem += "\n-----END CERTIFICATE-----\n\n"
|
||||
|
||||
# Append intermediary certificates.
|
||||
pem += urllib.request.urlopen("https://www.gandi.net/static/CAs/GandiStandardSSLCA.pem").read().decode("ascii")
|
||||
|
||||
# Write out.
|
||||
|
||||
with open(ssl_certificate, "w") as f:
|
||||
f.write(pem)
|
||||
|
||||
print("The certificate has been installed in %s. Restarting services..." % ssl_certificate)
|
||||
|
||||
# Restart dovecot and if this is for PRIMARY_HOSTNAME.
|
||||
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
shell('check_call', ["/usr/sbin/service", "dovecot", "restart"])
|
||||
shell('check_call', ["/usr/sbin/service", "postfix", "restart"])
|
||||
|
||||
# Restart nginx in all cases.
|
||||
|
||||
shell('check_call', ["/usr/sbin/service", "nginx", "restart"])
|
||||
|
||||
else:
|
||||
print("The certificate has an unknown status. Please check https://www.gandi.net/admin/ssl/%d/details for the status of this order." % cert['id'])
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: python management/buy_certificate.py gandi_api_key domain_name {purchase, setup}")
|
||||
sys.exit(1)
|
||||
api_key = sys.argv[1]
|
||||
domain_name = sys.argv[2]
|
||||
cmd = sys.argv[3]
|
||||
buy_ssl_certificate(api_key, domain_name, cmd, load_environment())
|
||||
|
204
management/cli.py
Executable file
204
management/cli.py
Executable file
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/python3
|
||||
#
|
||||
# This is a command-line script for calling management APIs
|
||||
# on the Mail-in-a-Box control panel backend. The script
|
||||
# reads /var/lib/mailinabox/api.key for the backend's
|
||||
# root API key. This file is readable only by root, so this
|
||||
# tool can only be used as root.
|
||||
|
||||
import sys
|
||||
import getpass
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import re
|
||||
import csv
|
||||
|
||||
|
||||
def mgmt(cmd, data=None, is_json=False):
|
||||
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||
mgmt_uri = 'http://127.0.0.1:10222'
|
||||
|
||||
setup_key_auth(mgmt_uri)
|
||||
|
||||
req = urllib.request.Request(
|
||||
mgmt_uri + cmd,
|
||||
urllib.parse.urlencode(data).encode("utf8") if data else None)
|
||||
try:
|
||||
response = urllib.request.urlopen(req)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
try:
|
||||
print(e.read().decode("utf8"))
|
||||
except:
|
||||
pass
|
||||
print(
|
||||
"The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.",
|
||||
file=sys.stderr)
|
||||
elif hasattr(e, 'read'):
|
||||
print(e.read().decode('utf8'), file=sys.stderr)
|
||||
else:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
resp = response.read().decode('utf8')
|
||||
if is_json:
|
||||
resp = json.loads(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def read_password():
|
||||
while True:
|
||||
first = getpass.getpass('password: ')
|
||||
if len(first) < 8:
|
||||
print("Passwords must be at least eight characters.")
|
||||
continue
|
||||
second = getpass.getpass(' (again): ')
|
||||
if first != second:
|
||||
print("Passwords not the same. Try again.")
|
||||
continue
|
||||
break
|
||||
return first
|
||||
|
||||
|
||||
def setup_key_auth(mgmt_uri):
|
||||
key = open('/var/lib/mailinabox/api.key').read().strip()
|
||||
|
||||
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||
auth_handler.add_password(realm='Mail-in-a-Box Management Server',
|
||||
uri=mgmt_uri,
|
||||
user=key,
|
||||
passwd='')
|
||||
opener = urllib.request.build_opener(auth_handler)
|
||||
urllib.request.install_opener(opener)
|
||||
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("""Usage:
|
||||
{cli} system default-quota [new default] (set default quota for system)
|
||||
{cli} user (lists users)
|
||||
{cli} user add user@domain.com [password]
|
||||
{cli} user password user@domain.com [password]
|
||||
{cli} user remove user@domain.com
|
||||
{cli} user make-admin user@domain.com
|
||||
{cli} user quota user@domain [new-quota]
|
||||
{cli} user remove-admin user@domain.com
|
||||
{cli} user admins (lists admins)
|
||||
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
|
||||
{cli} user mfa disable user@domain.com [id] (disables MFA for user)
|
||||
{cli} alias (lists aliases)
|
||||
{cli} alias add incoming.name@domain.com sent.to@other.domain.com
|
||||
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
|
||||
{cli} alias remove incoming.name@domain.com
|
||||
|
||||
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
|
||||
""".format(cli="management/cli.py"))
|
||||
|
||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||
users = mgmt("/mail/users?format=json", is_json=True)
|
||||
for domain in users:
|
||||
for user in domain["users"]:
|
||||
if user['status'] == 'inactive':
|
||||
continue
|
||||
print(user['email'], end='')
|
||||
if "admin" in user['privileges']:
|
||||
print("*", end='')
|
||||
if user['quota'] == '0':
|
||||
print(" unlimited", end='')
|
||||
else:
|
||||
print(" " + user['quota'], end='')
|
||||
print()
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||
if len(sys.argv) < 5:
|
||||
if len(sys.argv) < 4:
|
||||
email = input("email: ")
|
||||
else:
|
||||
email = sys.argv[3]
|
||||
pw = read_password()
|
||||
else:
|
||||
email, pw = sys.argv[3:5]
|
||||
|
||||
if sys.argv[2] == "add":
|
||||
print(mgmt("/mail/users/add", {"email": email, "password": pw}))
|
||||
elif sys.argv[2] == "password":
|
||||
print(mgmt("/mail/users/password", {"email": email, "password": pw}))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
print(mgmt("/mail/users/remove", {"email": sys.argv[3]}))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin",
|
||||
"remove-admin") and len(
|
||||
sys.argv) == 4:
|
||||
if sys.argv[2] == "make-admin":
|
||||
action = "add"
|
||||
else:
|
||||
action = "remove"
|
||||
print(
|
||||
mgmt("/mail/users/privileges/" + action, {
|
||||
"email": sys.argv[3],
|
||||
"privilege": "admin"
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||
# Dump a list of admin users.
|
||||
users = mgmt("/mail/users?format=json", is_json=True)
|
||||
for domain in users:
|
||||
for user in domain["users"]:
|
||||
if "admin" in user['privileges']:
|
||||
print(user['email'])
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
|
||||
# Set a user's quota
|
||||
print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3]))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
|
||||
# Set a user's quota
|
||||
users = mgmt("/mail/users/quota", {
|
||||
"email": sys.argv[3],
|
||||
"quota": sys.argv[4]
|
||||
})
|
||||
|
||||
elif sys.argv[1] == "user" and len(
|
||||
sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
|
||||
# Show MFA status for a user.
|
||||
status = mgmt("/mfa/status", {"user": sys.argv[4]}, is_json=True)
|
||||
W = csv.writer(sys.stdout)
|
||||
W.writerow(["id", "type", "label"])
|
||||
for mfa in status["enabled_mfa"]:
|
||||
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
||||
|
||||
elif sys.argv[1] == "user" and len(
|
||||
sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||
# Disable MFA (all or a particular device) for a user.
|
||||
print(
|
||||
mgmt(
|
||||
"/mfa/disable", {
|
||||
"user": sys.argv[4],
|
||||
"mfa-id": sys.argv[5] if len(sys.argv) == 6 else None
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||
print(mgmt("/mail/aliases"))
|
||||
|
||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||
print(
|
||||
mgmt("/mail/aliases/add", {
|
||||
"address": sys.argv[3],
|
||||
"forwards_to": sys.argv[4]
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
print(mgmt("/mail/aliases/remove", {"address": sys.argv[3]}))
|
||||
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
||||
sys.argv) == 3:
|
||||
print(mgmt("/system/default-quota?text=1"))
|
||||
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
||||
sys.argv) == 4:
|
||||
print(mgmt("/system/default-quota", {"default_quota": sys.argv[3]}))
|
||||
|
||||
else:
|
||||
print("Invalid command-line arguments.")
|
||||
sys.exit(1)
|
|
@ -1,27 +1,28 @@
|
|||
# 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 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.
|
||||
BR Brazil
|
||||
CA Canada
|
||||
CN China
|
||||
EG Egypt
|
||||
FR France
|
||||
DE Germany
|
||||
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
|
||||
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
|
||||
ID Indonesia
|
||||
PH Philippines
|
||||
TR Turkey
|
||||
IT Italy
|
||||
PK Pakistan
|
||||
ES Spain
|
||||
CA Canada
|
||||
AD Andorra
|
||||
AE United Arab Emirates
|
||||
AF Afghanistan
|
||||
|
@ -183,7 +184,6 @@ PA Panama
|
|||
PE Peru
|
||||
PF French Polynesia
|
||||
PG Papua New Guinea
|
||||
PK Pakistan
|
||||
PL Poland
|
||||
PM Saint Pierre and Miquelon
|
||||
PN Pitcairn Pitcairn Islands
|
Can't render this file because it has a wrong number of fields in line 5.
|
1285
management/daemon.py
1285
management/daemon.py
File diff suppressed because it is too large
Load diff
28
management/daily_tasks.sh
Executable file
28
management/daily_tasks.sh
Executable file
|
@ -0,0 +1,28 @@
|
|||
#!/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"
|
||||
|
||||
# Renew the daemon's PGP key if about to expire
|
||||
management/pgp.py 2>&1 | management/email_administrator.py "PGP Key Renewal 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"
|
File diff suppressed because it is too large
Load diff
165
management/editconf.py
Executable file
165
management/editconf.py
Executable file
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/python3
|
||||
#
|
||||
# This is a helper tool for editing configuration files during the setup
|
||||
# process. The tool is given new values for settings as command-line
|
||||
# arguments. It comments-out existing setting values in the configuration
|
||||
# file and adds new values either after their former location or at the
|
||||
# end.
|
||||
#
|
||||
# The configuration file has settings that look like:
|
||||
#
|
||||
# NAME=VALUE
|
||||
#
|
||||
# If the -s option is given, then space becomes the delimiter, i.e.:
|
||||
#
|
||||
# NAME VALUE
|
||||
#
|
||||
# If the -c option is given, then the supplied character becomes the comment character
|
||||
#
|
||||
# If the -w option is given, then setting lines continue onto following
|
||||
# lines while the lines start with whitespace, e.g.:
|
||||
#
|
||||
# NAME VAL
|
||||
# UE
|
||||
|
||||
# create the new config file in memory
|
||||
|
||||
import sys
|
||||
import re
|
||||
|
||||
|
||||
def edit_conf(filename,
|
||||
settings,
|
||||
delimiter_re,
|
||||
delimiter,
|
||||
comment_char,
|
||||
folded_lines=False,
|
||||
testing=False,
|
||||
erase_setting=False):
|
||||
found = set()
|
||||
buf = ""
|
||||
input_lines = list(open(filename, "r+"))
|
||||
|
||||
while len(input_lines) > 0:
|
||||
line = input_lines.pop(0)
|
||||
|
||||
# If this configuration file uses folded lines, append any folded lines
|
||||
# into our input buffer.
|
||||
if folded_lines and line[0] not in (comment_char, " ", ""):
|
||||
while len(input_lines) > 0 and input_lines[0][0] in " \t":
|
||||
line += input_lines.pop(0)
|
||||
|
||||
# See if this line is for any settings passed on the command line.
|
||||
for i in range(len(settings)):
|
||||
# Check whether this line contain this setting from the command-line arguments.
|
||||
name, val = settings[i].split("=", 1)
|
||||
m = re.match(
|
||||
"(\s*)" + "(" + re.escape(comment_char) + "\s*)?" +
|
||||
re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
|
||||
if not m:
|
||||
continue
|
||||
indent, is_comment, existing_val = m.groups()
|
||||
|
||||
# If this is already the setting, keep it in the file, except:
|
||||
# * If we've already seen it before, then remove this duplicate line.
|
||||
# * If val is empty and erase_setting is on, then comment it out.
|
||||
if is_comment is None and existing_val == val and not (not val and erase_setting):
|
||||
# It may be that we've already inserted this setting higher
|
||||
# in the file so check for that first.
|
||||
if i in found:
|
||||
break
|
||||
buf += line
|
||||
found.add(i)
|
||||
break
|
||||
|
||||
# comment-out the existing line (also comment any folded lines)
|
||||
if is_comment is None:
|
||||
buf += comment_char + line.rstrip().replace(
|
||||
"\n", "\n" + comment_char) + "\n"
|
||||
else:
|
||||
# the line is already commented, pass it through
|
||||
buf += line
|
||||
|
||||
# if this option oddly appears more than once, don't add the setting again
|
||||
# Or if we're clearing it, don't add it
|
||||
if (i in found) or (not val and erase_setting):
|
||||
break
|
||||
|
||||
# add the new setting
|
||||
buf += indent + name + delimiter + val + "\n"
|
||||
|
||||
# note that we've applied this option
|
||||
found.add(i)
|
||||
|
||||
break
|
||||
else:
|
||||
# If did not match any setting names, pass this line through.
|
||||
buf += line
|
||||
|
||||
# Put any settings we didn't see at the end of the file, except those being erased.
|
||||
for i in range(len(settings)):
|
||||
if i not in found:
|
||||
name, val = settings[i].split("=", 1)
|
||||
if not (not val and erase_setting):
|
||||
buf += name + delimiter + val + "\n"
|
||||
|
||||
if not testing:
|
||||
# Write out the new file.
|
||||
with open(filename, "w") as f:
|
||||
f.write(buf)
|
||||
else:
|
||||
# Just print the new file to stdout.
|
||||
print(buf)
|
||||
|
||||
|
||||
# Run standalone
|
||||
if __name__ == "__main__":
|
||||
# sanity check
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
"usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# parse command line arguments
|
||||
filename = sys.argv[1]
|
||||
settings = sys.argv[2:]
|
||||
|
||||
delimiter = "="
|
||||
delimiter_re = r"\s*=\s*"
|
||||
comment_char = "#"
|
||||
folded_lines = False
|
||||
testing = False
|
||||
erase_setting = False
|
||||
while settings[0][0] == "-" and settings[0] != "--":
|
||||
opt = settings.pop(0)
|
||||
if opt == "-s":
|
||||
# Space is the delimiter
|
||||
delimiter = " "
|
||||
delimiter_re = r"\s+"
|
||||
elif opt == "-e":
|
||||
# Erase settings that have empty values.
|
||||
erase_setting = True
|
||||
elif opt == "-w":
|
||||
# Line folding is possible in this file.
|
||||
folded_lines = True
|
||||
elif opt == "-c":
|
||||
# Specifies a different comment character.
|
||||
comment_char = settings.pop(0)
|
||||
elif opt == "-t":
|
||||
testing = True
|
||||
else:
|
||||
print("Invalid option.")
|
||||
sys.exit(1)
|
||||
|
||||
# sanity check command line
|
||||
for setting in settings:
|
||||
try:
|
||||
name, value = setting.split("=", 1)
|
||||
except:
|
||||
import subprocess
|
||||
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
|
||||
sys.exit(1)
|
||||
|
||||
edit_conf(filename, settings, delimiter_re, delimiter, comment_char,
|
||||
folded_lines, testing, erase_setting)
|
64
management/email_administrator.py
Executable file
64
management/email_administrator.py
Executable file
|
@ -0,0 +1,64 @@
|
|||
#!/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
|
||||
|
||||
from pgp import create_signature
|
||||
|
||||
# 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]
|
||||
|
||||
# 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()
|
||||
|
||||
noreply = "noreply-daemon@" + env['PRIMARY_HOSTNAME']
|
||||
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
|
||||
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon",
|
||||
"noreply-daemon@" + env['PRIMARY_HOSTNAME'])
|
||||
msg['To'] = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
||||
|
||||
content_html = "<html><body><pre>{}</pre></body></html>".format(
|
||||
html.escape(content))
|
||||
|
||||
msg.attach(MIMEText(create_signature(content.encode()).decode(), '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(
|
||||
noreply, # MAIL FROM
|
||||
admin_addr, # RCPT TO
|
||||
msg.as_string())
|
||||
smtpclient.quit()
|
926
management/mail_log.py
Executable file
926
management/mail_log.py
Executable file
|
@ -0,0 +1,926 @@
|
|||
#!/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 dateutil.parser
|
||||
import time
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
import utils
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
TIME_DELTAS = OrderedDict([
|
||||
('all', datetime.timedelta(weeks=52)),
|
||||
('month', datetime.timedelta(weeks=4)),
|
||||
('2weeks', datetime.timedelta(days=14)),
|
||||
('week', datetime.timedelta(days=7)),
|
||||
('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))
|
||||
])
|
||||
|
||||
END_DATE = NOW = datetime.datetime.now()
|
||||
START_DATE = None
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
# List of strings to filter users with
|
||||
FILTERS = None
|
||||
|
||||
# What to show (with defaults)
|
||||
SCAN_OUT = True # Outgoing email
|
||||
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│ ')
|
||||
|
||||
|
||||
def scan_mail_log_line(line, collector):
|
||||
""" Scan a log line and extract interesting data """
|
||||
|
||||
m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)",
|
||||
line)
|
||||
|
||||
if not m:
|
||||
return True
|
||||
|
||||
date, system, service, log = m.groups()
|
||||
collector["scan_count"] += 1
|
||||
|
||||
# print()
|
||||
# print("date:", date)
|
||||
# print("host:", system)
|
||||
# print("service:", service)
|
||||
# print("log:", log)
|
||||
|
||||
# Replaced the dateutil parser for a less clever way of parser that is roughly 4 times faster.
|
||||
# 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_postgrey_line(date, log, collector):
|
||||
""" Scan a postgrey log line and extract interesting data """
|
||||
|
||||
m = re.match(
|
||||
"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
|
||||
"client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||
|
||||
if m:
|
||||
|
||||
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):
|
||||
""" Scan a postfix smtpd log line and extract interesting data """
|
||||
|
||||
# Check if the incoming mail was rejected
|
||||
|
||||
m = re.match(
|
||||
"NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
|
||||
|
||||
if m:
|
||||
message, sender, user = m.groups()
|
||||
|
||||
# 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__":
|
||||
try:
|
||||
env_vars = utils.load_environment()
|
||||
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)
|
File diff suppressed because it is too large
Load diff
158
management/mfa.py
Normal file
158
management/mfa.py
Normal file
|
@ -0,0 +1,158 @@
|
|||
import base64
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import pyotp
|
||||
import qrcode
|
||||
|
||||
from mailconfig import open_database
|
||||
|
||||
|
||||
def get_user_id(email, c):
|
||||
c.execute('SELECT id FROM users WHERE email=?', (email, ))
|
||||
r = c.fetchone()
|
||||
if not r:
|
||||
raise ValueError("User does not exist.")
|
||||
return r[0]
|
||||
|
||||
|
||||
def get_mfa_state(email, env):
|
||||
c = open_database(env)
|
||||
c.execute(
|
||||
'SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?',
|
||||
(get_user_id(email, c), ))
|
||||
return [{
|
||||
"id": r[0],
|
||||
"type": r[1],
|
||||
"secret": r[2],
|
||||
"mru_token": r[3],
|
||||
"label": r[4]
|
||||
} for r in c.fetchall()]
|
||||
|
||||
|
||||
def get_public_mfa_state(email, env):
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
return [{
|
||||
"id": s["id"],
|
||||
"type": s["type"],
|
||||
"label": s["label"]
|
||||
} for s in mfa_state]
|
||||
|
||||
|
||||
def get_hash_mfa_state(email, env):
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
return [{
|
||||
"id": s["id"],
|
||||
"type": s["type"],
|
||||
"secret": s["secret"]
|
||||
} for s in mfa_state]
|
||||
|
||||
|
||||
def enable_mfa(email, type, secret, token, label, env):
|
||||
if type == "totp":
|
||||
validate_totp_secret(secret)
|
||||
# Sanity check with the provide current token.
|
||||
totp = pyotp.TOTP(secret)
|
||||
if not totp.verify(token, valid_window=1):
|
||||
raise ValueError("Invalid token.")
|
||||
else:
|
||||
raise ValueError("Invalid MFA type.")
|
||||
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute(
|
||||
'INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)',
|
||||
(get_user_id(email, c), type, secret, label))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def set_mru_token(email, mfa_id, token, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?',
|
||||
(token, get_user_id(email, c), mfa_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def disable_mfa(email, mfa_id, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
if mfa_id is None:
|
||||
# Disable all MFA for a user.
|
||||
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c), ))
|
||||
else:
|
||||
# Disable a particular MFA mode for a user.
|
||||
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?',
|
||||
(get_user_id(email, c), mfa_id))
|
||||
conn.commit()
|
||||
return c.rowcount > 0
|
||||
|
||||
|
||||
def validate_totp_secret(secret):
|
||||
if type(secret) != str or secret.strip() == "":
|
||||
raise ValueError("No secret provided.")
|
||||
if len(secret) != 32:
|
||||
raise ValueError("Secret should be a 32 characters base32 string")
|
||||
|
||||
|
||||
def provision_totp(email, env):
|
||||
# Make a new secret.
|
||||
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||
validate_totp_secret(secret) # sanity check
|
||||
|
||||
# Make a URI that we encode within a QR code.
|
||||
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||
name=email,
|
||||
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel")
|
||||
|
||||
# Generate a QR code as a base64-encode PNG image.
|
||||
qr = qrcode.make(uri)
|
||||
byte_arr = io.BytesIO()
|
||||
qr.save(byte_arr, format='PNG')
|
||||
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||
|
||||
return {"type": "totp", "secret": secret, "qr_code_base64": png_b64}
|
||||
|
||||
|
||||
def validate_auth_mfa(email, request, env):
|
||||
# Validates that a login request satisfies any MFA modes
|
||||
# that have been enabled for the user's account. Returns
|
||||
# a tuple (status, [hints]). status is True for a successful
|
||||
# MFA login, False for a missing token. If status is False,
|
||||
# hints is an array of codes that indicate what the user
|
||||
# can try. Possible codes are:
|
||||
# "missing-totp-token"
|
||||
# "invalid-totp-token"
|
||||
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
|
||||
# If no MFA modes are added, return True.
|
||||
if len(mfa_state) == 0:
|
||||
return (True, [])
|
||||
|
||||
# Try the enabled MFA modes.
|
||||
hints = set()
|
||||
for mfa_mode in mfa_state:
|
||||
if mfa_mode["type"] == "totp":
|
||||
# Check that a token is present in the X-Auth-Token header.
|
||||
# If not, give a hint that one can be supplied.
|
||||
token = request.headers.get('x-auth-token')
|
||||
if not token:
|
||||
hints.add("missing-totp-token")
|
||||
continue
|
||||
|
||||
# Check for a replay attack.
|
||||
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
|
||||
# If the token fails, skip this MFA mode.
|
||||
hints.add("invalid-totp-token")
|
||||
continue
|
||||
|
||||
# Check the token.
|
||||
totp = pyotp.TOTP(mfa_mode["secret"])
|
||||
if not totp.verify(token, valid_window=1):
|
||||
hints.add("invalid-totp-token")
|
||||
continue
|
||||
|
||||
# On success, record the token to prevent a replay attack.
|
||||
set_mru_token(email, mfa_mode['id'], token, env)
|
||||
return (True, [])
|
||||
|
||||
# On a failed login, indicate failure and any hints for what the user can do instead.
|
||||
return (False, list(hints))
|
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
|
178
management/pgp.py
Executable file
178
management/pgp.py
Executable file
|
@ -0,0 +1,178 @@
|
|||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
# Tools to manipulate PGP keys
|
||||
|
||||
import gpg
|
||||
import utils
|
||||
import datetime
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
env = utils.load_environment()
|
||||
|
||||
# Import daemon's keyring - usually in /home/user-data/.gnupg/
|
||||
gpghome = env['GNUPGHOME']
|
||||
daemon_key_fpr = env['PGPKEY']
|
||||
default_context = gpg.Context(armor=True, home_dir=gpghome)
|
||||
|
||||
# Auxiliary function to process the key in order to be read more conveniently
|
||||
|
||||
|
||||
def key_representation(key):
|
||||
if key is None:
|
||||
return None
|
||||
key_rep = {
|
||||
"master_fpr": key.fpr,
|
||||
"revoked": key.revoked != 0,
|
||||
"ids": [],
|
||||
"ids_emails": {},
|
||||
"subkeys": []
|
||||
}
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
key_rep["ids"] = [id.uid for id in key.uids]
|
||||
# No duplicate email addresses in this list
|
||||
key_rep["ids_emails"] = list({id.email for id in key.uids})
|
||||
key_rep["subkeys"] = [{
|
||||
"master":
|
||||
skey.fpr == key.fpr,
|
||||
"sign":
|
||||
skey.can_sign == 1,
|
||||
"cert":
|
||||
skey.can_certify == 1,
|
||||
"encr":
|
||||
skey.can_encrypt == 1,
|
||||
"auth":
|
||||
skey.can_authenticate == 1,
|
||||
"fpr":
|
||||
skey.fpr,
|
||||
"expires":
|
||||
skey.expires if skey.expires != 0 else None,
|
||||
"expires_date":
|
||||
datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat()
|
||||
if skey.expires != 0 else None,
|
||||
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) -
|
||||
now).days if skey.expires != 0 else None,
|
||||
"expired":
|
||||
skey.expired == 1,
|
||||
"algorithm":
|
||||
gpg.core.pubkey_algo_name(skey.pubkey_algo),
|
||||
"bits":
|
||||
skey.length
|
||||
} for skey in key.subkeys]
|
||||
|
||||
return key_rep
|
||||
|
||||
|
||||
# Tests an import as for whether we have any sort of private key material in our import
|
||||
|
||||
|
||||
def contains_private_keys(imports):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
|
||||
result = tmp.key_import(imports)
|
||||
try:
|
||||
return result.secret_read != 0
|
||||
except AttributeError:
|
||||
raise ValueError("Import is not a valid PGP key block!")
|
||||
|
||||
|
||||
# Decorator: Copies the homedir of a context onto a temporary directory and returns a context operating over that tmpdir
|
||||
|
||||
|
||||
def fork_context(f, context=default_context):
|
||||
from os.path import isdir, isfile
|
||||
|
||||
def dirs_files_only(current_dir, files):
|
||||
ignore = []
|
||||
for f in files:
|
||||
path = f"{current_dir}{f}"
|
||||
if not isdir(path) and not isfile(path):
|
||||
ignore.append(f)
|
||||
return ignore
|
||||
|
||||
def wrapped(*args, **kwargs):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
shutil.copytree(context.home_dir,
|
||||
f"{tmpdir}/gnupg",
|
||||
ignore=dirs_files_only)
|
||||
kwargs["context"] = gpg.Context(armor=context.armor,
|
||||
home_dir=f"{tmpdir}/gnupg")
|
||||
kwargs["buffer"] = gpg.Data()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def get_key(fingerprint, context=default_context):
|
||||
try:
|
||||
return context.get_key(fingerprint, secret=False)
|
||||
except KeyError:
|
||||
return None
|
||||
except gpg.errors.GPGMEError:
|
||||
return None
|
||||
|
||||
|
||||
def get_daemon_key(context=default_context):
|
||||
if daemon_key_fpr is None or daemon_key_fpr == "":
|
||||
return None
|
||||
return context.get_key(daemon_key_fpr, secret=True)
|
||||
|
||||
|
||||
def get_imported_keys(context=default_context):
|
||||
# All the keys in the keyring, except for the daemon's key
|
||||
return list(
|
||||
filter(lambda k: k.fpr != daemon_key_fpr,
|
||||
context.keylist(secret=False)))
|
||||
|
||||
|
||||
def import_key(key, context=default_context):
|
||||
data = str.encode(key)
|
||||
if contains_private_keys(data):
|
||||
raise ValueError("Import cannot contain private keys!")
|
||||
return context.key_import(data)
|
||||
|
||||
|
||||
def export_key(fingerprint, context=default_context):
|
||||
if get_key(fingerprint) is None:
|
||||
return None
|
||||
# Key does exist, export it!
|
||||
return context.key_export(pattern=fingerprint)
|
||||
|
||||
|
||||
def delete_key(fingerprint, context=default_context):
|
||||
key = get_key(fingerprint)
|
||||
if fingerprint == daemon_key_fpr:
|
||||
raise ValueError("You cannot delete the daemon's key!")
|
||||
elif key is None:
|
||||
return None
|
||||
context.op_delete_ext(
|
||||
key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
|
||||
return True
|
||||
|
||||
|
||||
# Key usage
|
||||
|
||||
# Uses the daemon key to sign the provided message. If 'detached' is True, only the signature will be returned
|
||||
|
||||
|
||||
def create_signature(data, detached=False, context=default_context):
|
||||
signed_data, _ = context.sign(data,
|
||||
mode=gpg.constants.sig.mode.DETACH if
|
||||
detached else gpg.constants.sig.mode.CLEAR)
|
||||
return signed_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import utils
|
||||
# Check if we should renew the key
|
||||
|
||||
daemon_key = get_daemon_key()
|
||||
|
||||
exp = daemon_key.subkeys[0].expires
|
||||
now = datetime.datetime.utcnow()
|
||||
days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days
|
||||
if days_left > 14:
|
||||
sys.exit(0)
|
||||
else:
|
||||
utils.shell("check_output", ["management/pgp_renew.sh"])
|
12
management/pgp_renew.sh
Executable file
12
management/pgp_renew.sh
Executable file
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
# Renews the daemon's PGP key, if needed.
|
||||
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
export GNUPGHOME # Dump into the environment so that gpg uses it as homedir
|
||||
|
||||
gpg --batch --command-fd=0 --edit-key "${PGPKEY-}" << EOF;
|
||||
key 0
|
||||
expire
|
||||
180d
|
||||
save
|
||||
EOF
|
785
management/ssl_certificates.py
Executable file
785
management/ssl_certificates.py
Executable file
|
@ -0,0 +1,785 @@
|
|||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
# Utilities for installing and selecting SSL certificates.
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from utils import shell, safe_domain_name, sort_domains
|
||||
import idna
|
||||
|
||||
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
||||
|
||||
|
||||
def get_ssl_certificates(env):
|
||||
# Scan all of the installed SSL certificates and map every domain
|
||||
# that the certificates are good for to the best certificate for
|
||||
# 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
|
||||
"--agree-tos", # Automatically agrees to Let's Encrypt TOS
|
||||
"--register-unsafely-without-email", # The daemon takes care of renewals
|
||||
|
||||
# first will be main domain
|
||||
"-d",
|
||||
",".join(domain_list),
|
||||
|
||||
# use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
|
||||
"--csr",
|
||||
csr_file.name,
|
||||
# we only use the full chain
|
||||
"--cert-path",
|
||||
os.path.join(d, 'cert'),
|
||||
# we only use the full chain
|
||||
"--chain-path",
|
||||
os.path.join(d, 'chain'),
|
||||
"--fullchain-path",
|
||||
cert_file,
|
||||
"--webroot",
|
||||
"--webroot-path",
|
||||
webroot,
|
||||
"--config-dir",
|
||||
account_path,
|
||||
# "--staging",
|
||||
],
|
||||
stderr=subprocess.STDOUT).decode("utf8")
|
||||
install_cert_copy_file(cert_file, env)
|
||||
|
||||
ret[-1]["log"].append(certbotret)
|
||||
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" % (
|
||||
# common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
|
||||
safe_domain_name(cn),
|
||||
cert.not_valid_after.date().isoformat().replace("-",
|
||||
""), # expiration date
|
||||
hexlify(cert.fingerprint(
|
||||
hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
||||
)
|
||||
ssl_certificate = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
||||
|
||||
# Install the certificate.
|
||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||
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,159 +1,354 @@
|
|||
<style>
|
||||
#alias_table .actions > * { padding-right: 3px; }
|
||||
#alias_table .alias-required .remove { display: none }
|
||||
#alias_table .alias-auto .actions>* {
|
||||
display: none
|
||||
}
|
||||
|
||||
#addalias-form .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn.btn-xs {
|
||||
padding: .1rem .3rem;
|
||||
font-size: .75rem;
|
||||
border-radius: .2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Aliases</h2>
|
||||
|
||||
<h3>Add a mail alias</h3>
|
||||
|
||||
<p>Aliases are email forwarders. An alias can forward email to a <a href="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;">
|
||||
<div class="form-group">
|
||||
<label for="addaliasEmail" class="col-sm-2 control-label">Email Address</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addaliasEmail" placeholder="Incoming Email Address">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="addaliasTargets" class="col-sm-2 control-label">Forward To</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" id="addaliasTargets" placeholder="Forward to these email addresses (one per line or separated by commas)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button id="add-alias-button" type="submit" class="btn btn-primary">Add</button>
|
||||
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<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="mb-3">
|
||||
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
||||
<button type="button" class="btn btn-secondary" data-mode="regular">Regular</button>
|
||||
<button type="button" class="btn btn-secondary" data-mode="catchall">Catch-All</button>
|
||||
<button type="button" class="btn btn-secondary" data-mode="domainalias">Domain Alias</button>
|
||||
</div>
|
||||
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
||||
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
|
||||
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to
|
||||
another domain, preserving the part before the @-sign.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="addaliasAddress" class="input-group-text">Alias</label>
|
||||
<input type="email" class="form-control" id="addaliasAddress">
|
||||
</div>
|
||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted ">
|
||||
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
|
||||
You may use international (non-ASCII) characters for the domain part of the email address only.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9 mb-3">
|
||||
<div class="form-floating">
|
||||
<textarea class="form-control" style="min-height: 8em;" id="addaliasForwardsTo"></textarea>
|
||||
<label for="addaliasForwardsTo">Forwards To</label>
|
||||
</div>
|
||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
|
||||
<span class="domainalias text-muted">Enter just the part of an email address starting with the
|
||||
@-sign.</span>
|
||||
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded
|
||||
by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other
|
||||
domains, create a mail user and then log into webmail for the user and create a filter rule to forward
|
||||
mail.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Permitted Senders</h4>
|
||||
|
||||
<div class="col-lg-9 mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
||||
checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
||||
Any mail user listed in the Forwards To box can send mail claiming to be from <span
|
||||
class="regularalias">the alias address</span><span class="catchall domainalias">any address
|
||||
on the alias domain</span>.
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
||||
id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
||||
I’ll enter the mail users that can send mail claiming to be from <span
|
||||
class="regularalias">the alias address</span><span class="catchall domainalias">any address
|
||||
on the alias domain</span>.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addaliasForwardsToDiv" style="display: none;">
|
||||
<div class="form-floating">
|
||||
<textarea class="form-control" style="min-height: 8em;" id="addaliasSenders"
|
||||
placeholder="one user per line or separated by commas"></textarea>
|
||||
<label for="addaliasSenders">Permitted Senders</label>
|
||||
</div>
|
||||
<small>One user per line or separated by commas</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
|
||||
</form>
|
||||
|
||||
<h3>Existing mail aliases</h3>
|
||||
<table id="alias_table" class="table" style="width: auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Email Address<br></th>
|
||||
<th>Forwards To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
<table id="alias_table" class="table">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Alias<br></th>
|
||||
<th>Forwards To</th>
|
||||
<th>Permitted Senders</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 1.5em"><small>Hostmaster@, postmaster@, 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">
|
||||
<table>
|
||||
<tr id="alias-template">
|
||||
<td class='actions'>
|
||||
<a href="#" onclick="aliases_edit(this); return false;" class='edit' title="Edit Alias">
|
||||
<span class="glyphicon glyphicon-pencil"></span>
|
||||
</a>
|
||||
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
||||
<span class="glyphicon glyphicon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
<td class='email'> </td>
|
||||
<td class='target'> </td>
|
||||
</tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr id="alias-template">
|
||||
<td class='actions row justify-content-evenly' style="min-width: 4em; border: none;">
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;"
|
||||
class='edit' title="Edit Alias">
|
||||
<span class="text-center text-primary fas fa-pen"></span>
|
||||
</button>
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_remove(this); return false;"
|
||||
class='remove' title="Remove Alias">
|
||||
<span class="text-center text-danger fas fa-trash"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class='address'> </td>
|
||||
<td class='forwardsTo'> </td>
|
||||
<td class='senders'> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Mail aliases API (advanced)</h3>
|
||||
|
||||
<p>Use your box’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">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td><b>GET</b></td>
|
||||
<td><i>(none)</i></td>
|
||||
<td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded
|
||||
results.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/add</td>
|
||||
<td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/remove</td>
|
||||
<td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
||||
line argument which you must fill in with your email address and password.</p>
|
||||
|
||||
<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>
|
||||
function show_aliases() {
|
||||
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/aliases",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function(r) {
|
||||
$('#alias_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#alias-template").clone();
|
||||
n.attr('id', '');
|
||||
function show_aliases() {
|
||||
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/aliases",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function (r) {
|
||||
$('#alias_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var hdr = $("<tr><th colspan='4' class='bg-light'></th></tr>");
|
||||
hdr.find('th').text(r[i].domain);
|
||||
$('#alias_table tbody').append(hdr);
|
||||
|
||||
if (r[i].required) n.addClass('alias-required');
|
||||
n.attr('data-email', r[i].source);
|
||||
n.find('td.email').text(r[i].source)
|
||||
for (var j = 0; j < r[i].destination.length; j++)
|
||||
n.find('td.target').append($("<div></div>").text(r[i].destination[j]))
|
||||
$('#alias_table tbody').append(n);
|
||||
}
|
||||
})
|
||||
}
|
||||
for (var k = 0; k < r[i].aliases.length; k++) {
|
||||
var alias = r[i].aliases[k];
|
||||
|
||||
var is_alias_add_update = false;
|
||||
function do_add_alias() {
|
||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||
var email = $("#addaliasEmail").val();
|
||||
var targets = $("#addaliasTargets").val();
|
||||
api(
|
||||
"/mail/aliases/add",
|
||||
"POST",
|
||||
{
|
||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||
source: email,
|
||||
destination: targets
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error(title, $("<pre/>").text(r));
|
||||
show_aliases()
|
||||
aliases_reset_form();
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error(title, r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
var n = $("#alias-template").clone();
|
||||
n.attr('id', '');
|
||||
|
||||
function aliases_reset_form() {
|
||||
$("#addaliasEmail").prop('disabled', false);
|
||||
$("#addaliasEmail").val('')
|
||||
$("#addaliasTargets").val('')
|
||||
$('#alias-cancel').addClass('hidden');
|
||||
$('#add-alias-button').text('Add');
|
||||
is_alias_add_update = false;
|
||||
}
|
||||
if (alias.auto) n.addClass('alias-auto');
|
||||
n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend
|
||||
n.find('td.address').text(alias.address_display)
|
||||
for (var j = 0; j < alias.forwards_to.length; j++)
|
||||
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[j]))
|
||||
for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++)
|
||||
n.find('td.senders').append($("<div></div>").text(alias.permitted_senders[j]))
|
||||
$('#alias_table tbody').append(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function aliases_edit(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var targetdivs = $(elem).parents('tr').find('.target div');
|
||||
var targets = "";
|
||||
for (var i = 0; i < targetdivs.length; i++)
|
||||
targets += $(targetdivs[i]).text() + "\n";
|
||||
$('#alias_type_buttons button').off('click').click(function () {
|
||||
$('#alias_type_buttons button').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
|
||||
if ($(this).attr('data-mode') == "regular") {
|
||||
$('#addaliasAddress').attr('type', 'email');
|
||||
$('#addaliasAddress').attr('placeholder', 'you@example.com (incoming email address)');
|
||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||
$('#alias_mode_info').slideUp();
|
||||
$('#addalias-form .regularalias').removeClass('hidden');
|
||||
} else if ($(this).attr('data-mode') == "catchall") {
|
||||
$('#addaliasAddress').attr('type', 'text');
|
||||
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
|
||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||
$('#alias_mode_info').slideDown();
|
||||
$('#addalias-form .catchall').removeClass('hidden');
|
||||
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||
$('#addaliasAddress').attr('type', 'text');
|
||||
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
|
||||
$('#addaliasForwardsTo').attr('placeholder', '@example.net (forward to other domain)');
|
||||
$('#alias_mode_info').slideDown();
|
||||
$('#addalias-form .domainalias').removeClass('hidden');
|
||||
}
|
||||
})
|
||||
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
||||
}
|
||||
|
||||
is_alias_add_update = true;
|
||||
$('#alias-cancel').removeClass('hidden');
|
||||
$("#addaliasEmail").prop('disabled', true);
|
||||
$("#addaliasEmail").val(email);
|
||||
$("#addaliasTargets").val(targets);
|
||||
$('#add-alias-button').text('Update');
|
||||
$('body').animate({ scrollTop: 0 })
|
||||
}
|
||||
var is_alias_add_update = false;
|
||||
function do_add_alias() {
|
||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||
var form_address = $("#addaliasAddress").val();
|
||||
var form_forwardsto = $("#addaliasForwardsTo").val();
|
||||
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
|
||||
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
|
||||
show_modal_error(title, "You did not enter any permitted senders.");
|
||||
return false;
|
||||
}
|
||||
api(
|
||||
"/mail/aliases/add",
|
||||
"POST",
|
||||
{
|
||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||
address: form_address,
|
||||
forwards_to: form_forwardsto,
|
||||
permitted_senders: form_senders
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error(title, $("<pre/>").text(r));
|
||||
show_aliases()
|
||||
aliases_reset_form();
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error(title, r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function aliases_remove(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Remove Alias",
|
||||
"Remove " + email + "?",
|
||||
"Remove",
|
||||
function() {
|
||||
api(
|
||||
"/mail/aliases/remove",
|
||||
"POST",
|
||||
{
|
||||
source: email
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||
show_aliases();
|
||||
});
|
||||
});
|
||||
}
|
||||
function aliases_reset_form() {
|
||||
$("#addaliasAddress").prop('disabled', false);
|
||||
$("#addaliasAddress").val('')
|
||||
$("#addaliasForwardsTo").val('')
|
||||
$("#addaliasSenders").val('')
|
||||
$('#alias-cancel').addClass('hidden');
|
||||
$('#add-alias-button').text('Add Alias');
|
||||
is_alias_add_update = false;
|
||||
}
|
||||
|
||||
function aliases_edit(elem) {
|
||||
var address = $(elem).parents('tr').attr('data-address');
|
||||
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
||||
var senderdivs = $(elem).parents('tr').find('.senders div');
|
||||
var forwardsTo = "";
|
||||
for (var i = 0; i < receiverdivs.length; i++)
|
||||
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
||||
var senders = "";
|
||||
for (var i = 0; i < senderdivs.length; i++)
|
||||
senders += $(senderdivs[i]).text() + "\n";
|
||||
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
||||
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
||||
else if (address.charAt(0) == '@')
|
||||
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
||||
else
|
||||
$('#alias_type_buttons button[data-mode="regular"]').click();
|
||||
$('#alias-cancel').removeClass('hidden');
|
||||
$("#addaliasAddress").prop('disabled', true);
|
||||
$("#addaliasAddress").val(address);
|
||||
$("#addaliasForwardsTo").val(forwardsTo);
|
||||
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
|
||||
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
|
||||
$("#addaliasSenders").val(senders);
|
||||
$('#add-alias-button').text('Update');
|
||||
$('body').animate({ scrollTop: 0 })
|
||||
is_alias_add_update = true;
|
||||
}
|
||||
|
||||
function aliases_remove(elem) {
|
||||
var row_address = $(elem).parents('tr').attr('data-address');
|
||||
show_modal_confirm(
|
||||
"Remove Alias",
|
||||
"Remove " + row_address + "?",
|
||||
"Remove",
|
||||
function () {
|
||||
api(
|
||||
"/mail/aliases/remove",
|
||||
"POST",
|
||||
{
|
||||
address: row_address
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove Alias", $("<pre/>").text(r));
|
||||
show_aliases();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scroll_top() {
|
||||
$('html, body').animate({
|
||||
scrollTop: $("#panel_aliases").offset().top
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
|
374
management/templates/custom-dns.html
Normal file
374
management/templates/custom-dns.html
Normal file
|
@ -0,0 +1,374 @@
|
|||
<style>
|
||||
#custom-dns-current td.long {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Custom DNS</h2>
|
||||
|
||||
<p class="text-warning">This is an advanced configuration page.</p>
|
||||
|
||||
<p>It is possible to set custom DNS records on domains hosted here.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
|
||||
<div class="col-lg-10 col-xl-7 mb-3">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text">Name</label>
|
||||
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
|
||||
<label class="input-group-text">.</label>
|
||||
<select id="customdnsZone" class="form-select"></select>
|
||||
</div>
|
||||
<small>Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</small>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-7 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="customdnsType" class="input-group-text">Type</label>
|
||||
<select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()">
|
||||
<option value="A"
|
||||
data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">
|
||||
A (IPv4 address)</option>
|
||||
<option value="AAAA"
|
||||
data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">
|
||||
AAAA (IPv6 address)</option>
|
||||
<option value="CAA"
|
||||
data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild "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="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||
<option value="MX"
|
||||
data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">
|
||||
MX (mail exchanger)</option>
|
||||
<option value="SRV"
|
||||
data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">
|
||||
SRV (service record)</option>
|
||||
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH
|
||||
fingerprint record)</option>
|
||||
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS
|
||||
subdomain delegation)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-7 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="customdnsValue" class="input-group-text">Value</label>
|
||||
<input type="text" class="form-control" id="customdnsValue" placeholder="">
|
||||
<label for="customdnsTtl" class="input-group-text">TTL</label>
|
||||
<input type="number" class="form-control" id="customdnsTtl" placeholder="default">
|
||||
</div>
|
||||
<small id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-3">Set Record</button>
|
||||
</form>
|
||||
|
||||
<div style="font-size: 90%;">
|
||||
<b>Sort by:</b>
|
||||
<a href="#"
|
||||
onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain
|
||||
name</a>
|
||||
<b>|</b>
|
||||
<a href="#"
|
||||
onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">Created</a>
|
||||
</div>
|
||||
<table id="custom-dns-current" class="table col-12" style="display: none; margin-top: 0;">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th>Domain Name</th>
|
||||
<th>Record Type</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="5">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Using a secondary nameserver</h3>
|
||||
|
||||
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#"
|
||||
onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or
|
||||
use the DNS server on this box but add a secondary (aka “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;">
|
||||
<div class="col-12 form-floating mb-3">
|
||||
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname"
|
||||
placeholder="ns1.example.com"></textarea>
|
||||
<label for="secondarydnsHostname">Secondary Nameservers</label>
|
||||
<div>
|
||||
<p class="small">
|
||||
Multiple secondary servers can be separated with commas or spaces (i.e., <span
|
||||
class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
|
||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP
|
||||
address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span
|
||||
class="font-monospace">xfr:10.0.0.0/8</span>.
|
||||
</p>
|
||||
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
||||
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the
|
||||
default/normal setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</form>
|
||||
|
||||
|
||||
<h3>Custom DNS API</h3>
|
||||
|
||||
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own
|
||||
dynamic DNS service.</p>
|
||||
|
||||
<p>Usage:</p>
|
||||
|
||||
<pre>curl -X <b>VERB</b> [-d "<b>value</b>"] --user {email}:{password} https://{{hostname}}/admin/dns/custom[/<b>qname</b>[/<b>rtype</b>]]</pre>
|
||||
|
||||
<p>(Brackets denote an optional argument.)</p>
|
||||
|
||||
<h4>Verbs</h4>
|
||||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Usage</th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>GET</b></td>
|
||||
<td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>,
|
||||
<code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code>
|
||||
parameters in the request URL filter the records returned in the response. The request body
|
||||
(<code>-d "..."</code>) must be omitted.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>PUT</b></td>
|
||||
<td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and
|
||||
<code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and
|
||||
<code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>POST</b></td>
|
||||
<td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin
|
||||
<code>A</code> records. (PUT would delete previously added records.)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>DELETE</b></td>
|
||||
<td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all
|
||||
records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only
|
||||
the record matching the <code>qname</code>, <code>rtype</code> and value.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Parameters</h4>
|
||||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th>Parameter</th>
|
||||
<th>Value</th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>email</b></td>
|
||||
<td>The email address of any administrative user here.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>password</b></td>
|
||||
<td>That user’s password.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>qname</b></td>
|
||||
<td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or
|
||||
a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>rtype</b></td>
|
||||
<td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address),
|
||||
<code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which
|
||||
is a fully qualified domain name — 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 class="font-monospace"><b>value</b></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>
|
||||
|
||||
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a
|
||||
href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be
|
||||
added to all custom domains unless you override them.</p>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
||||
line argument which you must fill in with your email address and password.</p>
|
||||
|
||||
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
||||
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||
|
||||
# deletes that record and all A records for that domain name
|
||||
curl -X DELETE https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||
|
||||
# sets a CNAME alias
|
||||
curl -X PUT -d "bar.mydomain.com." https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
|
||||
|
||||
# deletes that CNAME and all CNAME records for that domain name
|
||||
curl -X DELETE https://{{hostname}}/admin/dns/custom/foo.mydomain.com/cname
|
||||
|
||||
# adds a TXT record using POST to preserve any previous TXT records
|
||||
curl -X POST -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
|
||||
|
||||
# deletes that one TXT record while preserving other TXT records
|
||||
curl -X DELETE -d "some text here" https://{{hostname}}/admin/dns/custom/foo.mydomain.com/txt
|
||||
</pre>
|
||||
|
||||
<script>
|
||||
function show_custom_dns() {
|
||||
api(
|
||||
"/dns/secondary-nameserver",
|
||||
"GET",
|
||||
{},
|
||||
function (data) {
|
||||
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
||||
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
||||
});
|
||||
|
||||
api(
|
||||
"/dns/zones",
|
||||
"GET",
|
||||
{},
|
||||
function (data) {
|
||||
$('#customdnsZone').text('');
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
$('#customdnsZone').append($('<option/>').text(data[i]));
|
||||
}
|
||||
});
|
||||
|
||||
show_current_custom_dns();
|
||||
show_customdns_rtype_hint();
|
||||
}
|
||||
|
||||
function show_current_custom_dns() {
|
||||
api(
|
||||
"/dns/custom",
|
||||
"GET",
|
||||
{},
|
||||
function (data) {
|
||||
if (data.length > 0)
|
||||
$('#custom-dns-current').fadeIn();
|
||||
else
|
||||
$('#custom-dns-current').fadeOut();
|
||||
window.miab_custom_dns_data = data;
|
||||
show_current_custom_dns_update_after_sort();
|
||||
});
|
||||
}
|
||||
|
||||
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++) {
|
||||
if (sort_key == "qname" && data[i].zone != last_zone) {
|
||||
var r = $("<tr><th colspan=5 class='bg-light'></th></tr>");
|
||||
r.find("th").text(data[i].zone);
|
||||
tbody.append(r);
|
||||
last_zone = data[i].zone;
|
||||
}
|
||||
|
||||
var tr = $("<tr/>");
|
||||
tbody.append(tr);
|
||||
tr.attr('data-qname', data[i].qname);
|
||||
tr.attr('data-rtype', data[i].rtype);
|
||||
tr.attr('data-value', data[i].value);
|
||||
tr.append($('<td class="long"/>').text(data[i].qname));
|
||||
tr.append($('<td/>').text(data[i].rtype));
|
||||
tr.append($('<td class="long font-monospace" style="max-width: 40em"/>').text(data[i].value));
|
||||
if (data[i].ttl) {
|
||||
tr.append($('<td/>').text(data[i].ttl));
|
||||
} else {
|
||||
tr.append($('<td/>').html('<i class="">default</i>'));
|
||||
}
|
||||
tr.append($('<td class="col-1">[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
||||
}
|
||||
}
|
||||
|
||||
function delete_custom_dns_record(elem) {
|
||||
var qname = $(elem).parents('tr').attr('data-qname');
|
||||
var rtype = $(elem).parents('tr').attr('data-rtype');
|
||||
var value = $(elem).parents('tr').attr('data-value');
|
||||
do_set_custom_dns(qname, rtype, value, "DELETE");
|
||||
return false;
|
||||
}
|
||||
|
||||
function do_set_secondary_dns() {
|
||||
api(
|
||||
"/dns/secondary-nameserver",
|
||||
"POST",
|
||||
{
|
||||
hostnames: $('#secondarydnsHostname').val()
|
||||
},
|
||||
function (data) {
|
||||
if (data == "") return; // nothing updated
|
||||
show_modal_error("Secondary DNS", $("<pre/>").text(data));
|
||||
$('#secondarydns-clear-instructions').slideDown();
|
||||
},
|
||||
function (err) {
|
||||
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
|
||||
function do_set_custom_dns(qname, rtype, value, method) {
|
||||
if (!qname) {
|
||||
if ($('#customdnsQname').val() != '')
|
||||
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
|
||||
else
|
||||
qname = $('#customdnsZone').val();
|
||||
rtype = $('#customdnsType').val();
|
||||
value = {
|
||||
value: $('#customdnsValue').val(),
|
||||
ttl: $('#customdnsTtl').val()
|
||||
};
|
||||
method = 'POST';
|
||||
} else {
|
||||
value = {
|
||||
value: value
|
||||
}
|
||||
}
|
||||
|
||||
api(
|
||||
"/dns/custom/" + qname + "/" + rtype,
|
||||
method,
|
||||
value,
|
||||
function (data) {
|
||||
if (data == "") return; // nothing updated
|
||||
show_modal_error("Custom DNS", $("<pre/>").text(data));
|
||||
show_current_custom_dns();
|
||||
},
|
||||
function (err) {
|
||||
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
|
||||
function show_customdns_rtype_hint() {
|
||||
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
|
||||
}
|
||||
</script>
|
131
management/templates/external-dns.html
Normal file
131
management/templates/external-dns.html
Normal file
|
@ -0,0 +1,131 @@
|
|||
<style>
|
||||
#external_dns_settings .domain-header {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#external_dns_settings .values td {
|
||||
border: 0;
|
||||
padding-top: .75em;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#external_dns_settings .value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#external_dns_settings .explanation td {
|
||||
padding-top: .5em;
|
||||
padding-bottom: .75em;
|
||||
font-style: italic;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>External DNS</h2>
|
||||
|
||||
<p class="text-warning">This is an advanced configuration page.</p>
|
||||
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in
|
||||
the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS
|
||||
zone information shown in the table below into your external DNS server’s control panel.</p>
|
||||
|
||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your
|
||||
domain name by setting a DS record at your registrar, you will likely have to turn it off before changing
|
||||
nameservers.</p>
|
||||
|
||||
|
||||
<p class="alert" role="alert">
|
||||
<span class="fas fa-info-circle"></span>
|
||||
You may encounter zone file errors when attempting to create a TXT record with a long string.
|
||||
<a href="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain
|
||||
multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to
|
||||
validate your zone file.
|
||||
</p>
|
||||
|
||||
<h3>Download zonefile</h3>
|
||||
<p>You can download your zonefiles here or use the table of records below.</p>
|
||||
<form class="form-inline" role="form" onsubmit="do_download_zonefile(); return false;">
|
||||
<div class="col-md-10 col-xl-6 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="downloadZonefile" class="input-group-text">Zone</label>
|
||||
<select id="downloadZonefile" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Download</button>
|
||||
</form>
|
||||
|
||||
<h3>Records</h3>
|
||||
|
||||
<table id="external_dns_settings" class="table">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QName</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
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>")
|
||||
api(
|
||||
"/dns/dump",
|
||||
"GET",
|
||||
{},
|
||||
function (zones) {
|
||||
$('#external_dns_settings tbody').html("");
|
||||
for (var j = 0; j < zones.length; j++) {
|
||||
var h = $("<tr><td colspan='3' class='bg-light'><h4 class='domain-header'/></td></tr>");
|
||||
h.find("td h4").text(zones[j][0]);
|
||||
$('#external_dns_settings tbody').append(h);
|
||||
|
||||
var r = zones[j][1];
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value font-monospace'/></tr>");
|
||||
n.find('.qname').text(r[i].qname);
|
||||
n.find('.rtype').text(r[i].rtype);
|
||||
n.find('.value').text(r[i].value);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
|
||||
var n = $("<tr class='explanation'><td colspan='3'/><small/></tr>");
|
||||
n.find('td small').text(r[i].explanation);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function do_download_zonefile() {
|
||||
var zone = $('#downloadZonefile').val();
|
||||
|
||||
api(
|
||||
"/dns/zonefile/" + zone,
|
||||
"GET",
|
||||
{},
|
||||
function (data) {
|
||||
show_modal_error("Download Zonefile", $("<pre/>").text(data));
|
||||
},
|
||||
function (err) {
|
||||
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
|
||||
});
|
||||
}
|
||||
</script>
|
|
@ -1,359 +1,630 @@
|
|||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<html lang="en">
|
||||
|
||||
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
|
||||
<head>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{hostname}} - Mail-in-a-Box Control Panel</title>
|
||||
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
|
||||
<style>
|
||||
@import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300);
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
<link rel="stylesheet" href="/admin/assets/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/fontawesome/css/fontawesome.min.css">
|
||||
<link rel="stylesheet" href="/admin/assets/fontawesome/css/solid.min.css">
|
||||
|
||||
body {
|
||||
padding-top: 50px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
<style>
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: Raleway, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 1em 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 130%;
|
||||
border-bottom: 1px solid black;
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 130%;
|
||||
border-bottom: 1px solid black;
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 110%;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.panel-heading h3 {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
}
|
||||
h4 {
|
||||
font-size: 110%;
|
||||
margin-bottom: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
table.table {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">
|
||||
<style>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 7]>
|
||||
<p class="chromeframe">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> or <a href="http://www.google.com/chromeframe/?redirect=true">activate Google Chrome Frame</a> to improve your experience.</p>
|
||||
<![endif]-->
|
||||
<div class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="#">{{hostname}}</a>
|
||||
</div>
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown active">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">System <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
||||
<li><a href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Super Advanced Options</li>
|
||||
<li><a href="#system_dns" onclick="return show_panel(this);">DNS (Custom/External)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown active">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail/Sync <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#mail-guide" onclick="return show_panel(this);">Mail Instructions</a></li>
|
||||
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
|
||||
<li class="divider"></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>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
|
||||
</ul>
|
||||
</div><!--/.navbar-collapse -->
|
||||
</div>
|
||||
</div>
|
||||
h4:first-child {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div id="panel_system_status" class="container panel">
|
||||
{% include "system-status.html" %}
|
||||
</div>
|
||||
.admin_panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
<div id="panel_system_backup" class="container panel">
|
||||
{% include "system-backup.html" %}
|
||||
</div>
|
||||
table.table {
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
<div id="panel_system_dns" class="container panel">
|
||||
{% include "system-dns.html" %}
|
||||
</div>
|
||||
ol li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
<div id="panel_login" class="panel">
|
||||
{% include "login.html" %}
|
||||
</div>
|
||||
.if-logged-in {
|
||||
display: none;
|
||||
}
|
||||
|
||||
<div id="panel_mail-guide" class="container panel">
|
||||
{% include "mail-guide.html" %}
|
||||
</div>
|
||||
.if-logged-in-admin {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<div id="panel_users" class="container panel">
|
||||
{% include "users.html" %}
|
||||
</div>
|
||||
<body class="">
|
||||
|
||||
<div id="panel_aliases" class="container panel">
|
||||
{% include "aliases.html" %}
|
||||
</div>
|
||||
<div class="navbar navbar-expand-lg navbar-light" role="navigation">
|
||||
<div class="container bg-light pt-2 pb-2">
|
||||
<div class="if-logged-in">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
|
||||
aria-controls="#navbarContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-brand ms-2">
|
||||
<span class="fas fa-envelope"></span>
|
||||
<b>{{hostname}}</b>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
<span class="me-1 fas fa-sun"></span>
|
||||
<span class="form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="toggle-theme">
|
||||
<label class="ms-1 fas fa-moon"></label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="navbarContent">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">System</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#system_status" onclick="return show_panel(this);">Status
|
||||
Checks</a></li>
|
||||
<li><a class="dropdown-item" href="#tls" onclick="return show_panel(this);">TLS (SSL)
|
||||
Certificates</a></li>
|
||||
<li><a class="dropdown-item" href="#system_backup" onclick="return show_panel(this);">Backup
|
||||
Status</a></li>
|
||||
<li><a class="dropdown-item" href="#smtp_relays" onclick="return show_panel(this);">SMTP
|
||||
Relays</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Advanced</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#custom_dns" onclick="return show_panel(this);">Custom
|
||||
DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#external_dns"
|
||||
onclick="return show_panel(this);">External DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#pgp_keyring" onclick="return show_panel(this);">PGP
|
||||
Keyring Management</a></li>
|
||||
<li><a class="dropdown-item" href="#wkd" onclick="return show_panel(this);">WKD
|
||||
Management</a></li>
|
||||
<li><a class="dropdown-item" href="#munin" onclick="return show_panel(this);">Munin
|
||||
Monitoring</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-not-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Your Account</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#manage-password" onclick="return show_panel(this);">Manage Password</a></li>
|
||||
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 btn if-logged-in-not-admin" type="button" href="#mail-guide"
|
||||
onclick="return show_panel(this);">
|
||||
Mail Guide
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Mail</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#mail-guide" onclick="return show_panel(this);">Mail
|
||||
Guide</a></li>
|
||||
<li><a class="dropdown-item" href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||
<li><a class="dropdown-item" href="#aliases" onclick="return show_panel(this);">Aliases</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Your Account</li>
|
||||
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor
|
||||
Authentication</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in" type="button" href="#sync_guide"
|
||||
onclick="return show_panel(this);">Contacts/Calendar</button></li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in-admin" type="button" href="#web"
|
||||
onclick="return show_panel(this);">Web</button></li>
|
||||
<li><button class="nav-item btn btn-secondary if-logged-in" type="button"
|
||||
onclick="do_logout(); return false;"><b>Logout</b></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel_sync_guide" class="container panel">
|
||||
{% include "sync-guide.html" %}
|
||||
</div>
|
||||
<div class="container">
|
||||
<div id="panel_smtp_relays" class="admin_panel">
|
||||
{% include "smtp-relays.html" %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div id="panel_welcome" class="admin_panel">
|
||||
{% include "welcome.html" %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>This is a <a href="https://mailinabox.email">Mail-in-a-Box</a>.</p>
|
||||
</footer>
|
||||
</div> <!-- /container -->
|
||||
<div id="panel_system_status" class="admin_panel">
|
||||
{% include "system-status.html" %}
|
||||
</div>
|
||||
|
||||
<div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; text-align: center; background-color: rgba(255,255,255,.75)">
|
||||
<div style="margin: 20% auto">
|
||||
<div><span class="glyphicon glyphicon-time"></span></div>
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel_system_backup" class="admin_panel">
|
||||
{% include "system-backup.html" %}
|
||||
</div>
|
||||
|
||||
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title" id="errorModalTitle"> </h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p> </p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
|
||||
<button type="button" class="btn btn-danger" data-dismiss="modal">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel_external_dns" class="admin_panel">
|
||||
{% include "external-dns.html" %}
|
||||
</div>
|
||||
|
||||
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
|
||||
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
|
||||
<div id="panel_custom_dns" class="admin_panel">
|
||||
{% include "custom-dns.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var global_modal_state = null;
|
||||
var global_modal_funcs = null;
|
||||
<div id="panel_pgp_keyring" class="admin_panel">
|
||||
{% include "pgp-keyring.html" %}
|
||||
</div>
|
||||
|
||||
$(function() {
|
||||
$('#global_modal').on('shown.bs.modal', function (e) {
|
||||
// set focus to first input in the global modal's body
|
||||
var input = $('#global_modal .modal-body input');
|
||||
if (input.length > 0) $(input[0]).focus();
|
||||
})
|
||||
$('#global_modal .btn-danger').click(function() {
|
||||
// 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
|
||||
// is closing.
|
||||
global_modal_state = 0; // OK
|
||||
})
|
||||
$('#global_modal .btn-default').click(function() {
|
||||
global_modal_state = 1; // Cancel
|
||||
})
|
||||
$('#global_modal').on('hidden.bs.modal', function (e) {
|
||||
// do the cancel 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_funcs && global_modal_funcs[global_modal_state])
|
||||
global_modal_funcs[global_modal_state]();
|
||||
})
|
||||
})
|
||||
<div id="panel_wkd" class="admin_panel">
|
||||
{% include "wkd.html" %}
|
||||
</div>
|
||||
|
||||
function show_modal_error(title, message, callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
if (typeof question == String) {
|
||||
$('#global_modal p').text(message);
|
||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||
} else {
|
||||
$('#global_modal p').html("").append(message);
|
||||
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
||||
}
|
||||
$('#global_modal .btn-default').show().text("OK");
|
||||
$('#global_modal .btn-danger').hide();
|
||||
global_modal_funcs = [callback, callback];
|
||||
global_modal_state = null;
|
||||
$('#global_modal').modal({});
|
||||
}
|
||||
<div id="panel_manage-password" class="admin_panel">
|
||||
{% include "manage-password.html" %}
|
||||
</div>
|
||||
|
||||
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
if (typeof question == String) {
|
||||
$('#global_modal .modal-dialog').addClass("modal-sm");
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
$('#global_modal p').text(question);
|
||||
} else {
|
||||
$('#global_modal .modal-dialog').removeClass("modal-sm");
|
||||
$('#global_modal .modal-body').html("").append(question);
|
||||
}
|
||||
$('#global_modal .btn-default').show().text("Cancel");
|
||||
$('#global_modal .btn-danger').show().text(verb);
|
||||
global_modal_funcs = [yes_callback, cancel_callback];
|
||||
global_modal_state = null;
|
||||
$('#global_modal').modal({});
|
||||
}
|
||||
<div id="panel_mfa" class="admin_panel">
|
||||
{% include "mfa.html" %}
|
||||
</div>
|
||||
|
||||
var is_ajax_loading = false;
|
||||
function ajax(options) {
|
||||
setTimeout("if (is_ajax_loading) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||
function hide_loading_indicator() {
|
||||
is_ajax_loading = false;
|
||||
$('#ajax_loading_indicator').hide();
|
||||
}
|
||||
var old_success = options.success;
|
||||
var old_error = options.error;
|
||||
options.success = function(data) {
|
||||
hide_loading_indicator();
|
||||
if (data.status == "error")
|
||||
show_modal_error("Error", data.message);
|
||||
else if (old_success)
|
||||
old_success(data);
|
||||
};
|
||||
options.error = function(jqxhr) {
|
||||
hide_loading_indicator();
|
||||
if (!old_error)
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
else
|
||||
old_error(jqxhr.responseText);
|
||||
};
|
||||
is_ajax_loading = true;
|
||||
$.ajax(options);
|
||||
}
|
||||
<div id="panel_login" class="admin_panel">
|
||||
{% include "login.html" %}
|
||||
</div>
|
||||
|
||||
var api_credentials = ["", ""];
|
||||
function api(url, method, data, callback, callback_error) {
|
||||
// from http://www.webtoolkit.info/javascript-base64.html
|
||||
function base64encode(input) {
|
||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
var output = "";
|
||||
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
chr1 = input.charCodeAt(i++);
|
||||
chr2 = input.charCodeAt(i++);
|
||||
chr3 = input.charCodeAt(i++);
|
||||
enc1 = chr1 >> 2;
|
||||
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
|
||||
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
|
||||
enc4 = chr3 & 63;
|
||||
if (isNaN(chr2)) {
|
||||
enc3 = enc4 = 64;
|
||||
} else if (isNaN(chr3)) {
|
||||
enc4 = 64;
|
||||
}
|
||||
output = output +
|
||||
_keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
|
||||
_keyStr.charAt(enc3) + _keyStr.charAt(enc4);
|
||||
}
|
||||
<div id="panel_mail-guide" class="admin_panel">
|
||||
{% include "mail-guide.html" %}
|
||||
</div>
|
||||
|
||||
return output;
|
||||
}
|
||||
<div id="panel_users" class="admin_panel">
|
||||
{% include "users.html" %}
|
||||
</div>
|
||||
|
||||
ajax({
|
||||
url: "/admin" + url,
|
||||
method: method,
|
||||
data: data,
|
||||
beforeSend: function(xhr) {
|
||||
// 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
|
||||
// by user actions.
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Basic ' + base64encode(api_credentials[0] + ':' + api_credentials[1]));
|
||||
},
|
||||
success: callback,
|
||||
error: callback_error,
|
||||
statusCode: {
|
||||
403: function(xhr) {
|
||||
// Credentials are no longer valid. Try to login again.
|
||||
var p = current_panel;
|
||||
show_panel('login');
|
||||
switch_back_to_panel = p;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
<div id="panel_aliases" class="admin_panel">
|
||||
{% include "aliases.html" %}
|
||||
</div>
|
||||
|
||||
var current_panel = null;
|
||||
var switch_back_to_panel = null;
|
||||
function show_panel(panelid) {
|
||||
if (panelid.getAttribute)
|
||||
// we might be passed an HTMLElement <a>.
|
||||
panelid = panelid.getAttribute('href').substring(1);
|
||||
<div id="panel_sync_guide" class="admin_panel">
|
||||
{% include "sync-guide.html" %}
|
||||
</div>
|
||||
|
||||
$('.panel').hide();
|
||||
$('#panel_' + panelid).show();
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.setItem("miab-cp-lastpanel", panelid);
|
||||
if (window["show_" + panelid])
|
||||
window["show_" + panelid]();
|
||||
<div id="panel_web" class="admin_panel">
|
||||
{% include "web.html" %}
|
||||
</div>
|
||||
|
||||
current_panel = panelid;
|
||||
switch_back_to_panel = null;
|
||||
<div id="panel_tls" class="admin_panel">
|
||||
{% include "ssl.html" %}
|
||||
</div>
|
||||
|
||||
return false; // when called from onclick, cancel navigation
|
||||
}
|
||||
<div id="panel_munin" class="admin_panel">
|
||||
{% include "munin.html" %}
|
||||
</div>
|
||||
|
||||
$(function() {
|
||||
// Recall saved user credentials.
|
||||
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
|
||||
api_credentials = sessionStorage.getItem("miab-cp-credentials").split(":");
|
||||
else if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-credentials"))
|
||||
api_credentials = localStorage.getItem("miab-cp-credentials").split(":");
|
||||
<hr>
|
||||
|
||||
// Recall what the user was last looking at.
|
||||
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
|
||||
show_panel(localStorage.getItem("miab-cp-lastpanel"));
|
||||
} else {
|
||||
show_panel('login');
|
||||
}
|
||||
})
|
||||
<footer>
|
||||
<p>This is a <a href="https://power-mailinabox.net">Power Mail-in-a-Box</a>.</p>
|
||||
</footer>
|
||||
</div> <!-- /container -->
|
||||
|
||||
</script>
|
||||
</body>
|
||||
<div id="ajax_loading_indicator"
|
||||
style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(0,0,0,.8)">
|
||||
<div class="justify-content-center" style="margin: 20%;">
|
||||
<div class="spinner-border text-light" role="status" style="width: 4rem; height: 4rem;"></div>
|
||||
<div class="text-light display-5">Loading... please wait!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="errorModalTitle"> </h4>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-hidden="true"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p> </p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-bs-dismiss="modal">OK</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.darkmode,
|
||||
.darkmode .form-control,
|
||||
.darkmode .form-select {
|
||||
background-color: rgb(20, 20, 20) !important;
|
||||
}
|
||||
|
||||
.darkmode .btn-light,
|
||||
.darkmode .bg-light,
|
||||
.darkmode .dropdown-menu,
|
||||
.darkmode .input-group-text,
|
||||
.darkmode .modal-content,
|
||||
.darkmode .form-control:disabled,
|
||||
.darkmode .form-select:disabled {
|
||||
background-color: rgb(36, 36, 36) !important;
|
||||
}
|
||||
|
||||
.darkmode .form-control,
|
||||
.darkmode .form-select,
|
||||
.darkmode .btn-light,
|
||||
.darkmode .input-group-text,
|
||||
.darkmode .table,
|
||||
.darkmode .tr,
|
||||
.darkmode .thead,
|
||||
.darkmode .card,
|
||||
.darkmode .modal-header,
|
||||
.darkmode .modal-footer {
|
||||
border-color: rgb(56, 56, 56) !important;
|
||||
}
|
||||
|
||||
.darkmode h3 {
|
||||
border-color: rgb(206, 212, 218) !important;
|
||||
}
|
||||
|
||||
.darkmode .table>:not(:first-child) {
|
||||
border-top: 2px solid rgb(206, 212, 218) !important;
|
||||
}
|
||||
|
||||
.darkmode a,
|
||||
.darkmode b,
|
||||
.darkmode p,
|
||||
.darkmode label,
|
||||
.darkmode span,
|
||||
.darkmode small,
|
||||
.darkmode div,
|
||||
.darkmode td,
|
||||
.darkmode hr,
|
||||
.darkmode .form-control,
|
||||
.darkmode .form-select {
|
||||
color: rgb(206, 212, 218);
|
||||
}
|
||||
|
||||
.darkmode .btn,
|
||||
.darkmode h1,
|
||||
.darkmode h2,
|
||||
.darkmode h3,
|
||||
.darkmode h4,
|
||||
.darkmode h5,
|
||||
.darkmode .navbar-brand,
|
||||
.darkmode .dropdown-menu,
|
||||
.darkmode th,
|
||||
.darkmode .navbar-brand>span,
|
||||
.darkmode .navbar-brand>b,
|
||||
.darkmode .nav-item>*,
|
||||
.darkmode .input-group-text,
|
||||
.darkmode .input-group-text>* {
|
||||
color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
.darkmode .btn-close,
|
||||
.darkmode .navbar-toggler {
|
||||
filter: invert(97%) sepia(2%) saturate(1242%) hue-rotate(180deg) brightness(93%) contrast(82%);
|
||||
}
|
||||
|
||||
.darkmode .status-error .status-text {
|
||||
color: rgb(255, 191, 191);
|
||||
}
|
||||
|
||||
.darkmode .status-warning .status-text {
|
||||
color: rgb(255, 241, 191);
|
||||
}
|
||||
|
||||
.darkmode .status-ok .status-text {
|
||||
color: rgb(191, 255, 191);
|
||||
}
|
||||
|
||||
.darkmode .status-na .status-text {
|
||||
color: rgb(155, 155, 155);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="/admin/assets/jquery.min.js"></script>
|
||||
<script src="/admin/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
var global_modal_state = null;
|
||||
var global_modal_funcs = null;
|
||||
|
||||
const toggle = document.getElementById("toggle-theme")
|
||||
function set_dark_mode(isdark) {
|
||||
if (isdark) {
|
||||
$("body").addClass("darkmode")
|
||||
} else {
|
||||
$("body").removeClass("darkmode")
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-theme-preference")) {
|
||||
let themepref = localStorage.getItem("miab-theme-preference")
|
||||
if (themepref === "dark") {
|
||||
toggle.checked = true
|
||||
set_dark_mode(true)
|
||||
} else if (themepref === "light") {
|
||||
set_dark_mode(false)
|
||||
}
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
// Toggle dark mode right now
|
||||
toggle.checked = true
|
||||
set_dark_mode(true)
|
||||
}
|
||||
|
||||
$("#toggle-theme").change(() => {
|
||||
if (typeof localStorage != 'undefined') {
|
||||
localStorage.setItem("miab-theme-preference", toggle.checked ? "dark" : "light")
|
||||
}
|
||||
set_dark_mode(toggle.checked)
|
||||
})
|
||||
|
||||
$(function () {
|
||||
$('#global_modal').on('shown.bs.modal', function (e) {
|
||||
// set focus to first input in the global modal's body
|
||||
var input = $('#global_modal .modal-body input');
|
||||
if (input.length > 0) $(input[0]).focus();
|
||||
})
|
||||
$('#global_modal .btn-danger').click(function () {
|
||||
// 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
|
||||
// is closing.
|
||||
global_modal_state = 0; // OK
|
||||
})
|
||||
$('#global_modal .btn-default').click(function () {
|
||||
global_modal_state = 1; // Cancel
|
||||
})
|
||||
$('#global_modal').on('hidden.bs.modal', function (e) {
|
||||
// do the cancel 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_funcs && global_modal_funcs[global_modal_state])
|
||||
global_modal_funcs[global_modal_state]();
|
||||
})
|
||||
})
|
||||
|
||||
const global_modal = new bootstrap.Modal($("#global_modal"))
|
||||
|
||||
function show_modal_error(title, message, callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
if (typeof question == 'string') {
|
||||
$('#global_modal p').text(message);
|
||||
} else {
|
||||
$('#global_modal p').html("").append(message);
|
||||
}
|
||||
$('#global_modal .btn-default').show().text("OK");
|
||||
$('#global_modal .btn-danger').hide();
|
||||
global_modal_funcs = [callback, callback];
|
||||
global_modal_state = null;
|
||||
global_modal.show();
|
||||
return false; // handy when called from onclick
|
||||
}
|
||||
|
||||
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
|
||||
$('#global_modal h4').text(title);
|
||||
if (typeof question == 'string') {
|
||||
$('#global_modal .modal-body').html("<p/>");
|
||||
$('#global_modal p').text(question);
|
||||
} else {
|
||||
$('#global_modal .modal-body').html("").append(question);
|
||||
}
|
||||
if (typeof verb == 'string') {
|
||||
$('#global_modal .btn-default').show().text("Cancel");
|
||||
$('#global_modal .btn-danger').show().text(verb);
|
||||
} else {
|
||||
$('#global_modal .btn-default').show().text(verb[1]);
|
||||
$('#global_modal .btn-danger').show().text(verb[0]);
|
||||
}
|
||||
global_modal_funcs = [yes_callback, cancel_callback];
|
||||
global_modal_state = null;
|
||||
global_modal.show();
|
||||
return false; // handy when called from onclick
|
||||
}
|
||||
|
||||
var ajax_num_executing_requests = 0;
|
||||
function ajax_with_indicator(options) {
|
||||
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
|
||||
function hide_loading_indicator() {
|
||||
ajax_num_executing_requests--;
|
||||
if (ajax_num_executing_requests == 0)
|
||||
$('#ajax_loading_indicator').stop(true).hide(); // stop() prevents an ongoing fade from causing the thing to be shown again after this call
|
||||
}
|
||||
var old_success = options.success;
|
||||
var old_error = options.error;
|
||||
options.success = function (data) {
|
||||
hide_loading_indicator();
|
||||
if (data.status == "error")
|
||||
show_modal_error("Error", data.message);
|
||||
else if (old_success)
|
||||
old_success(data);
|
||||
};
|
||||
options.error = function (jqxhr) {
|
||||
hide_loading_indicator();
|
||||
if (!old_error)
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
else
|
||||
old_error(jqxhr.responseText, jqxhr);
|
||||
};
|
||||
ajax_num_executing_requests++;
|
||||
$.ajax(options);
|
||||
return false; // handy when called from onclick
|
||||
}
|
||||
|
||||
var api_credentials = null;
|
||||
function api(url, method, data, callback, callback_error, headers) {
|
||||
// from http://www.webtoolkit.info/javascript-base64.html
|
||||
function base64encode(input) {
|
||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
var output = "";
|
||||
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
|
||||
var i = 0;
|
||||
while (i < input.length) {
|
||||
chr1 = input.charCodeAt(i++);
|
||||
chr2 = input.charCodeAt(i++);
|
||||
chr3 = input.charCodeAt(i++);
|
||||
enc1 = chr1 >> 2;
|
||||
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
|
||||
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
|
||||
enc4 = chr3 & 63;
|
||||
if (isNaN(chr2)) {
|
||||
enc3 = enc4 = 64;
|
||||
} else if (isNaN(chr3)) {
|
||||
enc4 = 64;
|
||||
}
|
||||
output = output +
|
||||
_keyStr.charAt(enc1) + _keyStr.charAt(enc2) +
|
||||
_keyStr.charAt(enc3) + _keyStr.charAt(enc4);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function default_error(text, xhr) {
|
||||
if (xhr.status != 403) // else handled below
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
}
|
||||
|
||||
ajax_with_indicator({
|
||||
url: "/admin" + url,
|
||||
method: method,
|
||||
cache: false,
|
||||
data: data,
|
||||
headers: headers,
|
||||
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
||||
processData: typeof data != "string",
|
||||
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
||||
|
||||
beforeSend: function (xhr) {
|
||||
// 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
|
||||
// by user actions.
|
||||
if (api_credentials)
|
||||
xhr.setRequestHeader(
|
||||
'Authorization',
|
||||
'Basic ' + base64encode(api_credentials.username + ':' + api_credentials.session_key));
|
||||
},
|
||||
success: callback,
|
||||
error: callback_error || default_error,
|
||||
statusCode: {
|
||||
403: function (xhr) {
|
||||
// Credentials are no longer valid. Try to login again.
|
||||
do_logout();
|
||||
switch_back_to_panel = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var current_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) {
|
||||
if (panelid.getAttribute)
|
||||
// we might be passed an HTMLElement <a>.
|
||||
panelid = panelid.getAttribute('href').substring(1);
|
||||
|
||||
$('.admin_panel').hide();
|
||||
$('#panel_' + panelid).show();
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.setItem("miab-cp-lastpanel", panelid);
|
||||
if (window["show_" + panelid])
|
||||
window["show_" + panelid]();
|
||||
|
||||
current_panel = panelid;
|
||||
switch_back_to_panel = null;
|
||||
|
||||
return false; // when called from onclick, cancel navigation
|
||||
}
|
||||
|
||||
$(function () {
|
||||
// Recall saved user credentials.
|
||||
try {
|
||||
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
|
||||
api_credentials = JSON.parse(sessionStorage.getItem("miab-cp-credentials"));
|
||||
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.
|
||||
if (api_credentials != null && typeof localStorage != 'undefined' && localStorage.getItem("miab-cp-lastpanel")) {
|
||||
show_panel(localStorage.getItem("miab-cp-lastpanel"));
|
||||
} else if (api_credentials != null) {
|
||||
show_panel('welcome');
|
||||
} else {
|
||||
show_panel('login');
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,116 +1,201 @@
|
|||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
||||
<style>
|
||||
.title {
|
||||
margin: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
{% if no_admins_exist %}
|
||||
<div class="container">
|
||||
<div class="col-md-offset-2 col-md-8">
|
||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||
<pre>cd mailinabox
|
||||
sudo tools/mail.py user make-admin your@emailaddress.com</pre>
|
||||
<hr>
|
||||
.subtitle {
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login {
|
||||
margin: 0 auto;
|
||||
max-width: 32em;
|
||||
}
|
||||
|
||||
.login #loginOtp {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loginForm.is-twofactor #loginOtp {
|
||||
display: block
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1 class="title">{{hostname}}</h1>
|
||||
|
||||
{% if no_users_exist or no_admins_exist %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
{% if no_users_exist %}
|
||||
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||
<pre>cd mailinabox
|
||||
sudo management/cli.py user add me@{{hostname}}
|
||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||
{% else %}
|
||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||
<pre>cd mailinabox
|
||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||
{% endif %}
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-4 col-lg-4">
|
||||
<center>
|
||||
<p style="margin: 2em">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
</center>
|
||||
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input name='remember' type="checkbox" id="loginRemember"> Remember me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-default">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="login">
|
||||
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||
<div class="form-floating mb-3">
|
||||
<input type="email" class="form-control col-sm-12" id="loginEmail" placeholder="user@example.com">
|
||||
<label for="loginEmail">Email</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input type="password" class="form-control col-sm-12" id="loginPassword" placeholder="password">
|
||||
<label for="loginPassword">Password</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3" id="loginOtp">
|
||||
<input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off">
|
||||
<label for="loginOtpInput">TOTP Code</label>
|
||||
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two
|
||||
factor authentication app.</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<div class="ms-auto">
|
||||
<input type="checkbox" class="form-check-input" id="loginRemember">
|
||||
<label class="form-check-label" for="loginRemember">Remember Me</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="container-fluid">
|
||||
<button type="submit" class="btn btn-primary col-12">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email address.")
|
||||
return false;
|
||||
}
|
||||
if ($('#loginPassword').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email password.")
|
||||
return false;
|
||||
}
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email address.", function () {
|
||||
$('#loginEmail').focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exchange the email address & password for an API key.
|
||||
api_credentials = [$('#loginEmail').val(), $('#loginPassword').val()]
|
||||
if ($('#loginPassword').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email password.", function () {
|
||||
$('#loginPassword').focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
api(
|
||||
"/me",
|
||||
"GET",
|
||||
{ },
|
||||
function(response){
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != "authorized") {
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
// Exchange the email address & password for an API key.
|
||||
api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
api(
|
||||
"/login",
|
||||
"POST",
|
||||
{},
|
||||
function (response) {
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != 'ok') {
|
||||
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
||||
$('#loginForm').addClass('is-twofactor');
|
||||
if (response.reason === "invalid-totp-token") {
|
||||
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
$('#loginOtpInput').focus();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
|
||||
} else {
|
||||
// Login succeeded.
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Save the new credentials.
|
||||
api_credentials = [response.api_key, ""];
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
}
|
||||
} else if (!("api_key" in response)) {
|
||||
// Login succeeded but user might not be authorized!
|
||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
$('#loginPassword').val('');
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
|
||||
// Remember the credentials.
|
||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||
if ($('#loginRemember').val()) {
|
||||
localStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
} else {
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
sessionStorage.setItem("miab-cp-credentials", api_credentials.join(":"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Login succeeded.
|
||||
|
||||
// Open the next panel the user wants to go to.
|
||||
show_panel(!switch_back_to_panel ? 'system_status' : switch_back_to_panel)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Save the new credentials.
|
||||
api_credentials = {
|
||||
username: response.email,
|
||||
session_key: response.api_key,
|
||||
privileges: response.privileges
|
||||
};
|
||||
|
||||
function do_logout() {
|
||||
api_credentials = ["", ""];
|
||||
if (typeof localStorage != 'undefined')
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
if (typeof sessionStorage != 'undefined')
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
show_panel('login');
|
||||
}
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
$('#loginPassword').val('');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
|
||||
// Remember the credentials.
|
||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||
if ($('#loginRemember').val()) {
|
||||
localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||
sessionStorage.removeItem("miab-cp-credentials");
|
||||
} else {
|
||||
localStorage.removeItem("miab-cp-credentials");
|
||||
sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle menus.
|
||||
show_hide_menus();
|
||||
|
||||
// Open the next panel the user wants to go to. Do this after the XHR response
|
||||
// is over so that we don't start a new XHR request while this one is finishing,
|
||||
// which confuses the loading indicator.
|
||||
setTimeout(function () { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
'x-auth-token': $('#loginOtpInput').val()
|
||||
});
|
||||
}
|
||||
|
||||
function show_login() {
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginEmail,#loginPassword').each(function () {
|
||||
var input = $(this);
|
||||
if (!$.trim(input.val())) {
|
||||
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>
|
||||
|
|
|
@ -1,62 +1,133 @@
|
|||
<style>#panel_mail-guide table.table { width: auto; margin-left: 1.5em; }</style>
|
||||
<style>
|
||||
#panel_mail-guide table.table {
|
||||
width: auto;
|
||||
margin-left: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<h2>Checking and Sending Mail</h2>
|
||||
|
||||
<p>You can use either webmail, IMAP/SMTP, or Exchange/ActiveSync to access your mail.</p>
|
||||
|
||||
<hr>
|
||||
<div>
|
||||
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4>How to log in</h4>
|
||||
<div class="col-sm-7">
|
||||
<h3>Webmail</h3>
|
||||
|
||||
<p>Your username and password are the same no matter how you check your mail:</p>
|
||||
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
|
||||
<p style="margin-left: 2em"><strong><a
|
||||
href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
|
||||
<p>Your username is your whole email address.</p>
|
||||
|
||||
<table class="table" style="max-width: 30em">
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h4>Webmail</h4>
|
||||
<h3>Mobile/desktop apps</h3>
|
||||
|
||||
<p>Your webmail site is <a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<h4>Automatic configuration</h4>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<h4>IMAP/SMTP settings</h4>
|
||||
<p>iOS and OS X only: Open <a style="font-weight: bold"
|
||||
href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device
|
||||
or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your
|
||||
whole email address.</p>
|
||||
|
||||
<p>This method is preferred on Android devices and is not available on iOS devices.</p>
|
||||
<h4>Manual configuration</h4>
|
||||
|
||||
<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th style="padding-bottom: 1em; border-bottom: none">Server:</th> <td colspan="2">{{hostname}}</td></tr>
|
||||
<tr><th>Protocol</th> <th>Port</th> <th>Options</th></tr>
|
||||
</thead>
|
||||
<tr><th>IMAP</th> <td>993</td> <td>SSL</td></tr>
|
||||
<tr><th>SMTP</th> <td>587</td> <td>STARTTLS</td></tr>
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<th>Protocol/Method</th>
|
||||
<td>IMAP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Mail server</th>
|
||||
<td>{{hostname}}</td>
|
||||
<tr>
|
||||
<th>IMAP Port</th>
|
||||
<td>993</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>IMAP Security</th>
|
||||
<td>SSL or TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SMTP Port</th>
|
||||
<td>465</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SMTP Security</td>
|
||||
<td>SSL or TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Username:</th>
|
||||
<td>Your whole email address.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Password:</th>
|
||||
<td>Your mail password.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>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>.</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<h4>Exchange/ActiveSync settings</h4>
|
||||
|
||||
<p>On iOS devices and devices on this <a href="http://z-push.org/compatibility/">compatibility list</a>, set up your mail as an Exchange or ActiveSync server:</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">
|
||||
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||
<tr><th>Options</th> <td>Secure Connection</td></tr>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<td>{{hostname}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Options</th>
|
||||
<td>Secure Connection</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this
|
||||
method.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-5">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3>Other information about mail on your box</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<h4>Greylisting</h4>
|
||||
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially
|
||||
rejecting mail from people you haven’t received mail from before. Legitimate mail servers
|
||||
will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this.
|
||||
If you are waiting for an email from someone new, such as if you are registering on a new
|
||||
website and are waiting for an email confirmation, please be aware there will be a minimum of 3
|
||||
minutes delay, depending how soon the remote server attempts redelivery.</p>
|
||||
|
||||
<h4>+tag addresses</h4>
|
||||
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email
|
||||
address is <code>you@yourdomain.com</code>, you’ll also automatically get mail sent to
|
||||
<code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail
|
||||
for your own filtering rules without having to create aliases in this control panel.</p>
|
||||
|
||||
<h4>Use only this box to send as you</h4>
|
||||
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and
|
||||
other fraudulent mail to claim to be you. Only this machine is authorized to send email on
|
||||
behalf of your domain names. If you use any other service to send email as you, it will likely
|
||||
get spam filtered by recipients.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
57
management/templates/manage-password.html
Normal file
57
management/templates/manage-password.html
Normal file
|
@ -0,0 +1,57 @@
|
|||
<div>
|
||||
<h2>Manage Password</h2>
|
||||
<p>Here you can change your account password. The new password is then valid for both this panel and your email.</p>
|
||||
<p>If you have client emails configured, you'll then need to update the configuration with the new password. See the <a href="#mail-guide" onclick="return show_panel(this);">Mail Guide</a> for more information about this.</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="set_password_self(); return false;">
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="manage-password-new" class="input-group-text col-3">New Password</label>
|
||||
<input type="password" placeholder="password" class="form-control" id="manage-password-new">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
<div class="input-group">
|
||||
<label for="manage-password-confirm" class="input-group-text col-3">Confirm Password</label>
|
||||
<input type="password" placeholder="password" class="form-control" id="manage-password-confirm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button id="manage-password-submit" type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<small>After changing your password, you'll be logged out from the account and will need to log in again.</small>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function set_password_self() {
|
||||
if ($('#manage-password-new').val() !== $('#manage-password-confirm').val()) {
|
||||
show_modal_error("Set Password", 'Passwords do not match!');
|
||||
return;
|
||||
}
|
||||
|
||||
let password = $('#manage-password-new').val()
|
||||
|
||||
api(
|
||||
"/mail/users/password",
|
||||
"POST",
|
||||
{
|
||||
email: api_credentials.username,
|
||||
password: password
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Set Password", $("<pre/>").text(r), () => {
|
||||
do_logout()
|
||||
$('#manage-password-new').val("")
|
||||
$('#manage-password-confirm').val("")
|
||||
});
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Set Password", r);
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
240
management/templates/mfa.html
Normal file
240
management/templates/mfa.html
Normal file
|
@ -0,0 +1,240 @@
|
|||
<style>
|
||||
.twofactor #totp-setup,
|
||||
.twofactor #disable-2fa,
|
||||
.twofactor #output-2fa {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.loaded .loading-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #disable-2fa,
|
||||
.twofactor.enabled #totp-setup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #totp-setup,
|
||||
.twofactor.enabled #disable-2fa {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.twofactor #totp-setup-qr img {
|
||||
display: block;
|
||||
width: 256px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.twofactor #output-2fa.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
|
||||
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
|
||||
authenticator app (usually on your phone) when you log into this control panel.</p>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header text-white bg-danger">
|
||||
Enabling two-factor authentication does not protect access to your email
|
||||
</div>
|
||||
<div class="card-body bg-light">
|
||||
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most
|
||||
websites allow you to
|
||||
reset your password by checking your email, so anyone with access to your email can typically take over
|
||||
your other accounts. Additionally, if your email address or any alias that forwards to your email
|
||||
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
|
||||
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
|
||||
and ensure every administrator account for this control panel does the same.</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="twofactor">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
|
||||
<form id="totp-setup">
|
||||
<h3>Setup Instructions</h3>
|
||||
|
||||
<div class="row gx-5">
|
||||
<div class="col-12 col-lg-6">
|
||||
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a
|
||||
href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||
other two-factor authentication app</a> that supports TOTP.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into
|
||||
the app:</p>
|
||||
<div id="totp-setup-qr">
|
||||
<img class="mt-3 mb-3 ms-auto me-auto" id="twofactor-qrimg">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="totp-setup-secret"><b>Secret</b></label>
|
||||
<input type="text" class="form-control font-monospace" id="totp-setup-secret" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="mb-3">
|
||||
<label for="otp-label" style="font-weight: normal"><b>3.</b> Optionally, give your device a label so
|
||||
that you can remember what device you set it up on:</label>
|
||||
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit
|
||||
code and enter it here:</label>
|
||||
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div>
|
||||
<button id="totp-setup-submit" disabled type="submit" class="btn btn-primary">Enable Two-Factor
|
||||
Authentication</button>
|
||||
</div>
|
||||
<small>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will
|
||||
have to log in
|
||||
again, now using your two-factor authentication app.</small>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="disable-2fa">
|
||||
<div>
|
||||
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span
|
||||
class="font-monospace"></span></span>.</p>
|
||||
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||
</div>
|
||||
<small>You will have to log into the admin panel again after disabling two-factor authentication.</small>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var el = {
|
||||
disableForm: document.getElementById('disable-2fa'),
|
||||
totpSetupForm: document.getElementById('totp-setup'),
|
||||
totpSetupToken: document.getElementById('totp-setup-token'),
|
||||
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
||||
totpSetupLabel: document.getElementById('totp-setup-label'),
|
||||
totpQr: document.getElementById('totp-setup-qr'),
|
||||
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
||||
wrapper: document.querySelector('.twofactor')
|
||||
}
|
||||
|
||||
function update_setup_disabled(evt) {
|
||||
var val = evt.target.value.trim();
|
||||
|
||||
if (
|
||||
typeof val !== 'string' ||
|
||||
typeof el.totpSetupSecret.value !== 'string' ||
|
||||
val.length !== 6 ||
|
||||
el.totpSetupSecret.value.length !== 32 ||
|
||||
!(/^\+?\d+$/.test(val))
|
||||
) {
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
} else {
|
||||
el.totpSetupSubmit.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function render_totp_setup(provisioned_totp) {
|
||||
$('#twofactor-qrimg').attr("src", "data:image/png;base64," + provisioned_totp.qr_code_base64);
|
||||
|
||||
el.totpSetupSecret.val = provisioned_totp.secret;
|
||||
|
||||
el.totpSetupToken.addEventListener('input', update_setup_disabled);
|
||||
el.totpSetupForm.addEventListener('submit', do_enable_totp);
|
||||
|
||||
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
|
||||
|
||||
el.wrapper.classList.add('disabled');
|
||||
}
|
||||
|
||||
function render_disable(mfa) {
|
||||
el.disableForm.addEventListener('submit', do_disable);
|
||||
el.wrapper.classList.add('enabled');
|
||||
if (mfa.label) {
|
||||
$("#mfa-device-label").show()
|
||||
$("#mfa-device-label .font-monospace").text(mfa.label);
|
||||
} else {
|
||||
$("#mfa-device-label").hide()
|
||||
}
|
||||
}
|
||||
|
||||
function reset_view() {
|
||||
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
||||
|
||||
el.disableForm.removeEventListener('submit', do_disable);
|
||||
|
||||
el.totpSetupForm.reset();
|
||||
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||
|
||||
el.totpSetupSecret.setAttribute('value', '');
|
||||
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
|
||||
$('#twofactor-qrimg').attr("src", "");
|
||||
$('#totp-setup-secret').attr("value", "");
|
||||
}
|
||||
|
||||
function show_mfa() {
|
||||
reset_view();
|
||||
|
||||
api(
|
||||
'/mfa/status',
|
||||
'POST',
|
||||
{},
|
||||
function (res) {
|
||||
el.wrapper.classList.add('loaded');
|
||||
|
||||
var has_mfa = false;
|
||||
res.enabled_mfa.forEach(function (mfa) {
|
||||
if (mfa.type == "totp") {
|
||||
render_disable(mfa);
|
||||
has_mfa = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!has_mfa)
|
||||
render_totp_setup(res.new_mfa.totp);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function do_disable(evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
api(
|
||||
'/mfa/disable',
|
||||
'POST',
|
||||
{ type: 'totp' },
|
||||
function () {
|
||||
do_logout();
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function do_enable_totp(evt) {
|
||||
evt.preventDefault();
|
||||
|
||||
api(
|
||||
'/mfa/totp/enable',
|
||||
'POST',
|
||||
{
|
||||
token: $(el.totpSetupToken).val(),
|
||||
secret: $(el.totpSetupSecret).val(),
|
||||
label: $(el.totpSetupLabel).val()
|
||||
},
|
||||
function (res) { do_logout(); },
|
||||
function (res) { show_modal_error("Two-Factor Authentication Setup", res); }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
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>
|
298
management/templates/pgp-keyring.html
Normal file
298
management/templates/pgp-keyring.html
Normal file
|
@ -0,0 +1,298 @@
|
|||
<style>
|
||||
#pgp_keyring_config .icon {
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>PGP Keyring Management</h2>
|
||||
|
||||
<template id="pgpkey-template">
|
||||
<tr>
|
||||
<td class="row p-3">
|
||||
<div class="col-lg-10 col-12">
|
||||
<b id="isrevoked" class="fs-4"></b>
|
||||
<div id="uids" class="mb-3 font-monospace">
|
||||
<div id="uid-template">
|
||||
<span class="icon fas"></span><span id="id" class="status-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
<b class="fs-5">Subkeys</b>
|
||||
<div id="subkeys" class="align-middle col-12">
|
||||
<div class="row" id="subkey-template">
|
||||
<div style="width: 2em;" id="ismaster" data-bs-toggle="tooltip" title="Master Key"></div>
|
||||
<div style="width: 5em;">
|
||||
<b>
|
||||
<span class="sym" id="sign">S</span>
|
||||
<span class="sym" id="cert">C</span>
|
||||
<span class="sym" id="encr">E</span>
|
||||
<span class="sym" id="auth">A</span>
|
||||
</b>
|
||||
</div>
|
||||
<div class="col-md-2 col-4" id="algo">
|
||||
<b class="status-text"></b>
|
||||
</div>
|
||||
<div class="col" id="expiration">
|
||||
<span class="status-text"></span>
|
||||
</div>
|
||||
<div class="col-md-6 col-12" id="fpr">
|
||||
<span class="status-text font-monospace"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="options" class="col-lg-2 col-12">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<div id="pgp_keyring_config">
|
||||
<h3>Daemon's Private Key</h3>
|
||||
<table id="privatekey" class="container">
|
||||
</table>
|
||||
|
||||
<h3>Imported Public Keys</h3>
|
||||
<table id="pubkeys" class="container">
|
||||
</table>
|
||||
|
||||
<h3>Import Key</h3>
|
||||
<p>
|
||||
You can upload your <b>public</b> key/keychain here. Keys <b>must</b> be submitted in ASCII-armored format.
|
||||
<br>
|
||||
If you're using <code>gpg</code>, you can export your public key by following this example:
|
||||
<pre>
|
||||
# Get all the keys in the ring
|
||||
$ <b>gpg --list-keys</b>
|
||||
/home/you/.gnupg/pubring.kbx
|
||||
----------------------------
|
||||
pub rsa4096 1970-01-01 [SC]
|
||||
247C3553B4B36107BA0490C3CAFCCF3B4965761A
|
||||
uid [ full ] Someone That I Used to Know <someone@example.com>
|
||||
sub rsa2048 2020-01-01 [E] [expires: 2069-12-31]
|
||||
|
||||
pub rsa4096 1970-01-01 [SC] [expires: 2069-12-31]
|
||||
52661092E5CD9EEFD7796B19E85F540C9318B69F
|
||||
uid [ultimate] Me, Myself and I <me@example.net>
|
||||
sub rsa2048 2020-05-24 [E] [expires: 2069-12-31]
|
||||
|
||||
# Let's export the key "Me, Myself and I"
|
||||
$ <b>gpg --export --armor 52661092E5CD9EEFD7796B19E85F540C9318B69F</b>
|
||||
<b>-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
copy and paste this block in the area below
|
||||
-----END PGP PUBLIC KEY BLOCK-----</b>
|
||||
</pre>
|
||||
</p>
|
||||
<div class="form-floating col-12 col-xl-6 mb-3">
|
||||
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----
stuff here
-----END PGP PUBLIC KEY BLOCK-----"></textarea>
|
||||
<label for="pgp_paste_key">Paste your PGP public key here</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="importkey()">Import Key</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function pretty_fpr(fpr) {
|
||||
let pfpr = ""
|
||||
for (let n = 0; n < 2; ++n) {
|
||||
for (let i = 0; i < 5; ++i) {
|
||||
pfpr += `${fpr.substring(n * 20 + i * 4, n * 20 + (i + 1) * 4)} `
|
||||
}
|
||||
pfpr += " "
|
||||
}
|
||||
|
||||
return pfpr.substring(0, pfpr.length - 2)
|
||||
}
|
||||
|
||||
function key_html(key, darken_bg, daemon) {
|
||||
let keyrep = $("#pgpkey-template").html()
|
||||
console.log(keyrep)
|
||||
keyrep = $(keyrep)
|
||||
keyrep.attr("id", key.master_fpr)
|
||||
|
||||
// Main key config
|
||||
if (darken_bg) {
|
||||
keyrep.addClass("bg-light")
|
||||
}
|
||||
|
||||
let rev = keyrep.find("#isrevoked")
|
||||
|
||||
if (key.revoked) {
|
||||
rev.text("This key was revoked by it's owner.")
|
||||
rev.addClass(".status-error")
|
||||
} else {
|
||||
rev.text("Key is not revoked.")
|
||||
}
|
||||
|
||||
let uids = keyrep.find("#uids")
|
||||
let uidtemplate = $(uids.html())
|
||||
uids.find("#uid-template").remove()
|
||||
if (daemon) {
|
||||
uidtemplate.find(".icon").addClass("fa-robot")
|
||||
} else {
|
||||
uidtemplate.find(".icon").addClass("fa-user")
|
||||
}
|
||||
|
||||
key.ids.forEach(id => {
|
||||
let thisuid = uidtemplate.clone()
|
||||
thisuid.attr("id", "")
|
||||
thisuid.find("#id").text(id)
|
||||
thisuid.appendTo(uids)
|
||||
});
|
||||
|
||||
// Subkeys
|
||||
const keyflags = ["sign", "cert", "encr", "auth"]
|
||||
let subkeys = keyrep.find("#subkeys")
|
||||
let subkeytemplate = subkeys.html()
|
||||
keyrep.find("#subkey-template").remove()
|
||||
key.subkeys.forEach(skey => {
|
||||
let skeyrep = $(subkeytemplate)
|
||||
skeyrep.attr("id", `sub${skey.fpr}`)
|
||||
|
||||
let statusclass
|
||||
let expiration = skeyrep.find("#expiration")
|
||||
let exptxt = expiration.find(".status-text")
|
||||
|
||||
if (key.revoked) {
|
||||
statusclass = "status-error"
|
||||
exptxt.text("Revoked")
|
||||
} else if (skey.expired) {
|
||||
statusclass = "status-error"
|
||||
exptxt.text(`${skey.expires_date} (expired)`)
|
||||
} else if (skey.expires && skey.expires_days <= 14) {
|
||||
statusclass = "status-warning"
|
||||
exptxt.text(`${skey.expires_date} (${skey.expires_days} days)`)
|
||||
} else if (skey.expires) {
|
||||
statusclass = "status-ok"
|
||||
exptxt.text(`${skey.expires_date} (${skey.expires_days} days)`)
|
||||
} else {
|
||||
statusclass = "status-ok"
|
||||
exptxt.text("Does not expire")
|
||||
}
|
||||
|
||||
expiration.addClass(statusclass)
|
||||
|
||||
// Master key?
|
||||
if (skey.master) {
|
||||
skeyrep.find("#ismaster").addClass("fas fa-key")
|
||||
}
|
||||
|
||||
// Usage flags
|
||||
keyflags.forEach(flag => {
|
||||
skeyrep.find(`#${flag}`).addClass(skey[flag] ? statusclass : "status-na")
|
||||
});
|
||||
|
||||
// Algorithm and fingerprint
|
||||
let algo = skeyrep.find("#algo")
|
||||
algo.addClass(statusclass)
|
||||
algo.find(".status-text").html(`${skey.algorithm}, ${skey.bits} bits`)
|
||||
|
||||
let fpr = skeyrep.find("#fpr")
|
||||
fpr.addClass(statusclass)
|
||||
fpr.find(".status-text").html(pretty_fpr(skey.fpr))
|
||||
|
||||
skeyrep.appendTo(subkeys)
|
||||
});
|
||||
|
||||
// Options
|
||||
if (daemon) {
|
||||
keyrep.find("#options").html(`<button class="btn btn-primary col-12" onclick="exportkey('${key.master_fpr}')">Export Public Key</button>`)
|
||||
} else {
|
||||
keyrep.find("#options").html(`<button class="btn btn-secondary col-12 mb-3" onclick="exportkey('${key.master_fpr}')">Export Public Key</button><button class="btn btn-danger col-12" onclick="rmkey('${key.master_fpr}')">Remove Key</button>`)
|
||||
}
|
||||
|
||||
return keyrep
|
||||
}
|
||||
|
||||
function show_pgp_keyring() {
|
||||
$('#privatekey').html("<tr><td class='text-muted'>Loading...</td></tr>")
|
||||
$('#pubkeys').html("<tr><td class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/pgp/",
|
||||
"GET",
|
||||
{},
|
||||
function (r) {
|
||||
$('#privatekey').html("")
|
||||
$('#pubkeys').html("")
|
||||
key_html(r.daemon, true, true).appendTo("#privatekey")
|
||||
let pendulum = 1
|
||||
r.imported.forEach(k => {
|
||||
key_html(k, pendulum > 0, false).appendTo("#pubkeys")
|
||||
pendulum *= -1
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function exportkey(fpr) {
|
||||
api(
|
||||
`/system/pgp/${fpr}/export`,
|
||||
"GET",
|
||||
{},
|
||||
function (r) {
|
||||
show_modal_error("PGP Key", `Key export for <b>${fpr}</b>:<br><br><pre>${r}</pre>`)
|
||||
},
|
||||
function (_, xhr) {
|
||||
if (xhr.status == 404) {
|
||||
show_modal_error("Error", `The key you asked for (<b>${fpr}</b>) does not exist!`)
|
||||
} else {
|
||||
// Fallback to the default error modal
|
||||
show_modal_error("Error", "Something went wrong, sorry.")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function rmkey(fpr) {
|
||||
show_modal_confirm("Delete key", `Are you sure you wish to remove the key with the fingerprint ${pretty_fpr(fpr)}?`, "Yes, remove it", () => {
|
||||
api(
|
||||
`/system/pgp/${fpr}`,
|
||||
"DELETE",
|
||||
{},
|
||||
function (r) {
|
||||
if (r.length == 0) {
|
||||
show_modal_error("Delete key", "OK", show_pgp_keyring)
|
||||
} else {
|
||||
wkd_info = "OK. <b>The following entries were removed from WKD:</b><ul>"
|
||||
r.forEach(email => {
|
||||
wkd_info += `<li>${email}</li>`
|
||||
});
|
||||
wkd_info += "</ul>"
|
||||
|
||||
show_modal_error("Delete key", wkd_info, show_pgp_keyring)
|
||||
}
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Key deletion error", r)
|
||||
}
|
||||
)
|
||||
}, () => { })
|
||||
}
|
||||
|
||||
function importkey() {
|
||||
api(
|
||||
"/system/pgp/import",
|
||||
"POST",
|
||||
{
|
||||
key: $("#pgp_paste_key").val()
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Import Results", `<ul>
|
||||
<li><b>Keys read:</b> ${r.keys_read}</li>
|
||||
<li><b>Keys added:</b> ${r.keys_added}</li>
|
||||
<li><b>Keys not changed:</b> ${r.keys_unchanged}</li>
|
||||
<li><b>User id's added:</b> ${r.uids_added}</li>
|
||||
<li><b>Signatures added:</b> ${r.sigs_added}</li>
|
||||
<li><b>Revocations added:</b> ${r.revs_added}</li>
|
||||
</ul>`, show_pgp_keyring)
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Import Error", r)
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
322
management/templates/smtp-relays.html
Normal file
322
management/templates/smtp-relays.html
Normal file
|
@ -0,0 +1,322 @@
|
|||
<style>
|
||||
</style>
|
||||
|
||||
<h2>SMTP Relays</h2>
|
||||
|
||||
<p>SMTP Relays are third-party services that can deliver email on your behalf. They
|
||||
can be useful when, for example, port 25 is blocked, the cloud provider/ISP doesn't provide Reverse DNS, or the IP
|
||||
address
|
||||
has a low reputation, among other situations where deliverablity isn't great.</p>
|
||||
|
||||
<p>These services are governed by their own terms and as such limits can be imposed in the usage of those services.</p>
|
||||
|
||||
<p>Here, you can configure an authenticated SMTP relay and authorize it's associated servers to send mail for you.</p>
|
||||
|
||||
<div id="smtp_relay_config">
|
||||
<form class="form-horizontal" role="form" onsubmit="set_smtp_relay_config(); return false;">
|
||||
<h3>SMTP Relay Configuration</h3>
|
||||
<div id="smtp-relays" class="col-12">
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<label for="use_relay" class="input-group-text"><b>Use Relay?</b></label>
|
||||
<div class="input-group-text">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false
|
||||
onclick="checkfields();">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<label class="input-group-text">Where to?</label>
|
||||
<input type="text" class="form-control" id="relay_host" placeholder="relay.example.com">
|
||||
<label class="input-group-text">:</label>
|
||||
<div style="width: 20%">
|
||||
<input type="number" class="form-control" id="relay_port" placeholder="Port (465)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<label class="input-group-text">Username</label>
|
||||
<input type="text" class="form-control" id="relay_auth_user" placeholder="">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="input-group">
|
||||
<label class="input-group-text">Password/Key</label>
|
||||
<input type="password" class="form-control" id="relay_auth_pass" placeholder="">
|
||||
</div>
|
||||
<p class="small">If you've already set up a relay before on this box, you can leave this field blank if
|
||||
you don't want to change it's password.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Authorized Servers</h3>
|
||||
<p>The relay service should specify the servers where the email will be sent from, please add them below. These
|
||||
will probably be published in the form of a SPF record. <b>Failure to do so will potentially have your email
|
||||
sent to spam or even rejected altogether by recipients.</b>
|
||||
</p>
|
||||
|
||||
<p>You can use the button below to attempt to localize the SPF record associated with the service you're using.
|
||||
</p>
|
||||
|
||||
<button id="smtp_relay_autospf_btn" type="button" class="btn btn-secondary" onclick="autodetect_spf()">Detect
|
||||
SPF
|
||||
Records</button>
|
||||
|
||||
<div id="smtp_relay_autospf"></div>
|
||||
<br>
|
||||
<div class="form-group">
|
||||
<h4>Add your SPF configuration/authorized servers here</h4>
|
||||
<input type="text" class="form-control" id="relay_authorized_servers"
|
||||
placeholder="mail1.example.net mail2.example.net">
|
||||
<p class="small">You can separate multiple servers with commas or spaces. You can also add IP addresses or
|
||||
subnets using <code>10.20.30.40</code> or <code>10.0.0.0/8</code>. You can "import" SPF records using
|
||||
<code>spf:example.com</code>. If your provider gave you an SPF record to add to your DNS, you can also paste it here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>DKIM Configuration</h3>
|
||||
<p>DKIM allows receivers to verify that the email was sent by the relay you configured (this is, somebody you
|
||||
trust). <b>If your relay provider does not provide you with this information, it's probably safe to skip this step.</b></p>
|
||||
|
||||
<div class="col-lg-6 col-md-8 col-12">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="relay_dkim_selector" placeholder="selector"></td>
|
||||
<label class="input-group-text" for="relay_dkim_selector">._domainkey.{{hostname}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Paste the DKIM key here:</h4>
|
||||
<p><textarea id="relay_dkim_key" class="form-control" style="width: 100%; height: 8em"
|
||||
placeholder="k=algo;p=K3y/C0N7ent5/dGhpcyBpcyBub3QgYSByZWFsIGtleSwgc28gYmV3YXJlIDrigb4"></textarea>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
|
||||
<h3>After configuration</h3>
|
||||
<p>By that time you should be good to go. If your relay provider provides their own custom DNS verification
|
||||
methods (<b>including custom DMARC configurations</b>), feel free to publish them on DNS.</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const use_relay = document.getElementById("use_relay")
|
||||
const relay_host = document.getElementById("relay_host")
|
||||
const relay_port = document.getElementById("relay_port")
|
||||
const relay_auth_user = document.getElementById("relay_auth_user")
|
||||
const relay_auth_pass = document.getElementById("relay_auth_pass")
|
||||
|
||||
const relay_authorized_servers = document.getElementById("relay_authorized_servers")
|
||||
const relay_spf_discover = document.getElementById("smtp_relay_autospf_btn")
|
||||
const relay_spf_discover_results = document.getElementById("smtp_relay_autospf")
|
||||
|
||||
const relay_dkim_sel = document.getElementById("relay_dkim_selector")
|
||||
const relay_dkim_key = document.getElementById("relay_dkim_key")
|
||||
|
||||
function checkfields() {
|
||||
let relay_enabled = use_relay.checked
|
||||
|
||||
relay_host.disabled = !relay_enabled
|
||||
relay_port.disabled = !relay_enabled
|
||||
relay_auth_user.disabled = !relay_enabled
|
||||
relay_auth_pass.disabled = !relay_enabled
|
||||
relay_authorized_servers.disabled = !relay_enabled
|
||||
|
||||
relay_spf_discover.disabled = !relay_enabled
|
||||
relay_spf_discover_results.innerHTML = ""
|
||||
|
||||
relay_dkim_sel.disabled = !relay_enabled
|
||||
relay_dkim_key.disabled = !relay_enabled
|
||||
}
|
||||
|
||||
function show_smtp_relays() {
|
||||
api(
|
||||
"/system/smtp/relay",
|
||||
"GET",
|
||||
{},
|
||||
data => {
|
||||
use_relay.checked = data.enabled
|
||||
relay_host.value = data.host
|
||||
relay_port.value = data.port
|
||||
relay_auth_user.value = data.user
|
||||
relay_auth_pass.value = ""
|
||||
relay_authorized_servers.value = ""
|
||||
|
||||
if (data.spf_record) {
|
||||
relay_authorized_servers.value = data.spf_record
|
||||
}else if (data.authorized_servers) {
|
||||
data.authorized_servers.forEach(element => {
|
||||
relay_authorized_servers.value += `${element} `
|
||||
});
|
||||
}
|
||||
|
||||
if (data.dkim_selector) {
|
||||
relay_dkim_sel.value = data.dkim_selector
|
||||
relay_dkim_key.value = data.dkim_rr
|
||||
}
|
||||
|
||||
checkfields()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function set_smtp_relay_config() {
|
||||
let relay_configuration = {
|
||||
enabled: use_relay.checked,
|
||||
host: relay_host.value,
|
||||
port: relay_port.value,
|
||||
user: relay_auth_user.value,
|
||||
key: relay_auth_pass.value,
|
||||
dkim_selector: relay_dkim_sel.value,
|
||||
dkim_rr: relay_dkim_key.value
|
||||
}
|
||||
if (relay_authorized_servers.value.substr(0, 7) === "v=spf1 ") {
|
||||
relay_configuration.spf_record = relay_authorized_servers.value
|
||||
} else {
|
||||
relay_configuration.authorized_servers = relay_authorized_servers.value
|
||||
}
|
||||
|
||||
api(
|
||||
"/system/smtp/relay",
|
||||
"POST",
|
||||
relay_configuration,
|
||||
() => {
|
||||
show_modal_error("Done!", "The configuration has been updated and Postfix was restarted successfully. Please make sure everything is functioning as intended.", () => {
|
||||
return false
|
||||
})
|
||||
},
|
||||
(e) => {
|
||||
show_modal_error("Error!", e, () => { return false })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function add_spf(domain) {
|
||||
document.getElementById(`smtp_relay_spfinclude_${domain}`).disabled = true
|
||||
|
||||
let impl = false
|
||||
relay_authorized_servers.value.split(/[\s,]+/).forEach(s => {
|
||||
if (s === `spf:${domain}`) {
|
||||
impl = true
|
||||
}
|
||||
});
|
||||
|
||||
if (!impl) {
|
||||
relay_authorized_servers.value += ` spf:${domain}`
|
||||
}
|
||||
}
|
||||
|
||||
async function autodetect_spf() {
|
||||
let btn = $("#smtp_relay_autospf_btn")
|
||||
let results = $("#smtp_relay_autospf")
|
||||
|
||||
let host = relay_host.value
|
||||
if (host.trim() == "") {
|
||||
results.html("<b>Error:</b> No hostname specified.")
|
||||
return
|
||||
}
|
||||
|
||||
let hmatches = host.match(/([^\s.\\\/]+\.)+([^\s.\\\/]+)/)
|
||||
if (!hmatches || hmatches[0] != host) {
|
||||
results.html(`<b>Error: <code>${host}</code></b> is not a valid hostname.`)
|
||||
return
|
||||
}
|
||||
|
||||
btn.html("Working...")
|
||||
btn.prop("disabled", true)
|
||||
|
||||
results.html("")
|
||||
|
||||
let base_host = hmatches[hmatches.length - 2] + hmatches[hmatches.length - 1]
|
||||
|
||||
function record_html(name, rec) {
|
||||
if (rec.error) {
|
||||
return `<b>${name}</b> - ${rec.error} Error (${rec.msg})`
|
||||
} else {
|
||||
return `<button id="smtp_relay_spfinclude_${name}" type="button" class="btn btn-secondary" onclick="add_spf('${name}')">Include</button> <b>${name}</b> <code>${rec.msg}</code>`
|
||||
}
|
||||
}
|
||||
|
||||
let records = []
|
||||
|
||||
await Promise.all([
|
||||
query_spf(base_host).then(rr => { records[0] = record_html(base_host, rr) }),
|
||||
query_spf(`spf.${base_host}`).then(rr => { records[1] = record_html(`spf.${base_host}`, rr) }),
|
||||
query_spf(`_spf.${base_host}`).then(rr => { records[2] = record_html(`_spf.${base_host}`, rr) })
|
||||
])
|
||||
|
||||
let txt = "<h4>Here's what I've found:</h4><ul>"
|
||||
records.forEach((r) => {
|
||||
txt += `<li>${r}</li>`
|
||||
})
|
||||
|
||||
txt += "</ul><small>Only some common subdomains are tested here, so it's possible I've missed some. You <b>SHOULD NOT</b> include records you do not recognize, else there will be servers that can send mail as you, but that you will not use.</small>"
|
||||
|
||||
results.html(txt)
|
||||
|
||||
btn.html("Detect SPF Records")
|
||||
btn.prop("disabled", false)
|
||||
}
|
||||
|
||||
function query_spf(hostname) {
|
||||
// We use Cloudflare's DNS servers for this (DNS over HTTPS)
|
||||
return new Promise((resolve, _) => {
|
||||
$.ajax({
|
||||
url: "https://cloudflare-dns.com/dns-query",
|
||||
headers: {
|
||||
accept: "application/dns-json"
|
||||
},
|
||||
method: "GET",
|
||||
data: {
|
||||
name: hostname,
|
||||
type: "TXT",
|
||||
do: false,
|
||||
cd: false
|
||||
},
|
||||
success: (data) => {
|
||||
const RRTXT = 16
|
||||
const status_description = [
|
||||
// From https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6
|
||||
// (last checked: 3rd June 2021)
|
||||
"Success",
|
||||
"Format Error",
|
||||
"Server Failure",
|
||||
"Non-Existent Domain",
|
||||
"Not Implemented",
|
||||
"Query Refused",
|
||||
"Name exists when it should not",
|
||||
"RR set exists when it should not",
|
||||
"RR set that should exist does not",
|
||||
"Server is not authoritative for zone",
|
||||
"Not Authorized",
|
||||
"Name not contained in zone",
|
||||
"DSO-TYPE Not Implemented"
|
||||
]
|
||||
if (data.Status != 0) {
|
||||
if (data.Status > 11) {
|
||||
return resolve({ error: "DNS", msg: "Unknown Error" })
|
||||
} else {
|
||||
return resolve({ error: "DNS", msg: status_description[data.Status] })
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Answer) {
|
||||
data.Answer.forEach(ans => {
|
||||
if (ans.type == 16 && ans.name == hostname && ans.data.substring(1, 7) == "v=spf1") {
|
||||
return resolve({ msg: ans.data.substring(1, ans.data.length - 1) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return resolve({ error: "DNS", msg: "No SPF record found" })
|
||||
},
|
||||
error: (_, __, err) => {
|
||||
resolve({ error: "HTTP", msg: err })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
307
management/templates/ssl.html
Normal file
307
management/templates/ssl.html
Normal file
|
@ -0,0 +1,307 @@
|
|||
<style>
|
||||
.code-white>code {
|
||||
color: #ffffff
|
||||
}
|
||||
|
||||
#ssl_provision_result>div>div {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#ssl_domains .icon {
|
||||
min-width: 1.6em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>TLS (SSL) Certificates</h2>
|
||||
|
||||
<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address
|
||||
that the connection is secure between you and the owner of that address.</p>
|
||||
|
||||
<p>You need a TLS certificate for this box’s hostname ({{hostname}}) and every other domain name and subdomain
|
||||
that this box is hosting a website for (see the list below).</p>
|
||||
|
||||
<div id="ssl_provision">
|
||||
<h3>Provision certificates</h3>
|
||||
|
||||
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
|
||||
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/"
|
||||
target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:</p>
|
||||
<ul class="text-primary"></ul>
|
||||
|
||||
<div class="container input-group mt-3" style="overflow-x: auto;">
|
||||
<button id="ssl_provision_button" class="btn btn-primary"
|
||||
onclick="return provision_tls_cert();">Provision</button>
|
||||
<label class="input-group-text" for=""><b>By provisioning the certificates, you’re agreeing to the <a
|
||||
href="https://letsencrypt.org/repository">Let’s Encrypt Subscriber Agreement</a>.</b></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ssl_provision_result" class="mt-4"></div>
|
||||
|
||||
<h3>Certificate status</h3>
|
||||
|
||||
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed
|
||||
through <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a> 14 days prior to expiration.</p>
|
||||
|
||||
<table id="ssl_domains" class="table align-middle col-12" style="margin-bottom: 2em; display: none">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Certificate Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h3 id="ssl_install_header">Install certificate</h3>
|
||||
|
||||
<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try.
|
||||
You can generate the needed CSR below.</p>
|
||||
|
||||
<div class="col-lg-10 col-xl-8">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="ssldomain">Which domain are you getting a certificate for?</label>
|
||||
<select id="ssldomain" onchange="show_csr()" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<p><small>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides
|
||||
the one you choose above.</small></p>
|
||||
|
||||
<div class="col-lg-10 col-xl-8">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="sslcc">What country are you in?</label>
|
||||
<select id="sslcc" onchange="show_csr()" class="form-select">
|
||||
<option value="">(Select)</option>
|
||||
{% for code, name in csr_country_codes %}
|
||||
<option value="{{code}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p><small>This is required by some TLS certificate providers. You may just pick any if you know your TLS certificate
|
||||
provider doesn't require it.</small></p>
|
||||
|
||||
<div id="csr_info" style="display: none;">
|
||||
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
|
||||
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
<textarea class="form-control font-monospace" id="ssl_csr" style="min-height: 26.5em;" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="form-floating col-12 col-xl-6">
|
||||
<textarea id="ssl_paste_cert" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<label for="ssl_paste_cert" class="ms-3">TLS/SSL Certificate</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating col-12 col-xl-6">
|
||||
<textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<label for="ssl_paste_chain" class="ms-3">TLS/SSL intermediate Chain (if provided)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<button class="btn btn-primary" onclick="install_cert()">Install</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function show_tls(keep_provisioning_shown) {
|
||||
api(
|
||||
"/ssl/status",
|
||||
"GET",
|
||||
{
|
||||
},
|
||||
function (res) {
|
||||
// provisioning status
|
||||
|
||||
$("#ssl_provision_p ul").html("")
|
||||
if (!keep_provisioning_shown)
|
||||
$('#ssl_provision').toggle(res.can_provision.length > 0)
|
||||
|
||||
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
|
||||
if (res.can_provision.length > 0) {
|
||||
let hostlist = $("#ssl_provision_p ul")
|
||||
res.can_provision.forEach(domain => {
|
||||
$("#ssl_provision_p ul").append($("<li></li>").text(domain))
|
||||
});
|
||||
}
|
||||
|
||||
// certificate status
|
||||
var domains = res.status;
|
||||
var tb = $('#ssl_domains tbody');
|
||||
tb.text('');
|
||||
$('#ssldomain').html('<option value="">(select)</option>');
|
||||
$('#ssl_domains').show();
|
||||
for (var i = 0; i < domains.length; i++) {
|
||||
var row = $("<tr><th class='text-wrap'></th><td class='status'><div class='icon fas'></div><span class='status-text'></span></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
||||
tb.append(row);
|
||||
row.attr('data-domain', domains[i].domain);
|
||||
row.find('th').text(domains[i].domain);
|
||||
if (domains[i].status == "not-applicable") {
|
||||
row.find('.actions a').remove(); // no actions applicable
|
||||
}
|
||||
let statusclass
|
||||
let faclass
|
||||
switch (domains[i].status) {
|
||||
case "success":
|
||||
statusclass = "status-ok"
|
||||
faclass = "fa-check"
|
||||
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
||||
break;
|
||||
case "warning":
|
||||
statusclass = "status-warning"
|
||||
faclass = "fa-exclamation-triangle"
|
||||
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
|
||||
break;
|
||||
case "danger":
|
||||
statusclass = "status-error"
|
||||
faclass = "fa-times"
|
||||
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
|
||||
break;
|
||||
default:
|
||||
statusclass = "status-na"
|
||||
row.find('.actions a').remove()
|
||||
break;
|
||||
}
|
||||
let txt = row.find(".status")
|
||||
txt.addClass(statusclass);
|
||||
txt.find(".fas").addClass(faclass)
|
||||
txt.find("span").text(domains[i].text);
|
||||
|
||||
$('#ssldomain').append($('<option>').text(domains[i].domain));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ssl_install(elem) {
|
||||
var domain = $(elem).parents('tr').attr('data-domain');
|
||||
$('#ssldomain').val(domain);
|
||||
show_csr();
|
||||
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
||||
return false;
|
||||
}
|
||||
|
||||
function show_csr() {
|
||||
// Can't show a CSR until both inputs are entered.
|
||||
if ($('#ssldomain').val() == "") return;
|
||||
if ($('#sslcc').val() == "") return;
|
||||
|
||||
// Scroll to it and fetch.
|
||||
$('#csr_info').slideDown();
|
||||
$('#ssl_csr').text('Loading...');
|
||||
api(
|
||||
"/ssl/csr/" + $('#ssldomain').val(),
|
||||
"POST",
|
||||
{
|
||||
countrycode: $('#sslcc').val()
|
||||
},
|
||||
function (data) {
|
||||
$('#ssl_csr').text(data);
|
||||
});
|
||||
}
|
||||
|
||||
function install_cert() {
|
||||
api(
|
||||
"/ssl/install",
|
||||
"POST",
|
||||
{
|
||||
domain: $('#ssldomain').val(),
|
||||
cert: $('#ssl_paste_cert').val(),
|
||||
chain: $('#ssl_paste_chain').val()
|
||||
},
|
||||
function (status) {
|
||||
if (/^OK($|\n)/.test(status)) {
|
||||
console.log(status)
|
||||
show_modal_error("TLS Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function () { show_ssl(); $('#csr_info').slideUp(); });
|
||||
} else {
|
||||
show_modal_error("TLS Certificate Installation", status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function provision_tls_cert() {
|
||||
// Automatically provision any certs.
|
||||
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
|
||||
api(
|
||||
"/ssl/provision",
|
||||
"POST",
|
||||
{},
|
||||
function (status) {
|
||||
// Clear last attempt.
|
||||
$('#ssl_provision_result').html("");
|
||||
may_reenable_provision_button = true;
|
||||
|
||||
// Nothing was done. There might also be problem domains, but we've already displayed those.
|
||||
if (status.requests.length == 0) {
|
||||
show_modal_error("TLS Certificate Provisioning", "There were no domain names to provision certificates for.");
|
||||
// don't return - haven't re-enabled the provision button
|
||||
}
|
||||
|
||||
// Each provisioning API call returns zero or more "requests" which represent
|
||||
// a request to Let's Encrypt for a single certificate. Normally there is just
|
||||
// one request (for a single multi-domain certificate).
|
||||
let global_logs = []
|
||||
for (var i = 0; i < status.requests.length; i++) {
|
||||
var r = status.requests[i];
|
||||
|
||||
if (r.result == "skipped") {
|
||||
// not interested --- this domain wasn't in the table
|
||||
// to begin with
|
||||
continue;
|
||||
}
|
||||
|
||||
// plain log line
|
||||
if (typeof r === "string") {
|
||||
if (r !== "") {
|
||||
global_logs.append(r)
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let n = $("<div class=\"card mb-3\"><h5 class=\"card-header text-white code-white\"></h5><div class=\"card-body bg-light small\"></div></div>");
|
||||
$('#ssl_provision_result').append(n);
|
||||
|
||||
if (status.requests.length > 0) {
|
||||
n.find(".card-header").html(`Logs for ${r.domains.map((h) => { return `<code>${h}</code>` }).join(", ")}`);
|
||||
}
|
||||
|
||||
if (r.result == "error") {
|
||||
n.find(".card-header").addClass("bg-danger")
|
||||
} else if (r.result == "installed") {
|
||||
n.find(".card-header").addClass("bg-success")
|
||||
setTimeout("show_tls(true)", 1);
|
||||
}
|
||||
|
||||
// display the detailed log info in case of problems
|
||||
let logs = r.log.join("\n")
|
||||
if (logs === "" || logs === null || logs === undefined) {
|
||||
logs = "Something wrong happened, sorry!"
|
||||
}
|
||||
n.find("div").text(logs);
|
||||
}
|
||||
|
||||
if (global_logs.length !== 0) {
|
||||
let card = $("<div class=\"card mb-3\"><div class=\"card-body small\"></div></div>")
|
||||
$('#ssl_provision_result').append(card);
|
||||
n.find(".card-body").text(global_logs.join("\n"))
|
||||
}
|
||||
|
||||
if (may_reenable_provision_button) {
|
||||
$('#ssl_provision_p .btn').removeAttr("disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
|
@ -1,47 +1,95 @@
|
|||
<div class="container">
|
||||
<div>
|
||||
<h2>Contacts & Calendar Synchronization</h2>
|
||||
|
||||
<p>This box can hold your contacts and calendar, just like it holds your email.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="row d-flex">
|
||||
<div class="col-lg-6">
|
||||
<h4>In your browser</h4>
|
||||
|
||||
<p>You can edit your contacts and calendar from your web browser.</p>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
||||
<tr><td>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||
<tr><td>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>For...</th>
|
||||
<th>Visit this URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<th>Contacts</td>
|
||||
<td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Calendar</td>
|
||||
<td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
||||
complete email address and your mail password.</p>
|
||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>:
|
||||
your
|
||||
complete email address and your mail password.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<div class="col-lg-6">
|
||||
<h4>On your mobile device</h4>
|
||||
|
||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
|
||||
your contacts and calendar may already appear on your device.</p>
|
||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using
|
||||
Exchange/ActiveSync,
|
||||
your contacts and calendar may already appear on your device.</p>
|
||||
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
|
||||
|
||||
<table class="table">
|
||||
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||
<tr><td>Contacts</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>Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.89)</td></tr>
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>For...</th>
|
||||
<th>Use...</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td>Contacts and Calendar</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVx⁵</a> ($5.99;
|
||||
free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Only Contacts</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync
|
||||
free</a> (free)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Only Calendar</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a>
|
||||
($2.99)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Use the following settings:</p>
|
||||
|
||||
<table class="table">
|
||||
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
|
||||
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
|
||||
<tr><td>Use SSL</td> <td>Yes</td></tr>
|
||||
<tr><td>Username</td> <td>Your complete email address.</td></tr>
|
||||
<tr><td>Password</td> <td>Your mail password.</td></tr>
|
||||
<tr>
|
||||
<td>Account Type</td>
|
||||
<td>CardDAV or CalDAV</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server Name</td>
|
||||
<td>{{hostname}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use SSL</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Username</td>
|
||||
<td>Your complete email address.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Password</td>
|
||||
<td>Your mail password.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,78 +1,416 @@
|
|||
<style>
|
||||
#backup-status th { text-align: center; }
|
||||
#backup-status tr.full-backup td { font-weight: bold; }
|
||||
#backup-status tr.full-backup td {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Backup Status</h2>
|
||||
|
||||
<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="col-lg-10 col-xl-8">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="backup-target-type">Backup to</label>
|
||||
<select class="form-select" rows="1" id="backup-target-type" onchange="toggle_form()">
|
||||
<option value="off">Nowhere (Disable Backups)</option>
|
||||
<option value="local">{{hostname}}</option>
|
||||
<option value="rsync">rsync</option>
|
||||
<option value="s3">S3 (Amazon or compatible) </option>
|
||||
<option value="b2">Backblaze B2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You can also use SFTP (FTP over SSH) to copy files from <tt id="backup-location"></tt>. These files are encrpyted, 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>
|
||||
<!-- LOCAL BACKUP -->
|
||||
<div class="form-group backup-target-local">
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP
|
||||
over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are
|
||||
encrypted, so they are safe to store anywhere.</p>
|
||||
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure
|
||||
location. You will need this file to decrypt backup files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RSYNC BACKUP -->
|
||||
<div class="form-group backup-target-rsync">
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
|
||||
<h3>Current Backups</h3>
|
||||
<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>
|
||||
|
||||
<p>The backup directory currently contains the backups listed below. The total size on disk of the backups is <span id="backup-total-size"></span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text">Where to?</label>
|
||||
<input type="text" class="form-control" id="backup-target-rsync-host" placeholder="hostname.local">
|
||||
<label class="input-group-text">:</label>
|
||||
<div style="width: 20%">
|
||||
<input type="number" class="form-control" id="backup-target-rsync-port" placeholder="Port (22)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-rsync-path" class="input-group-text">Path</label>
|
||||
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" id="backup-target-rsync-path">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-rsync-path" class="input-group-text">Username</label>
|
||||
<input type="text" class="form-control" id="backup-target-rsync-user">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
||||
<label for="ssh-pub-key" class="col-sm-2 control-label">
|
||||
<h4>Public SSH Key</h4>
|
||||
</label>
|
||||
<textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea>
|
||||
<div class="small" style="margin-top: 2px">
|
||||
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
|
||||
of target user on the backup server specified above. That way you'll enable secure and
|
||||
passwordless authentication from your mail-in-a-box server and your backup server.
|
||||
</div>
|
||||
</div>
|
||||
<!-- S3 BACKUP -->
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||
secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-s3-host-select" class="input-group-text">S3 Region</label>
|
||||
<select class="form-select" id="backup-target-s3-host-select">
|
||||
{% for name, host in backup_s3_hosts %}
|
||||
<option value="{{host}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
<option value="other">Other (non AWS)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-s3-host" class="input-group-text">S3 Host / Endpoint</label>
|
||||
<input type="text" placeholder="Endpoint" class="form-control" id="backup-target-s3-host">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-s3-path" class="input-group-text">S3 Path</label>
|
||||
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control"
|
||||
id="backup-target-s3-path">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-user" class="input-group-text">S3 Access Key</label>
|
||||
<input type="text" class="form-control" id="backup-target-user">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-pass" class="input-group-text">S3 Secret Access Key</label>
|
||||
<input type="text" class="form-control" id="backup-target-pass">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backblaze -->
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2
|
||||
bucket. You must have a Backblaze account already.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||
secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-b2-user" class="input-group-text">B2 Application KeyID</label>
|
||||
<input type="text" class="form-control" id="backup-target-b2-user">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-b2-pass" class="input-group-text">B2 Application Key</label>
|
||||
<input type="text" class="form-control" id="backup-target-b2-pass">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-b2-bucket" class="input-group-text">B2 Bucket</label>
|
||||
<input type="text" class="form-control" id="backup-target-b2-bucket">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Common -->
|
||||
<div class="backup-target-local backup-target-rsync backup-target-s3 backup-target-b2">
|
||||
<hr>
|
||||
<div class="col-lg-10 col-xl-8">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="backup-min-age">Retention Days</label>
|
||||
<input type="number" class="form-control" id="backup-min-age">
|
||||
</div>
|
||||
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an
|
||||
incremental backup most nights, which requires that previous backups back to the most recent full backup be
|
||||
preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when
|
||||
the incremental backup data size exceeds a limit.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table id="backup-status" class="table" style="width: auto">
|
||||
<thead>
|
||||
<th colspan="2">When</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Deleted in...</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
<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 align-middle col-12">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th colspan="2">When</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Deleted in...</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><small>The size column in the table indicates the size of the encrpyted backup, but the total size on disk shown above includes storage for unencrpyted intermediate files.</small></p>
|
||||
<!-- Hide these buttons until we're sure we can use them :) -->
|
||||
<div class="row justify-content-evenly">
|
||||
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)"
|
||||
style="display: none;">Create Full Backup Now</button>
|
||||
<button id="create-incremental-backup-button" class="btn btn-primary col-3" onclick="do_backup(false)"
|
||||
style="display: none;">Create Incremental Backup Now</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function nice_size(bytes) {
|
||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
while (true) {
|
||||
if (powers.length == 1) break;
|
||||
if (bytes < 1000) break;
|
||||
bytes /= 1024;
|
||||
powers.shift();
|
||||
}
|
||||
return (Math.round(bytes*10)/10) + " " + powers[0];
|
||||
}
|
||||
|
||||
function show_system_backup() {
|
||||
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/backup/status",
|
||||
"GET",
|
||||
{ },
|
||||
function(r) {
|
||||
$('#backup-location').text(r.encdirectory);
|
||||
$('#backup-encpassword-file').text(r.encpwfile);
|
||||
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();
|
||||
|
||||
$('#backup-status tbody').html("");
|
||||
var total_disk_size = 0;
|
||||
for (var i = 0; i < r.backups.length; i++) {
|
||||
var b = r.backups[i];
|
||||
var tr = $('<tr/>');
|
||||
if (b.full) tr.addClass("full-backup");
|
||||
tr.append( $('<td/>').text(b.date_str + " " + r.tz) );
|
||||
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
||||
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
||||
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.encsize)) );
|
||||
if (b.deleted_in)
|
||||
tr.append( $('<td/>').text(b.deleted_in) );
|
||||
else
|
||||
tr.append( $('<td class="text-muted">n/a</td>') );
|
||||
$('#backup-status tbody').append(tr);
|
||||
init_inputs(target_type);
|
||||
}
|
||||
|
||||
total_disk_size += b.size;
|
||||
total_disk_size += b.encsize;
|
||||
}
|
||||
function nice_size(bytes) {
|
||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
while (true) {
|
||||
if (powers.length == 1) break;
|
||||
if (bytes < 1000) break;
|
||||
bytes /= 1024;
|
||||
powers.shift();
|
||||
}
|
||||
// round to have three significant figures but at most one decimal place
|
||||
if (bytes >= 100)
|
||||
bytes = Math.round(bytes)
|
||||
else
|
||||
bytes = Math.round(bytes * 10) / 10;
|
||||
return bytes + " " + powers[0];
|
||||
}
|
||||
|
||||
function show_system_backup() {
|
||||
show_custom_backup()
|
||||
|
||||
$('#backup-status tbody').html("<tr><td colspan='5' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/backup/status",
|
||||
"GET",
|
||||
{},
|
||||
function (r) {
|
||||
if (r.error) {
|
||||
show_modal_error("Backup Error", $("<pre/>").text(r.error));
|
||||
return;
|
||||
}
|
||||
|
||||
$('#backup-status tbody').html("");
|
||||
var total_disk_size = 0;
|
||||
|
||||
if (typeof r.backups == "undefined") {
|
||||
var tr = $('<tr><td colspan="5">Backups are turned off.</td></tr>');
|
||||
$('#backup-status tbody').append(tr);
|
||||
$('#create-full-backup-button').css("display", "none")
|
||||
$('#create-incremental-backup-button').css("display", "none")
|
||||
return;
|
||||
} else if (r.backups.length == 0) {
|
||||
var tr = $('<tr><td colspan="5">No backups have been made yet.</td></tr>');
|
||||
$('#backup-status tbody').append(tr);
|
||||
}
|
||||
|
||||
// Backups ARE enabled.
|
||||
$('#create-full-backup-button').css("display", "unset")
|
||||
$('#create-incremental-backup-button').css("display", "unset")
|
||||
for (var i = 0; i < r.backups.length; i++) {
|
||||
var b = r.backups[i];
|
||||
var tr = $('<tr/>');
|
||||
if (b.full) tr.addClass("full-backup");
|
||||
tr.append($('<td/>').text(b.date_str));
|
||||
tr.append($('<td/>').text(b.date_delta + " ago"));
|
||||
tr.append($('<td/>').text(b.full ? "full" : "increment"));
|
||||
tr.append($('<td/>').text(nice_size(b.size)));
|
||||
if (b.deleted_in)
|
||||
tr.append($('<td/>').text(b.deleted_in));
|
||||
else
|
||||
tr.append($('<td class="text-muted">unknown</td>'));
|
||||
$('#backup-status tbody').append(tr);
|
||||
|
||||
total_disk_size += b.size;
|
||||
}
|
||||
|
||||
total_disk_size += r.unmatched_file_size;
|
||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||
})
|
||||
}
|
||||
|
||||
function show_custom_backup() {
|
||||
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||
api(
|
||||
"/system/backup/config",
|
||||
"GET",
|
||||
{},
|
||||
function (r) {
|
||||
$("#backup-target-user").val(r.target_user);
|
||||
$("#backup-target-pass").val(r.target_pass);
|
||||
$("#backup-min-age").val(r.min_age_in_days);
|
||||
$(".backup-location").text(r.file_target_directory);
|
||||
$(".backup-encpassword-file").text(r.enc_pw_file);
|
||||
$("#ssh-pub-key").val(r.ssh_pub_key);
|
||||
|
||||
if (r.target == "file://" + r.file_target_directory) {
|
||||
$("#backup-target-type").val("local");
|
||||
} else if (r.target == "off") {
|
||||
$("#backup-target-type").val("off");
|
||||
} else if (r.target.substring(0, 8) == "rsync://") {
|
||||
$("#backup-target-type").val("rsync");
|
||||
let uri = r.target.substring(8)
|
||||
let i = uri.indexOf("/")
|
||||
let path = [uri.slice(0, i), uri.slice(i + 1)];
|
||||
let host_parts = path.shift().split('@');
|
||||
$("#backup-target-rsync-user").val(host_parts[0]);
|
||||
$("#backup-target-rsync-host").val(host_parts[1]);
|
||||
$("#backup-target-rsync-path").val(path[0]);
|
||||
$("#backup-target-rsync-port").val(r.target_rsync_port)
|
||||
} else if (r.target.substring(0, 5) == "s3://") {
|
||||
$("#backup-target-type").val("s3");
|
||||
var hostpath = r.target.substring(5).split('/');
|
||||
var host = hostpath.shift();
|
||||
let s3_options = $("#backup-target-s3-host-select option").map(function() {return this.value}).get()
|
||||
$("#backup-target-s3-host-select").val("other")
|
||||
for (let h of s3_options) {
|
||||
console.log(h)
|
||||
if (h == host) {
|
||||
$("#backup-target-s3-host-select").val(host)
|
||||
break
|
||||
}
|
||||
}
|
||||
$("#backup-target-s3-host").val(host);
|
||||
$("#backup-target-s3-path").val(hostpath.join('/'));
|
||||
} else if (r.target.substring(0, 5) == "b2://") {
|
||||
$("#backup-target-type").val("b2");
|
||||
var targetPath = r.target.substring(5);
|
||||
var b2_application_keyid = targetPath.split(':')[0];
|
||||
var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
|
||||
var b2_bucket = targetPath.split('@')[1];
|
||||
$("#backup-target-b2-user").val(b2_application_keyid);
|
||||
$("#backup-target-b2-pass").val(b2_applicationkey);
|
||||
$("#backup-target-b2-bucket").val(b2_bucket);
|
||||
}
|
||||
toggle_form()
|
||||
})
|
||||
}
|
||||
|
||||
function set_custom_backup() {
|
||||
var target_type = $("#backup-target-type").val();
|
||||
var target_user = $("#backup-target-user").val();
|
||||
var target_pass = $("#backup-target-pass").val();
|
||||
let target_port = $("#backup-target-rsync-port").val();
|
||||
|
||||
var target;
|
||||
if (target_type == "local" || target_type == "off")
|
||||
target = target_type;
|
||||
else if (target_type == "s3")
|
||||
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
||||
else if (target_type == "rsync") {
|
||||
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
||||
+ "/" + $("#backup-target-rsync-path").val();
|
||||
target_user = '';
|
||||
} else if (target_type == "b2") {
|
||||
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
|
||||
+ '@' + $('#backup-target-b2-bucket').val()
|
||||
target_user = '';
|
||||
target_pass = '';
|
||||
}
|
||||
|
||||
var min_age = $("#backup-min-age").val();
|
||||
api(
|
||||
"/system/backup/config",
|
||||
"POST",
|
||||
{
|
||||
target: target,
|
||||
target_user: target_user,
|
||||
target_pass: target_pass,
|
||||
target_rsync_port: target_port,
|
||||
min_age: min_age
|
||||
},
|
||||
function (r) {
|
||||
// use .text() --- it's a text response, not html
|
||||
show_modal_error("Backup configuration", $("<p/>").text(r), function () { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||
},
|
||||
function (r) {
|
||||
// use .text() --- it's a text response, not html
|
||||
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function init_inputs(target_type) {
|
||||
function set_host(host, overwrite_other) {
|
||||
if (host !== 'other') {
|
||||
$("#backup-target-s3-host").val(host);
|
||||
} else if (overwrite_other) {
|
||||
$("#backup-target-s3-host").val('');
|
||||
}
|
||||
}
|
||||
if (target_type == "s3") {
|
||||
$('#backup-target-s3-host-select').off('change').on('change', function () {
|
||||
set_host($('#backup-target-s3-host-select').val(), true);
|
||||
});
|
||||
set_host($('#backup-target-s3-host-select').val(), false);
|
||||
}
|
||||
}
|
||||
|
||||
function do_backup(is_full) {
|
||||
let disclaimer = "The backup process will pause some services (such as PHP, Postfix and Dovecot). Depending on the size of the data this can take a while."
|
||||
if (!is_full) {
|
||||
disclaimer += "\nDepending on the amount of incremental backups done after the last full backup, the box may decide to do a full backup instead."
|
||||
}
|
||||
show_modal_confirm("Warning!", disclaimer, "Start Backup", () => {
|
||||
api(
|
||||
"/system/backup/new",
|
||||
"POST",
|
||||
{
|
||||
full: is_full
|
||||
},
|
||||
function (r) {
|
||||
// use .text() --- it's a text response, not html
|
||||
show_modal_error("Backup configuration", $("<p/>").text(r), function () { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||
},
|
||||
function (r) {
|
||||
// use .text() --- it's a text response, not html
|
||||
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||
});
|
||||
return false;
|
||||
})
|
||||
}
|
||||
|
||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
<style>
|
||||
#external_dns_settings .heading td {
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
#external_dns_settings .heading.first td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
#external_dns_settings .values td {
|
||||
border: 0;
|
||||
padding-top: .75em;
|
||||
padding-bottom: 0;
|
||||
max-width: 50vw;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
#external_dns_settings .explanation td {
|
||||
border: 0;
|
||||
padding-top: .5em;
|
||||
padding-bottom: .75em;
|
||||
font-style: italic;
|
||||
font-size: 95%;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>DNS (Advanced)</h2>
|
||||
|
||||
<p class="text-danger">Custom and external DNS are for advanced configurations.</p>
|
||||
|
||||
<h3>Custom DNS</h3>
|
||||
|
||||
<p>It is possible to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service. To do so, you will need to call your box’s DNS API.</p>
|
||||
|
||||
<h4>The HTTP POST request</h4>
|
||||
|
||||
<p>Send a POST request like this:</p>
|
||||
|
||||
<pre>curl -d "" --user {email}:{password} https://{{hostname}}/admin/dns/set/<b>qname</b>[/<b>rtype</b>[/<b>value</b>]]</pre>
|
||||
|
||||
<table class="table">
|
||||
<thead><th>Parameter</th> <th>Value</th></thead>
|
||||
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
||||
<tr><td>password</td> <td>That user’s password.</td></tr>
|
||||
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set.</td></tr>
|
||||
<tr><td>rtype</td> <td>The resource type. <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), or <code>CNAME</code> (an alias, which is a fully qualified domain name).</td></tr>
|
||||
<tr><td>value</td> <td>The new record’s value. If omitted, the IPv4 address of the remote host is used. This is handy for dynamic DNS! To delete a record, use “__delete__”.</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 1em">Note that <code>-d ""</code> is merely to ensure curl sends a POST request. You do not need to put anything inside the quotes. You can also pass the value using typical form encoding in the POST body.</p>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/laptop.mydomain.com
|
||||
|
||||
# sets an alias
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/cname/bar.mydomain.com
|
||||
|
||||
# clears the alias
|
||||
curl -d "" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/bar.mydomain.com/cname/__delete__
|
||||
|
||||
# sets a TXT record using the alternate value syntax
|
||||
curl -d "value=something%20here" --user me@mydomain.com:###### https://{{hostname}}/admin/dns/set/foo.mydomain.com/txt
|
||||
</pre>
|
||||
|
||||
|
||||
<h3>External DNS</h3>
|
||||
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere.</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>Enter the following DNS entries at your DNS provider:</p>
|
||||
|
||||
<table id="external_dns_settings" class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QName</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function show_system_dns() {
|
||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/dns/dump",
|
||||
"GET",
|
||||
{ },
|
||||
function(zones) {
|
||||
$('#external_dns_settings tbody').html("");
|
||||
for (var j = 0; j < zones.length; j++) {
|
||||
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
|
||||
h.find("td").text(zones[j][0]);
|
||||
$('#external_dns_settings tbody').append(h);
|
||||
|
||||
var r = zones[j][1];
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
|
||||
n.find('.qname').text(r[i].qname);
|
||||
n.find('.rtype').text(r[i].rtype);
|
||||
n.find('.value').text(r[i].value);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
|
||||
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
|
||||
n.find('td').text(r[i].explanation);
|
||||
$('#external_dns_settings tbody').append(n);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
|
@ -1,79 +1,218 @@
|
|||
<h2>System Status Checks</h2>
|
||||
|
||||
<style>
|
||||
#system-checks .heading td {
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
#system-checks .heading.first td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
#system-checks .error td {
|
||||
color: #733;
|
||||
}
|
||||
#system-checks .ok td {
|
||||
color: #030;
|
||||
}
|
||||
#system-checks div.extra {
|
||||
display: none;
|
||||
margin-top: 1em;
|
||||
max-width: 50em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
#system-checks a.showhide {
|
||||
display: none;
|
||||
font-size: 85%;
|
||||
}
|
||||
#system-checks .pre {
|
||||
margin: 1em;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#system-checks .message {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#system-checks .icon {
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
#system-checks .heading {
|
||||
font-weight: bold;
|
||||
font-size: 180%;
|
||||
padding-top: 1.75em;
|
||||
}
|
||||
|
||||
#system-checks .heading.first {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.status-error .fas {
|
||||
color: rgb(190, 0, 0);
|
||||
}
|
||||
|
||||
.status-error.sym {
|
||||
color: rgb(190, 0, 0);
|
||||
}
|
||||
|
||||
.status-error .status-text {
|
||||
color: rgb(70, 0, 0);
|
||||
}
|
||||
|
||||
.status-warning .fas {
|
||||
color: rgb(191, 150, 0);
|
||||
}
|
||||
|
||||
.status-warning.sym {
|
||||
color: rgb(191, 150, 0);
|
||||
}
|
||||
|
||||
.status-warning .status-text {
|
||||
color: rgb(69, 54, 0);
|
||||
}
|
||||
|
||||
.status-ok .fas {
|
||||
color: rgb(0, 190, 0);
|
||||
}
|
||||
|
||||
.status-ok.sym {
|
||||
color: rgb(0, 190, 0);
|
||||
}
|
||||
|
||||
.status-ok .status-text {
|
||||
color: rgb(0, 70, 0);
|
||||
}
|
||||
|
||||
.status-na .fas {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
||||
.status-na.sym {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
||||
.status-na .status-text {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
||||
div.extra {
|
||||
display: none;
|
||||
margin-top: 1em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#system-checks .showhide {
|
||||
display: block;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
#system-checks .pre {
|
||||
margin: 1em;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table id="system-checks" class="table" style="max-width: 60em">
|
||||
<thead>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
|
||||
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
|
||||
<div>No reboot is necessary.</div>
|
||||
</div>
|
||||
|
||||
<div id="system-privacy-setting" style="display: none">
|
||||
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span>
|
||||
New-Version Check</a></div>
|
||||
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of
|
||||
Mail-in-a-Box.)</small></p>
|
||||
</div>
|
||||
|
||||
<div id="system-checks">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function show_system_status() {
|
||||
$('#system-checks tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/status",
|
||||
"POST",
|
||||
{ },
|
||||
function(r) {
|
||||
$('#system-checks tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><div class='extra'/><a class='showhide' href='#'/></tr>");
|
||||
if (i == 0) n.addClass('first')
|
||||
n.addClass(r[i].type)
|
||||
if (r[i].type == "ok") n.find('td.status').text("✓")
|
||||
if (r[i].type == "error") n.find('td.status').text("✖")
|
||||
n.find('td.message p').text(r[i].text)
|
||||
$('#system-checks tbody').append(n);
|
||||
function show_system_status() {
|
||||
$('#system-checks').html("")
|
||||
|
||||
if (r[i].extra.length > 0) {
|
||||
n.find('a.showhide').show().text("show more").click(function() {
|
||||
$(this).hide();
|
||||
$(this).parent().find('.extra').fadeIn();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
for (var j = 0; j < r[i].extra.length; j++) {
|
||||
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);
|
||||
});
|
||||
|
||||
var m = $("<div/>").text(r[i].extra[j].text)
|
||||
if (r[i].extra[j].monospace)
|
||||
m.addClass("pre");
|
||||
n.find('> td.message > div').append(m);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
api(
|
||||
"/system/status",
|
||||
"POST",
|
||||
{},
|
||||
function (r) {
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
let n = $("<div class='col-12'><div class='icon'></div><p class='message status-text' style='margin: 0'/>");
|
||||
if (i == 0) n.addClass('first')
|
||||
|
||||
if (r[i].type == "heading") {
|
||||
n.addClass(r[i].type)
|
||||
} else {
|
||||
n.addClass("status-" + r[i].type)
|
||||
n.find(".icon").addClass("fas")
|
||||
}
|
||||
|
||||
if (r[i].type == "ok") n.find(".icon").addClass("fa-check")
|
||||
if (r[i].type == "error") n.find(".icon").addClass("fa-times")
|
||||
if (r[i].type == "warning") n.find(".icon").addClass("fa-exclamation-triangle")
|
||||
|
||||
n.find('p.status-text').text(r[i].text)
|
||||
|
||||
if (r[i].extra.length > 0) {
|
||||
let open = false
|
||||
n.append("<p class='showhide btn btn-light mt-3' href='#'>Show More</p><div class='extra ps-4 col-12'></div>")
|
||||
n.find('.showhide').click(function () {
|
||||
let extra = $(this).parent().find('.extra')
|
||||
|
||||
if (open) {
|
||||
$(this).html("Show More");
|
||||
extra.fadeOut();
|
||||
} else {
|
||||
$(this).html("Show Less");
|
||||
extra.fadeIn();
|
||||
}
|
||||
|
||||
open = !open
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
for (var j = 0; j < r[i].extra.length; j++) {
|
||||
|
||||
var m = $("<div/>").text(r[i].extra[j].text)
|
||||
if (r[i].extra[j].monospace)
|
||||
m.addClass("pre");
|
||||
n.find('.extra').append(m);
|
||||
}
|
||||
|
||||
$('#system-checks').append(n);
|
||||
if (i < r.length - 1) {
|
||||
$('#system-checks').append($("<hr>"));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
@ -1,227 +1,453 @@
|
|||
<h2>Users</h2>
|
||||
|
||||
<style>
|
||||
#user_table tr.account_inactive td .address { color: #888; text-decoration: line-through; }
|
||||
#user_table .aliases { margin-top: .33em; font-size: 95%; }
|
||||
#user_table .aliases div:before { content: "⇖ "; }
|
||||
#user_table .aliases div { }
|
||||
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||
#user_table .account_inactive .if_active { display: none; }
|
||||
#user_table .account_active .if_inactive { display: none; }
|
||||
#user_table tr.account_inactive td.address {
|
||||
color: #888;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
#user_table .actions {
|
||||
margin-top: .33em;
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
#user_table .account_inactive .if_active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#user_table .account_active .if_inactive {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#user_table .account_active.if_inactive {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h3>Add a mail user</h3>
|
||||
|
||||
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p>
|
||||
<p>Add an email address to this system. This will create a new login username/password.</p>
|
||||
|
||||
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
||||
<div class="form-group">
|
||||
<label class="sr-only" for="adduserEmail">Email address</label>
|
||||
<input type="email" class="form-control" id="adduserEmail" placeholder="Email Address">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="sr-only" for="adduserPassword">Password</label>
|
||||
<input type="password" class="form-control" id="adduserPassword" placeholder="Password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<select class="form-control" id="adduserPrivs">
|
||||
<option value="">Normal User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
<form role="form" onsubmit="return do_add_user(); return false;">
|
||||
<div class="mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserEmail">Email address</label>
|
||||
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail"
|
||||
placeholder="me@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserPassword">Password</label>
|
||||
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword"
|
||||
placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 col-lg-6 mb-3">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserPrivs">User Type</label>
|
||||
<select class="form-select" id="adduserPrivs">
|
||||
<option value="">Normal User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 col-lg-6 mb-3">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserQuota">Storage Quota</label>
|
||||
<input type="text" class="form-control" id="adduserQuota" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||
<li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#"
|
||||
onclick="return generate_random_password()">generate a random password</a>.</li>
|
||||
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to
|
||||
existing accounts.</li>
|
||||
<li>Administrators get access to this control panel.</li>
|
||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#"
|
||||
onclick="return show_panel('aliases');">aliases</a> can.</li>
|
||||
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are
|
||||
allowed. For unlimited storage enter 0 (zero)</li>
|
||||
</ul>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
<p style="margin-top: .5em"><small>
|
||||
Passwords must be at least four characters and may not contain spaces.
|
||||
Administrators get access to this control panel.
|
||||
</small></p>
|
||||
|
||||
<h3>Existing mail users</h3>
|
||||
<table id="user_table" class="table" style="width: auto">
|
||||
<tbody>
|
||||
</tbody>
|
||||
<table id="user_table" class="table col-12">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="35%">Email Address</th>
|
||||
<th class="">Messages</th>
|
||||
<th class="">Size</th>
|
||||
<th class="">Used</th>
|
||||
<th class="">Quota</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="display: none">
|
||||
<table>
|
||||
<tr id="user-template">
|
||||
<td class='email'>
|
||||
<div class='address'> </div>
|
||||
<table>
|
||||
<tr id="user-template">
|
||||
<td class='address'>
|
||||
</td>
|
||||
<td class="box-count"></td>
|
||||
<td class="box-size"></td>
|
||||
<td class="percent"></td>
|
||||
<td class="quota">
|
||||
</td>
|
||||
<td class='actions'>
|
||||
<span class='privs'>
|
||||
</span>
|
||||
|
||||
<div class='actions'>
|
||||
<span class='privs'>
|
||||
</span>
|
||||
<span class="if_active">
|
||||
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
|
||||
set quota
|
||||
</a>
|
||||
|
|
||||
</span>
|
||||
|
||||
<span class="if_active">
|
||||
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
|
||||
set password
|
||||
</a>
|
||||
|
|
||||
</span>
|
||||
<span class="if_active">
|
||||
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
|
||||
set password
|
||||
</a>
|
||||
|
|
||||
</span>
|
||||
|
||||
<span class='add-privs'>
|
||||
</span>
|
||||
<span class='add-privs'>
|
||||
</span>
|
||||
|
||||
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
||||
archive account
|
||||
</a>
|
||||
|
||||
<div class='if_inactive' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address.</div>
|
||||
</div>
|
||||
|
||||
<div class='aliases' style='display: none'> </div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
||||
archive account
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="user-extra-template" class="if_inactive">
|
||||
<td colspan="3" style="border: 0; padding-top: 0">
|
||||
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this
|
||||
email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Mail user API (advanced)</h3>
|
||||
|
||||
<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">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td><b>GET</b< /td>
|
||||
<td><i>(none)</i></td>
|
||||
<td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded
|
||||
results.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b< /td>
|
||||
<td class="font-monospace">/add</td>
|
||||
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional
|
||||
parameters: <code>privilege=admin</code> and <code>quota</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/remove</td>
|
||||
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/privileges/add</td>
|
||||
<td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and
|
||||
<code>privilege=admin</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/privileges/remove</td>
|
||||
<td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>GET</b></td>
|
||||
<td class="font-monospace">/quota</td>
|
||||
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/quota</td>
|
||||
<td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
||||
line argument which you must fill in with your administrative email address and password.</p>
|
||||
|
||||
<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>
|
||||
function show_users() {
|
||||
$('#user_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/users",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function(r) {
|
||||
$('#user_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var n = $("#user-template").clone();
|
||||
n.attr('id', '');
|
||||
function show_users() {
|
||||
api(
|
||||
"/system/default-quota",
|
||||
"GET",
|
||||
{},
|
||||
function (r) {
|
||||
$('#adduserQuota').val(r['default-quota']);
|
||||
}
|
||||
);
|
||||
|
||||
n.addClass("account_" + r[i].status);
|
||||
n.attr('data-email', r[i].email);
|
||||
n.find('td.email .address').text(r[i].email)
|
||||
$('#user_table tbody').append(n);
|
||||
$('#user_table tbody').html("<tr><td colspan='6' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/mail/users",
|
||||
"GET",
|
||||
{ format: 'json' },
|
||||
function (r) {
|
||||
$('#user_table tbody').html("");
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
var hdr = $("<tr><th colspan='6' class='bg-light'></th></tr>");
|
||||
hdr.find('th').text(r[i].domain);
|
||||
$('#user_table tbody').append(hdr);
|
||||
|
||||
if (r[i].status == 'inactive') continue;
|
||||
for (var k = 0; k < r[i].users.length; k++) {
|
||||
var user = r[i].users[k];
|
||||
|
||||
var add_privs = ["admin"];
|
||||
var n = $("#user-template").clone();
|
||||
var n2 = $("#user-extra-template").clone();
|
||||
n.attr('id', '');
|
||||
n2.attr('id', '');
|
||||
$('#user_table tbody').append(n);
|
||||
$('#user_table tbody').append(n2);
|
||||
|
||||
for (var j = 0; j < r[i].privileges.length; j++) {
|
||||
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
|
||||
p.find('span.name').text(r[i].privileges[j]);
|
||||
n.find('.privs').append(p);
|
||||
if (add_privs.indexOf(r[i].privileges[j]) >= 0)
|
||||
add_privs.splice(add_privs.indexOf(r[i].privileges[j]), 1);
|
||||
}
|
||||
n.addClass("account_" + user.status);
|
||||
n2.addClass("account_" + user.status);
|
||||
|
||||
for (var j = 0; j < add_privs.length; j++) {
|
||||
var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
|
||||
p.find('span.name').text(add_privs[j]);
|
||||
n.find('.add-privs').append(p);
|
||||
}
|
||||
n.attr('data-email', user.email);
|
||||
n.attr('data-quota', user.quota);
|
||||
n.find('.address').text(user.email);
|
||||
n.find('.box-count').text((user.box_count).toLocaleString('en'));
|
||||
if (user.box_count == '?') {
|
||||
n.find('.box-count').attr('title', 'Message count is unkown')
|
||||
}
|
||||
n.find('.box-size').text(user.box_size);
|
||||
if (user.box_size == '?') {
|
||||
n.find('.box-size').attr('title', 'Mailbox size is unkown')
|
||||
}
|
||||
n.find('.percent').text(user.percent);
|
||||
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
|
||||
n2.find('.restore_info tt').text(user.mailbox);
|
||||
|
||||
if (r[i].aliases && r[i].aliases.length > 0) {
|
||||
n.find('.aliases').show();
|
||||
for (var j = 0; j < r[i].aliases.length; j++) {
|
||||
n.find('td.email .aliases').append($("<div/>").text(
|
||||
r[i].aliases[j][0]
|
||||
+ (r[i].aliases[j][1].length > 0 ? " ⇐ " + r[i].aliases[j][1].join(", ") : "")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (user.status == 'inactive') continue;
|
||||
|
||||
function do_add_user() {
|
||||
var email = $("#adduserEmail").val();
|
||||
var pw = $("#adduserPassword").val();
|
||||
var privs = $("#adduserPrivs").val();
|
||||
api(
|
||||
"/mail/users/add",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: pw,
|
||||
privileges: privs
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Add User", $("<pre/>").text(r));
|
||||
show_users()
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Add User", r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
var add_privs = ["admin"];
|
||||
|
||||
function users_set_password(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Archive User",
|
||||
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least four characters and may not contain spaces.</small></p>"),
|
||||
"Set Password",
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/password",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: $('#users_set_password_pw').val()
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Set Password", $("<pre/>").text(r));
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Set Password", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
for (var j = 0; j < user.privileges.length; j++) {
|
||||
var p = $("<span><b><span class='name'></span></b> (<a href='#' onclick='mod_priv(this, \"remove\"); return false;' title='Remove Privilege'>remove privilege</a>) |</span>");
|
||||
p.find('span.name').text(user.privileges[j]);
|
||||
n.find('.privs').append(p);
|
||||
if (add_privs.indexOf(user.privileges[j]) >= 0)
|
||||
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
|
||||
}
|
||||
|
||||
function users_remove(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
show_modal_confirm(
|
||||
"Archive User",
|
||||
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
|
||||
"Archive",
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/remove",
|
||||
"POST",
|
||||
{
|
||||
email: email
|
||||
},
|
||||
function(r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||
show_users();
|
||||
},
|
||||
function(r) {
|
||||
show_modal_error("Remove User", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
for (var j = 0; j < add_privs.length; j++) {
|
||||
var p = $("<span><a href='#' onclick='mod_priv(this, \"add\"); return false;' title='Add Privilege'>make <span class='name'></span></a> | </span>");
|
||||
p.find('span.name').text(add_privs[j]);
|
||||
n.find('.add-privs').append(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mod_priv(elem, add_remove) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var priv = $(elem).parents('td').find('.name').text();
|
||||
function do_add_user() {
|
||||
var email = $("#adduserEmail").val();
|
||||
var pw = $("#adduserPassword").val();
|
||||
var privs = $("#adduserPrivs").val();
|
||||
var quota = $("#adduserQuota").val();
|
||||
api(
|
||||
"/mail/users/add",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: pw,
|
||||
privileges: privs,
|
||||
quota: quota
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Add User", $("<pre/>").text(r));
|
||||
show_users()
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Add User", r);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// can't remove your own admin access
|
||||
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials[0]) {
|
||||
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
||||
return;
|
||||
}
|
||||
function users_set_password(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
|
||||
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
||||
show_modal_confirm(
|
||||
"Modify Privileges",
|
||||
"Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?",
|
||||
add_remove1,
|
||||
function() {
|
||||
api(
|
||||
"/mail/users/privileges/" + add_remove,
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
privilege: priv
|
||||
},
|
||||
function(r) {
|
||||
show_users();
|
||||
});
|
||||
});
|
||||
}
|
||||
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(
|
||||
"Set Password",
|
||||
$("<p>Set a new password for <b>" + email + "</b>?</p> <p><label for='users_set_password_pw' style='display: block; font-weight: normal'>New Password:</label><input type='password' id='users_set_password_pw'></p><p><small>Passwords must be at least eight characters.</small>" + yourpw + "</p>"),
|
||||
"Set Password",
|
||||
function () {
|
||||
api(
|
||||
"/mail/users/password",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
password: $('#users_set_password_pw').val()
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Set Password", $("<pre/>").text(r));
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Set Password", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function users_set_quota(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var quota = $(elem).parents('tr').attr('data-quota');
|
||||
|
||||
show_modal_confirm(
|
||||
"Set Quota",
|
||||
$("<p>Set quota for <b>" + email + "</b>?</p>" +
|
||||
"<p>" +
|
||||
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
|
||||
"<input type='text' id='users_set_quota' value='" + quota + "'></p>" +
|
||||
"<p><small>Quotas may not contain any spaces or commas. Suffixes of G (gigabytes) and M (megabytes) are allowed.</small></p>" +
|
||||
"<p><small>For unlimited storage enter 0 (zero)</small></p>"),
|
||||
"Set Quota",
|
||||
function () {
|
||||
api(
|
||||
"/mail/users/quota",
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
quota: $('#users_set_quota').val()
|
||||
},
|
||||
function (r) {
|
||||
show_users();
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Set Quota", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function users_remove(elem) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
|
||||
// can't remove yourself
|
||||
if (api_credentials != null && email == api_credentials.username) {
|
||||
show_modal_error("Archive User", "You cannot archive your own account.");
|
||||
return;
|
||||
}
|
||||
|
||||
show_modal_confirm(
|
||||
"Archive User",
|
||||
$("<p>Are you sure you want to archive <b>" + email + "</b>?</p> <p>The user's mailboxes will not be deleted (you can do that later), but the user will no longer be able to log into any services on this machine.</p>"),
|
||||
"Archive",
|
||||
function () {
|
||||
api(
|
||||
"/mail/users/remove",
|
||||
"POST",
|
||||
{
|
||||
email: email
|
||||
},
|
||||
function (r) {
|
||||
// Responses are multiple lines of pre-formatted text.
|
||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||
show_users();
|
||||
},
|
||||
function (r) {
|
||||
show_modal_error("Remove User", r);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mod_priv(elem, add_remove) {
|
||||
var email = $(elem).parents('tr').attr('data-email');
|
||||
var priv = $(elem).parents('td').find('.name').text();
|
||||
|
||||
// can't remove your own admin access
|
||||
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
|
||||
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
||||
return;
|
||||
}
|
||||
|
||||
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
||||
show_modal_confirm(
|
||||
"Modify Privileges",
|
||||
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
|
||||
add_remove1,
|
||||
function () {
|
||||
api(
|
||||
"/mail/users/privileges/" + add_remove,
|
||||
"POST",
|
||||
{
|
||||
email: email,
|
||||
privilege: priv
|
||||
},
|
||||
function (r) {
|
||||
show_users();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function generate_random_password() {
|
||||
var pw = "";
|
||||
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
||||
for (var i = 0; i < 12; i++)
|
||||
pw += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></p>");
|
||||
return false; // cancel click
|
||||
}
|
||||
</script>
|
||||
|
|
103
management/templates/web.html
Normal file
103
management/templates/web.html
Normal file
|
@ -0,0 +1,103 @@
|
|||
<style>
|
||||
</style>
|
||||
|
||||
<h2>Static Web Hosting</h2>
|
||||
|
||||
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at
|
||||
all domain names that you set up an email user or alias for.</p>
|
||||
|
||||
<h3>Uploading web files</h3>
|
||||
|
||||
<p>You can replace the default website with your own HTML pages and other static files. This control panel won’t
|
||||
help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status"
|
||||
onclick="return show_panel(this);">Status Checks</a> page.</li>
|
||||
|
||||
<li>On your personal computer, install an SSH file transfer program such as <a
|
||||
href="https://filezilla-project.org/">FileZilla</a> or <a
|
||||
href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
|
||||
|
||||
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol
|
||||
is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this
|
||||
machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this
|
||||
control panel. Your SSH credentials probably involves a private key file.</li>
|
||||
|
||||
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine.
|
||||
They will appear directly and immediately on the web.</li>
|
||||
|
||||
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.
|
||||
</li>
|
||||
|
||||
<table id="web_domains_existing" class="table col-12" style="margin-bottom: 1em;">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Directory for Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or
|
||||
<a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a
|
||||
href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records
|
||||
to the new domain at your registrar (but <i>not</i> glue records).</p>
|
||||
|
||||
</ol>
|
||||
|
||||
<script>
|
||||
function show_web() {
|
||||
api(
|
||||
"/web/domains",
|
||||
"GET",
|
||||
{
|
||||
},
|
||||
function (domains) {
|
||||
var tb = $('#web_domains_existing tbody');
|
||||
tb.text('');
|
||||
for (var i = 0; i < domains.length; i++) {
|
||||
if (!domains[i].static_enabled) continue;
|
||||
var row = $("<tr><th class='domain'><a href=''></a></th><td class='directory'><tt/></td> <td class='change-root hidden'><button class='btn btn-default btn-xs' onclick='show_change_web_root(this)'>Change</button></td></tr>");
|
||||
tb.append(row);
|
||||
row.attr('data-domain', domains[i].domain);
|
||||
row.attr('data-custom-web-root', domains[i].custom_root);
|
||||
row.find('.domain a').text('https://' + domains[i].domain);
|
||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||
row.find('.directory tt').text(domains[i].root);
|
||||
if (domains[i].root != domains[i].custom_root)
|
||||
row.find('.change-root').removeClass('hidden');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function do_web_update() {
|
||||
api(
|
||||
"/web/update",
|
||||
"POST",
|
||||
{
|
||||
},
|
||||
function (data) {
|
||||
if (data == "")
|
||||
data = "Nothing changed.";
|
||||
else
|
||||
data = $("<pre/>").text(data);
|
||||
show_modal_error("Web Update", data, function () { show_web() });
|
||||
});
|
||||
}
|
||||
|
||||
function show_change_web_root(elem) {
|
||||
var domain = $(elem).parents('tr').attr('data-domain');
|
||||
var root = $(elem).parents('tr').attr('data-custom-web-root');
|
||||
show_modal_confirm(
|
||||
'Change Root Directory for ' + domain,
|
||||
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),
|
||||
'Update',
|
||||
function () { do_web_update(); });
|
||||
}
|
||||
</script>
|
3
management/templates/welcome.html
Normal file
3
management/templates/welcome.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<h1 class="title">{{hostname}}</h1>
|
||||
|
||||
<p class="subtitle">Welcome to your Power Mail-in-a-Box control panel.</p>
|
306
management/templates/wkd.html
Normal file
306
management/templates/wkd.html
Normal file
|
@ -0,0 +1,306 @@
|
|||
<style>
|
||||
#wkd_table #uids {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
#wkd_table .domain-header {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template id="wkd-entry-template">
|
||||
<tr>
|
||||
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
|
||||
<td id="key-dropdown" class="dropdown show" style="width: 100%;">
|
||||
<button class="btn bg-light" style="width: 100%;" data-bs-toggle="dropdown" id="current-key">
|
||||
</button>
|
||||
<div class="dropdown-menu" style="width: 100%;" id="menu">
|
||||
<button class="btn bg-light" style="width:100%;" id="key-none">
|
||||
<b>None</b>
|
||||
<br>
|
||||
<a>Do not serve a key in WKD.</a>
|
||||
</button>
|
||||
<button class="btn bg-light" style="width:100%" id="key-sample">
|
||||
<div class="row">
|
||||
<div class="col-6" id="fingerprint">
|
||||
<b class="font-monospace status-text" id="fpr"></b><span id="subkeys"></span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<b>
|
||||
<span class="sym" id="sign">S</span>
|
||||
<span class="sym" id="cert">C</span>
|
||||
<span class="sym" id="encr">E</span>
|
||||
<span class="sym" id="auth">A</span>
|
||||
</b>,
|
||||
<span id="expire"><span class="status-text"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="key-emails">
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template id="wkd-nokeys-template">
|
||||
<tr>
|
||||
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
|
||||
<td style="width: 100%; text-align: center;" class="text-muted">
|
||||
<b>None</b>
|
||||
<br>
|
||||
<a>There are no keys in the keyring that contain this email.</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<h2>WKD Management</h2>
|
||||
|
||||
<p>WKD (<b>W</b>eb <b>K</b>ey <b>D</b>irectory) is an
|
||||
<b><a href="https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html">experimental feature</a></b>
|
||||
that allows users to authoratively publish their public PGP keys on the web, via HTTPS.
|
||||
</p>
|
||||
<p>Unlike other solutions (like public keyservers), WKD has the advantage that the owner
|
||||
of the domain has some degree of control over what keys are published and as such there
|
||||
is more certainity that the key actually belongs to it's owner.</p>
|
||||
<p>As of this version, there is no support for WKS - This may or may not change in the future.</p>
|
||||
|
||||
<h3>Hosting somewhere else</h3>
|
||||
<a>
|
||||
If you have a separate server to host WKD, you can still use it instead of this box on a per-domain basis.
|
||||
<br>
|
||||
This box uses the Advanced Method to serve the keys. For example, to host your <code>@some.example.com</code> keys,
|
||||
you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's
|
||||
records.
|
||||
<br><br>
|
||||
There's not a way to "disable" WKD at the moment - but if you don't want to publish keys, or if you want to use the
|
||||
Direct method somewhere else, you can always set the records to an unreachable destination
|
||||
(for example, an A record to <code>0.0.0.0</code>)
|
||||
</a>
|
||||
|
||||
<h3>Emails hosted by this box</h3>
|
||||
<a>
|
||||
This box can serve keys to any email address hosted by this box, be it an user or an alias,
|
||||
granted that the key has one or more user id's with matching emails.
|
||||
<br>
|
||||
You can add keys on the PGP Keyring Management menu.
|
||||
</a>
|
||||
|
||||
<div class="input-group mt-3 mb-3">
|
||||
<label for="wkd-show-all-entries" class="input-group-text"><b>Show all email addresses</b></label>
|
||||
<div class="input-group-text">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox" role="switch" id="wkd-show-all-entries" class="form-check-input" value=false
|
||||
onclick="toggle_emails_with_no_pgp_key();">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="wkd_table" class="table" style="width: 100%">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email Address</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wkd_tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button id="wkd_submit" class="btn btn-primary" disabled onclick="update_wkd()">Apply</button>
|
||||
|
||||
<script>
|
||||
let dirty_config = {}
|
||||
let toggleable_lines = []
|
||||
let show_toggleable_lines = document.getElementById("wkd-show-all-entries").checked
|
||||
function pretty_long_id(fpr) {
|
||||
let plid = ""
|
||||
for (let i = 6; i < 10; ++i) {
|
||||
plid += `${fpr.substring(i * 4, (i + 1) * 4)} `
|
||||
}
|
||||
|
||||
return plid.substring(0, plid.length - 1)
|
||||
}
|
||||
function generate_wkd_menu(email, wkd_config, keys) {
|
||||
if (wkd_config.options.length == 0) {
|
||||
// No possible options
|
||||
let menurep = $("#wkd-nokeys-template").html()
|
||||
menurep = $(menurep)
|
||||
menurep.attr("id", `wkd-${email}`)
|
||||
menurep.find("#email").html(email)
|
||||
|
||||
return menurep
|
||||
}
|
||||
|
||||
// else
|
||||
let menurep = $("#wkd-entry-template").html()
|
||||
menurep = $(menurep)
|
||||
menurep.attr("id", `wkd-${email}`)
|
||||
|
||||
menurep.find("#email").html(email)
|
||||
|
||||
let template = menurep.find("#key-sample")
|
||||
wkd_config.options.forEach(key => {
|
||||
let option = template.prop("outerHTML")
|
||||
option = $(option)
|
||||
option.attr("id", `key-${key}`)
|
||||
|
||||
// Basic information
|
||||
let keyinfo = keys[key]
|
||||
let skey = keyinfo.subkeys[0] // Master subkey
|
||||
|
||||
let fpr = option.find("#fingerprint")
|
||||
fpr.find("#fpr").text(pretty_long_id(key))
|
||||
fpr.find("#subkeys").text(` (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`)
|
||||
option.click(() => {
|
||||
$("#wkd_submit").attr("disabled", false)
|
||||
dirty_config[email] = key
|
||||
menurep.find("#current-key").html(option.html())
|
||||
})
|
||||
option.appendTo(menurep.find("#menu"))
|
||||
|
||||
let classType = ""
|
||||
let expire = option.find("#expire")
|
||||
let exp = expire.find(".status-text")
|
||||
|
||||
// Purpose and expiration
|
||||
// For evaluation we're only going to use the master subkey
|
||||
if (keyinfo.revoked) {
|
||||
classType = "status-error"
|
||||
exp.text(`revoked`)
|
||||
} else if (skey.expired) {
|
||||
classType = "status-error"
|
||||
exp.text(`expired on ${skey.expires_date}`)
|
||||
} else if (skey.expires && skey.expires_days <= 14) {
|
||||
classType = "status-warning"
|
||||
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
||||
} else if (skey.expires) {
|
||||
classType = "status-ok"
|
||||
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
||||
} else {
|
||||
classType = "status-ok"
|
||||
exp.text("does not expire")
|
||||
}
|
||||
expire.addClass(classType)
|
||||
fpr.addClass(classType)
|
||||
|
||||
let s = option.find("#sign")
|
||||
let c = option.find("#cert")
|
||||
let e = option.find("#encr")
|
||||
let a = option.find("#auth")
|
||||
|
||||
s.addClass(skey.sign ? classType : "status-na")
|
||||
c.addClass(skey.cert ? classType : "status-na")
|
||||
e.addClass(skey.encr ? classType : "status-na")
|
||||
a.addClass(skey.auth ? classType : "status-na")
|
||||
|
||||
let txt = ""
|
||||
let n_emails = keyinfo.ids_emails.length
|
||||
if (n_emails == 1) {
|
||||
txt = `<b class="font-monospace">${email}</b>`
|
||||
} else if (email.length >= 70) {
|
||||
// Absurd situation, but can happen!
|
||||
// We'll only
|
||||
txt = `<b class="font-monospace">${email}</b> and ${n_emails - 1} other${n_emails == 2 ? "" : "s"}`
|
||||
} else {
|
||||
let buf = 70 - email.length
|
||||
let others = n_emails - 1
|
||||
let sorted_emails = keyinfo.ids_emails.filter((v) => {
|
||||
return v != email
|
||||
})
|
||||
sorted_emails.sort((x, y) => {
|
||||
// Longer emails come first. If there's a tie, use alphabetic order
|
||||
return x.length == y.length ? x.localeCompare(y) : x.length > y.length ? -1 : 1
|
||||
})
|
||||
|
||||
txt = `<b class="font-monospace">${email}</b>`
|
||||
|
||||
// From the remaining emails, pick the longest ones within the buffer constraints
|
||||
// If none, append the "and x others text"
|
||||
sorted_emails.forEach(e => {
|
||||
if (e.length <= buf) {
|
||||
others--
|
||||
buf -= e.length
|
||||
txt += `, <span class="font-monospace">${e}</span>`
|
||||
}
|
||||
});
|
||||
|
||||
if (others > 0) {
|
||||
txt += ` and ${others} other${others == 1 ? "" : "s"}`
|
||||
}
|
||||
}
|
||||
option.find("#key-emails").html(txt)
|
||||
});
|
||||
template.remove()
|
||||
|
||||
let nokeyopt = menurep.find("#key-none")
|
||||
nokeyopt.click(() => {
|
||||
$("#wkd_submit").attr("disabled", false)
|
||||
dirty_config[email] = null
|
||||
menurep.find("#current-key").html(nokeyopt.html())
|
||||
})
|
||||
|
||||
menurep.find("#current-key").html(menurep.find(`#key-${wkd_config.selected ? wkd_config.selected : "none"}`).html())
|
||||
|
||||
return menurep
|
||||
}
|
||||
function generate_wkd_dropdowns(wkd_info) {
|
||||
$("#wkd_tbody").html("")
|
||||
Object.keys(wkd_info.wkd).forEach(domain => {
|
||||
$(`<tr><td colspan="2" class="bg-light"><h4 class="domain-header">${domain}</h4></td></tr>`).appendTo("#wkd_tbody")
|
||||
Object.keys(wkd_info.wkd[domain]).forEach(email => {
|
||||
let menu = generate_wkd_menu(email, wkd_info.wkd[domain][email], wkd_info.keys)
|
||||
menu.appendTo("#wkd_tbody")
|
||||
|
||||
// No possible keys, we can hide this line to save space
|
||||
if (wkd_info.wkd[domain][email].options.length === 0) {
|
||||
toggleable_lines.push(menu)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
toggle_emails_with_no_pgp_key()
|
||||
}
|
||||
function show_wkd() {
|
||||
toggleable_lines = []
|
||||
$("#wkd_tbody").html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||
api(
|
||||
"/system/pgp/wkd",
|
||||
"GET",
|
||||
{},
|
||||
(info) => {
|
||||
$("#wkd_submit").attr("disabled", true)
|
||||
dirty_config = {}
|
||||
Object.keys(info.wkd).forEach(domain => {
|
||||
Object.keys(info.wkd[domain]).forEach(email => {
|
||||
dirty_config[email] = info.wkd[domain][email].selected
|
||||
})
|
||||
})
|
||||
generate_wkd_dropdowns(info)
|
||||
}
|
||||
)
|
||||
}
|
||||
function update_wkd() {
|
||||
api(
|
||||
"/system/pgp/wkd",
|
||||
"POST",
|
||||
dirty_config,
|
||||
(r) => {
|
||||
show_modal_error("WKD Management", $("<p/>").text(r), () => { if (r == "OK") show_wkd() })
|
||||
},
|
||||
(r) => {
|
||||
show_modal_error("WKD Management", $("<p/>").text(r))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function toggle_emails_with_no_pgp_key() {
|
||||
let show_toggleable_lines = document.getElementById("wkd-show-all-entries").checked
|
||||
|
||||
toggleable_lines.forEach(line => {
|
||||
line.toggle(show_toggleable_lines)
|
||||
});
|
||||
}
|
||||
</script>
|
|
@ -1,171 +1,234 @@
|
|||
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():
|
||||
# Load settings from /etc/mailinabox.conf.
|
||||
return load_env_vars_from_file("/etc/mailinabox.conf")
|
||||
# Load settings from /etc/mailinabox.conf.
|
||||
return load_env_vars_from_file("/etc/mailinabox.conf")
|
||||
|
||||
|
||||
def load_env_vars_from_file(fn):
|
||||
# Load settings from a KEY=VALUE file.
|
||||
import collections
|
||||
env = collections.OrderedDict()
|
||||
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
|
||||
return env
|
||||
# Load settings from a KEY=VALUE file.
|
||||
import collections
|
||||
env = collections.OrderedDict()
|
||||
for line in open(fn):
|
||||
env.setdefault(*line.strip().split("=", 1))
|
||||
return env
|
||||
|
||||
|
||||
def save_environment(env):
|
||||
with open("/etc/mailinabox.conf", "w") as f:
|
||||
for k, v in env.items():
|
||||
f.write("%s=%s\n" % (k, v))
|
||||
with open("/etc/mailinabox.conf", "w") as f:
|
||||
for k, v in env.items():
|
||||
f.write("%s=%s\n" % (k, v))
|
||||
|
||||
|
||||
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
||||
|
||||
|
||||
def write_settings(config, env):
|
||||
import rtyaml
|
||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||
with open(fn, "w") as f:
|
||||
f.write(rtyaml.dump(config))
|
||||
|
||||
|
||||
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):
|
||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||
import urllib.parse
|
||||
return urllib.parse.quote(name, safe='')
|
||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||
import urllib.parse
|
||||
return urllib.parse.quote(name, safe='')
|
||||
|
||||
def unsafe_domain_name(name_encoded):
|
||||
import urllib.parse
|
||||
return urllib.parse.unquote(name_encoded)
|
||||
|
||||
def sort_domains(domain_names, env):
|
||||
# Put domain names in a nice sorted order. For web_update, PRIMARY_HOSTNAME
|
||||
# must appear first so it becomes the nginx default server.
|
||||
|
||||
# First group PRIMARY_HOSTNAME and its subdomains, then parent domains of PRIMARY_HOSTNAME, then other domains.
|
||||
groups = ( [], [], [] )
|
||||
for d in domain_names:
|
||||
if d == env['PRIMARY_HOSTNAME'] or d.endswith("." + env['PRIMARY_HOSTNAME']):
|
||||
groups[0].append(d)
|
||||
elif env['PRIMARY_HOSTNAME'].endswith("." + d):
|
||||
groups[1].append(d)
|
||||
else:
|
||||
groups[2].append(d)
|
||||
# Put domain names in a nice sorted order.
|
||||
|
||||
# Within each group, sort parent domains before subdomains and after that sort lexicographically.
|
||||
def sort_group(group):
|
||||
# Find the top-most domains.
|
||||
top_domains = sorted(d for d in group if len([s for s in group if d.endswith("." + s)]) == 0)
|
||||
ret = []
|
||||
for d in top_domains:
|
||||
ret.append(d)
|
||||
ret.extend( sort_group([s for s in group if s.endswith("." + d)]) )
|
||||
return ret
|
||||
|
||||
groups = [sort_group(g) for g in groups]
|
||||
# The nice order will group domain names by DNS zone, i.e. the top-most
|
||||
# domain name that we serve that ecompasses a set of subdomains. Map
|
||||
# each of the domain names to the zone that contains them. Walk the domains
|
||||
# from shortest to longest since zones are always shorter than their
|
||||
# subdomains.
|
||||
zones = {}
|
||||
for domain in sorted(domain_names, key=lambda d: len(d)):
|
||||
for z in zones.values():
|
||||
if domain.endswith("." + z):
|
||||
# We found a parent domain already in the list.
|
||||
zones[domain] = z
|
||||
break
|
||||
else:
|
||||
# 'break' did not occur: there is no parent domain, so it is its
|
||||
# own zone.
|
||||
zones[domain] = domain
|
||||
|
||||
# Sort the zones.
|
||||
zone_domains = sorted(
|
||||
zones.values(),
|
||||
key=lambda d: (
|
||||
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
||||
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].
|
||||
endswith("." + d)),
|
||||
|
||||
# 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
|
||||
|
||||
return groups[0] + groups[1] + groups[2]
|
||||
|
||||
def sort_email_addresses(email_addresses, env):
|
||||
email_addresses = set(email_addresses)
|
||||
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||
ret = []
|
||||
for domain in sort_domains(domains, env):
|
||||
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain))
|
||||
ret.extend(sorted(domain_emails))
|
||||
email_addresses -= domain_emails
|
||||
ret.extend(sorted(email_addresses)) # whatever is left
|
||||
return ret
|
||||
|
||||
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)
|
||||
email_addresses = set(email_addresses)
|
||||
domains = set(
|
||||
email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||
ret = []
|
||||
for domain in sort_domains(domains, env):
|
||||
domain_emails = set(email for email in email_addresses
|
||||
if email.endswith("@" + domain))
|
||||
ret.extend(sorted(domain_emails))
|
||||
email_addresses -= domain_emails
|
||||
ret.extend(sorted(email_addresses)) # whatever is left
|
||||
return ret
|
||||
|
||||
|
||||
def 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):
|
||||
# A safe way to execute processes.
|
||||
# Some processes like apt-get require being given a sane PATH.
|
||||
import subprocess
|
||||
|
||||
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
|
||||
# A safe way to execute processes.
|
||||
# Some processes like apt-get require being given a sane PATH.
|
||||
import subprocess
|
||||
env.update({"PATH": "/sbin:/bin:/usr/sbin:/usr/bin"})
|
||||
kwargs = {
|
||||
'env': env,
|
||||
'stderr': None if not capture_stderr else subprocess.STDOUT,
|
||||
}
|
||||
if method == "check_output" and input is not None:
|
||||
kwargs['input'] = input
|
||||
|
||||
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
|
||||
kwargs = {
|
||||
'env': env,
|
||||
'stderr': None if not capture_stderr else subprocess.STDOUT,
|
||||
}
|
||||
if method == "check_output" and input is not None:
|
||||
kwargs['input'] = input
|
||||
if not trap:
|
||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||
else:
|
||||
try:
|
||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||
code = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
ret = e.output
|
||||
code = e.returncode
|
||||
if not return_bytes and isinstance(ret, bytes):
|
||||
ret = ret.decode("utf8")
|
||||
if not trap:
|
||||
return ret
|
||||
else:
|
||||
return code, ret
|
||||
|
||||
if not trap:
|
||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||
else:
|
||||
try:
|
||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||
code = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
ret = e.output
|
||||
code = e.returncode
|
||||
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
|
||||
if not trap:
|
||||
return ret
|
||||
else:
|
||||
return code, ret
|
||||
|
||||
def create_syslog_handler():
|
||||
import logging.handlers
|
||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||
handler.setLevel(logging.WARNING)
|
||||
return handler
|
||||
import logging.handlers
|
||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||
handler.setLevel(logging.WARNING)
|
||||
return handler
|
||||
|
||||
|
||||
def du(path):
|
||||
# Computes the size of all files in the path, like the `du` command.
|
||||
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
||||
# soft and hard links.
|
||||
total_size = 0
|
||||
seen = set()
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
try:
|
||||
stat = os.lstat(fp)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.st_ino in seen:
|
||||
continue
|
||||
seen.add(stat.st_ino)
|
||||
total_size += stat.st_size
|
||||
return total_size
|
||||
|
||||
|
||||
def wait_for_service(port, public, env, timeout):
|
||||
# Block until a service on a given port (bound privately or publicly)
|
||||
# is taking connections, with a maximum timeout.
|
||||
import socket
|
||||
import time
|
||||
start = time.perf_counter()
|
||||
while True:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout / 3)
|
||||
try:
|
||||
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
|
||||
return True
|
||||
except OSError:
|
||||
if time.perf_counter() > start + timeout:
|
||||
return False
|
||||
time.sleep(min(timeout / 4, 1))
|
||||
|
||||
def get_php_version():
|
||||
# Gets the version of PHP installed in the system.
|
||||
return shell("check_output", ["/usr/bin/php", "-v"])[4:7]
|
||||
|
||||
def get_os_code():
|
||||
# Massive mess incoming
|
||||
dist = shell("check_output", ["/usr/bin/lsb_release", "-is"]).strip()
|
||||
version = shell("check_output", ["/usr/bin/lsb_release", "-rs"]).strip()
|
||||
|
||||
if dist == "Debian":
|
||||
if version == "10":
|
||||
return "Debian10"
|
||||
elif version == "11":
|
||||
return "Debian11"
|
||||
elif dist == "Ubuntu":
|
||||
if version == "20.04":
|
||||
return "Ubuntu2004"
|
||||
elif version == "22.04":
|
||||
return "Ubuntu2204"
|
||||
|
||||
return None
|
||||
|
||||
if __name__ == "__main__":
|
||||
from web_update import get_web_domains
|
||||
env = load_environment()
|
||||
domains = get_web_domains(env)
|
||||
for domain in domains:
|
||||
print(domain)
|
||||
|
|
|
@ -2,51 +2,205 @@
|
|||
# domains for which a mail account has been set up.
|
||||
########################################################################
|
||||
|
||||
import os, os.path, re, rtyaml
|
||||
import os.path
|
||||
import re
|
||||
import rtyaml
|
||||
|
||||
from mailconfig import get_mail_domains
|
||||
from dns_update import get_custom_dns_config
|
||||
from utils import shell, safe_domain_name, sort_domains
|
||||
from dns_update import get_custom_dns_config, get_dns_zones
|
||||
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
|
||||
from utils import shell, safe_domain_name, sort_domains, get_php_version
|
||||
|
||||
def get_web_domains(env):
|
||||
# What domains should we serve websites for?
|
||||
|
||||
def get_web_domains(env,
|
||||
include_www_redirects=True,
|
||||
include_auto=True,
|
||||
exclude_dns_elsewhere=True):
|
||||
# What domains should we serve HTTP(S) for?
|
||||
domains = set()
|
||||
|
||||
# At the least it's the PRIMARY_HOSTNAME so we can serve webmail
|
||||
# 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 Webfinger and ActiveSync auto-discover of email settings
|
||||
# (though the latter isn't really working). These will require that
|
||||
# an SSL cert be installed.
|
||||
# Serve web for all mail domains so that we might at least
|
||||
# provide auto-discover of email settings, and also a static website
|
||||
# if the user wants to make one.
|
||||
domains |= get_mail_domains(env)
|
||||
|
||||
# ...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.
|
||||
dns = get_custom_dns_config(env)
|
||||
for domain, value in dns.items():
|
||||
if domain not in domains: continue
|
||||
if (isinstance(value, str) and (value != "local")) \
|
||||
or (isinstance(value, dict) and ("A" in value) and (value["A"] != "local")) \
|
||||
or (isinstance(value, dict) and ("AAAA" in value) and (value["AAAA"] != "local")):
|
||||
domains.remove(domain)
|
||||
if include_www_redirects and include_auto:
|
||||
# Add 'www.' subdomains that we want to provide default redirects
|
||||
# to the main domain for. We'll add 'www.' to any DNS zones, i.e.
|
||||
# the topmost of each domain we serve.
|
||||
domains |= set('www.' + zone for zone, zonefile in get_dns_zones(env))
|
||||
|
||||
# Sort the list. Put PRIMARY_HOSTNAME first so it becomes the
|
||||
# default server (nginx's default_server).
|
||||
if include_auto:
|
||||
# Add Autoconfiguration domains for domains that there are user accounts at:
|
||||
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
|
||||
domains |= set(
|
||||
'autoconfig.' + maildomain
|
||||
for maildomain in get_mail_domains(env, users_only=True))
|
||||
domains |= set(
|
||||
'autodiscover.' + maildomain
|
||||
for maildomain in get_mail_domains(env, users_only=True))
|
||||
|
||||
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
|
||||
domains |= set('mta-sts.' + maildomain
|
||||
for maildomain in get_mail_domains(env))
|
||||
|
||||
# 'openpgpkey.' for WKD support
|
||||
domains |= set('openpgpkey.' + maildomain
|
||||
for maildomain in get_mail_domains(env))
|
||||
|
||||
if exclude_dns_elsewhere:
|
||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||
# 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)
|
||||
|
||||
return domains
|
||||
|
||||
def do_web_update(env):
|
||||
# Build an nginx configuration file.
|
||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||
|
||||
# Add configuration for each web domain.
|
||||
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
||||
for domain in get_web_domains(env):
|
||||
nginx_conf += make_domain_config(domain, template1, template2, env)
|
||||
|
||||
def get_domains_with_a_records(env):
|
||||
domains = set()
|
||||
dns = get_custom_dns_config(env)
|
||||
for domain, rtype, value, ttl in dns:
|
||||
if rtype == "CNAME" or (rtype in ("A", "AAAA")
|
||||
and value not in ("local", env['PUBLIC_IP'])):
|
||||
domains.add(domain)
|
||||
return domains
|
||||
|
||||
|
||||
def get_web_domains_with_root_overrides(env):
|
||||
# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
||||
# which means static hosting is not happening.
|
||||
root_overrides = {}
|
||||
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
|
||||
|
||||
|
||||
DOMAIN_EXTERNAL = -1
|
||||
DOMAIN_PRIMARY = 1
|
||||
DOMAIN_WWW = 2
|
||||
DOMAIN_REDIRECT = 4
|
||||
DOMAIN_WKD = 8
|
||||
|
||||
|
||||
def get_web_domain_flags(env):
|
||||
flags = dict()
|
||||
zones = get_dns_zones(env)
|
||||
email_domains = get_mail_domains(env)
|
||||
user_domains = get_mail_domains(env, users_only=True)
|
||||
external = get_domains_with_a_records(env)
|
||||
redirects = get_web_domains_with_root_overrides(env)
|
||||
|
||||
for d in email_domains:
|
||||
flags[d] = flags.get(d, 0)
|
||||
flags[f"mta-sts.{d}"] = flags.get(d, 0)
|
||||
flags[f"openpgpkey.{d}"] = flags.get(d, 0) | DOMAIN_WKD
|
||||
|
||||
for d in user_domains:
|
||||
flags[f"autoconfig.{d}"] = flags.get(d, 0)
|
||||
flags[f"autodiscover.{d}"] = flags.get(d, 0)
|
||||
|
||||
for d, _ in zones:
|
||||
flags[f"www.{d}"] = flags.get(d, 0) | DOMAIN_WWW
|
||||
|
||||
for d in redirects:
|
||||
flags[d] = flags.get(d, 0) | DOMAIN_REDIRECT
|
||||
|
||||
flags[env["PRIMARY_HOSTNAME"]] |= DOMAIN_PRIMARY
|
||||
|
||||
# Last check for websites hosted elsewhere
|
||||
for d in flags.keys():
|
||||
if d in external:
|
||||
# -1 = All bits set to 1, assuming twos-complement
|
||||
flags[d] = DOMAIN_EXTERNAL
|
||||
return flags
|
||||
|
||||
|
||||
def do_web_update(env):
|
||||
# Pre-load what SSL certificates we will use for each domain.
|
||||
ssl_certificates = get_ssl_certificates(env)
|
||||
|
||||
# Build an nginx configuration file.
|
||||
nginx_conf = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-top.conf")).read()
|
||||
nginx_conf = re.sub("{{phpver}}", get_php_version(), nginx_conf)
|
||||
|
||||
# Add upstream additions
|
||||
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www",
|
||||
".upstream.conf")
|
||||
if not os.path.exists(nginx_upstream_include):
|
||||
with open(nginx_upstream_include, "a+") as f:
|
||||
f.writelines([
|
||||
f"# Add your nginx-wide configurations here.\n",
|
||||
"# The following names are already defined:\n\n",
|
||||
"# # php-default: The php socket used for apps managed by the box. (Roundcube, Z-Push, Nextcloud, etc.) - DO NOT USE!\n",
|
||||
"# # php-fpm: A php socket not managed by the box. Feel free to use it for your PHP applications\n"
|
||||
])
|
||||
|
||||
nginx_conf += "\ninclude %s;\n" % (nginx_upstream_include)
|
||||
|
||||
# Load the templates.
|
||||
template0 = open(
|
||||
os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
template1 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-alldomains.conf")).read()
|
||||
template2 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-primaryonly.conf")).read()
|
||||
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||
template4 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-openpgpkey.conf")).read()
|
||||
|
||||
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'],
|
||||
[template0, template1, template2],
|
||||
ssl_certificates, env)
|
||||
|
||||
# Add configuration all other web domains.
|
||||
pairs = list(get_web_domain_flags(env).items())
|
||||
|
||||
# Sort the domains in some way to keep ordering consistency. Keep domains and subdomains together.
|
||||
pairs.sort(reverse=False, key=lambda x: x[0][::-1])
|
||||
for domain, flags in pairs:
|
||||
if flags & DOMAIN_PRIMARY == DOMAIN_PRIMARY or flags == DOMAIN_EXTERNAL:
|
||||
# PRIMARY_HOSTNAME is handled above.
|
||||
continue
|
||||
if flags & DOMAIN_WWW == 0:
|
||||
# This is a regular domain.
|
||||
if flags & DOMAIN_WKD == DOMAIN_WKD:
|
||||
nginx_conf += make_domain_config(
|
||||
domain, [template0, template1, template4],
|
||||
ssl_certificates, env)
|
||||
elif flags & DOMAIN_REDIRECT == 0:
|
||||
nginx_conf += make_domain_config(domain,
|
||||
[template0, template1],
|
||||
ssl_certificates, env)
|
||||
else:
|
||||
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.
|
||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||
|
@ -67,129 +221,179 @@ def do_web_update(env):
|
|||
|
||||
return "web updated\n"
|
||||
|
||||
def make_domain_config(domain, template, template_for_primaryhost, env):
|
||||
# How will we configure this domain.
|
||||
|
||||
def make_domain_config(domain, templates, ssl_certificates, env):
|
||||
# GET SOME VARIABLES
|
||||
|
||||
# Where will its root directory be for static files?
|
||||
|
||||
root = get_web_root(domain, env)
|
||||
|
||||
# What private key and SSL certificate will we use for this domain?
|
||||
ssl_key, ssl_certificate, csr_path = 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
|
||||
# available. Make a self-signed one now if one doesn't exist.
|
||||
ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, env)
|
||||
# ADDITIONAL DIRECTIVES.
|
||||
|
||||
# Put pieces together.
|
||||
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"
|
||||
nginx_conf_extra = ""
|
||||
|
||||
# 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
|
||||
# can trigger an nginx update.
|
||||
def hashfile(filepath):
|
||||
import hashlib
|
||||
sha1 = hashlib.sha1()
|
||||
f = open(filepath, 'rb')
|
||||
try:
|
||||
sha1.update(f.read())
|
||||
finally:
|
||||
f.close()
|
||||
return sha1.hexdigest()
|
||||
|
||||
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(
|
||||
tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||
|
||||
# Add in any user customizations in YAML format.
|
||||
hsts = "yes"
|
||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||
if os.path.exists(nginx_conf_custom_fn):
|
||||
yaml = rtyaml.load(open(nginx_conf_custom_fn))
|
||||
if domain in yaml:
|
||||
yaml = yaml[domain]
|
||||
|
||||
# any proxy or redirect here?
|
||||
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():
|
||||
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.
|
||||
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||
if os.path.exists(nginx_conf_custom_include):
|
||||
nginx_conf += "\tinclude %s;\n" % (nginx_conf_custom_include)
|
||||
nginx_conf_custom_include = os.path.join(
|
||||
env["STORAGE_ROOT"], "www",
|
||||
safe_domain_name(domain) + ".conf")
|
||||
if not os.path.exists(nginx_conf_custom_include):
|
||||
with open(nginx_conf_custom_include, "a+") as f:
|
||||
f.writelines([
|
||||
f"# Custom configurations for {domain} go here\n",
|
||||
"# To use php: use the \"php-fpm\" alias\n\n",
|
||||
"index index.html index.htm;\n"
|
||||
])
|
||||
|
||||
# Ending.
|
||||
nginx_conf += nginx_conf_parts[1]
|
||||
nginx_conf_extra += "\tinclude %s;\n" % (nginx_conf_custom_include)
|
||||
|
||||
# PUT IT ALL TOGETHER
|
||||
|
||||
# Combine the pieces. Iteratively place each template into the "# ADDITIONAL DIRECTIVES HERE" placeholder
|
||||
# of the previous template.
|
||||
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
|
||||
for t in templates + [nginx_conf_extra]:
|
||||
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t,
|
||||
nginx_conf)
|
||||
|
||||
# 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
|
||||
|
||||
def get_web_root(domain, env):
|
||||
|
||||
def get_web_root(domain, env, test_exists=True):
|
||||
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
||||
for test_domain in (domain, 'default'):
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
||||
if os.path.exists(root): break
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www",
|
||||
safe_domain_name(test_domain))
|
||||
if os.path.exists(root) or not test_exists:
|
||||
break
|
||||
return root
|
||||
|
||||
def get_domain_ssl_files(domain, env):
|
||||
# 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')
|
||||
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))
|
||||
def is_default_web_root(domain, env):
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain))
|
||||
return not os.path.exists(root)
|
||||
|
||||
# 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:
|
||||
from status_checks import check_certificate
|
||||
if check_certificate(domain, ssl_certificate_primary, None) == "OK":
|
||||
ssl_certificate = ssl_certificate_primary
|
||||
|
||||
# Where would the CSR go? As with the SSL cert itself, the CSR must be
|
||||
# different for each domain name.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/ssl_cert_sign_req.csr')
|
||||
else:
|
||||
csr_path = os.path.join(env["STORAGE_ROOT"], 'ssl/%s/certificate_signing_request.csr' % safe_domain_name(domain))
|
||||
def get_web_domains_info(env):
|
||||
www_redirects = set(get_web_domains(env)) - \
|
||||
set(get_web_domains(env, include_www_redirects=False))
|
||||
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))
|
||||
ssl_certificates = get_ssl_certificates(env)
|
||||
|
||||
return ssl_key, ssl_certificate, csr_path
|
||||
|
||||
def ensure_ssl_certificate_exists(domain, ssl_key, ssl_certificate, csr_path, 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.
|
||||
shell("check_call", [
|
||||
"openssl", "req", "-new",
|
||||
"-key", ssl_key,
|
||||
"-out", csr_path,
|
||||
"-sha256",
|
||||
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)])
|
||||
|
||||
# And then make the certificate.
|
||||
shell("check_call", [
|
||||
"openssl", "x509", "-req",
|
||||
"-days", "365",
|
||||
"-in", csr_path,
|
||||
"-signkey", ssl_key,
|
||||
"-out", ssl_certificate])
|
||||
# for the SSL config panel, get cert status
|
||||
def check_cert(domain):
|
||||
try:
|
||||
tls_cert = get_domain_ssl_files(domain,
|
||||
ssl_certificates,
|
||||
env,
|
||||
allow_missing_cert=True)
|
||||
except OSError: # PRIMARY_HOSTNAME cert is missing
|
||||
tls_cert = None
|
||||
if tls_cert is None:
|
||||
return ("danger", "No certificate installed.")
|
||||
cert_status, cert_status_details = check_certificate(
|
||||
domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||
if cert_status == "OK":
|
||||
return ("success", "Signed & valid. " + cert_status_details)
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
return ("warning",
|
||||
"Self-signed. Get a signed certificate to stop warnings.")
|
||||
else:
|
||||
return ("danger", "Certificate has a problem: " + cert_status)
|
||||
|
||||
return [{
|
||||
"domain":
|
||||
domain,
|
||||
"root":
|
||||
get_web_root(domain, env),
|
||||
"custom_root":
|
||||
get_web_root(domain, env, test_exists=False),
|
||||
"ssl_certificate":
|
||||
check_cert(domain),
|
||||
"static_enabled":
|
||||
domain not in (www_redirects | has_root_proxy_or_redirect),
|
||||
} for domain in get_web_domains(env)]
|
||||
|
|
254
management/wkd.py
Normal file
254
management/wkd.py
Normal file
|
@ -0,0 +1,254 @@
|
|||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
# WDK (Web Key Directory) Manager: Facilitates discovery of keys by third-parties
|
||||
# Current relevant documents: https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html
|
||||
|
||||
import pgp
|
||||
import utils
|
||||
import rtyaml
|
||||
import mailconfig
|
||||
import copy
|
||||
import shutil
|
||||
import os
|
||||
import re
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
env = utils.load_environment()
|
||||
|
||||
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
|
||||
|
||||
|
||||
class WKDError(Exception):
|
||||
"""
|
||||
Errors specifically related to WKD.
|
||||
"""
|
||||
|
||||
def __init__(self, msg):
|
||||
self.message = msg
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
def sha1(message):
|
||||
h = hashes.Hash(hashes.SHA1(), default_backend())
|
||||
h.update(message)
|
||||
return h.finalize()
|
||||
|
||||
|
||||
def zbase32(digest):
|
||||
# Crudely check if all quintets are complete
|
||||
if len(digest) % 5 != 0:
|
||||
raise ValueError("Digest cannot have incomplete chunks of 40 bits!")
|
||||
base = "ybndrfg8ejkmcpqxot1uwisza345h769"
|
||||
encoded = ""
|
||||
for i in range(0, len(digest), 5):
|
||||
chunk = int.from_bytes(digest[i:i + 5], byteorder="big")
|
||||
for j in range(35, -5, -5):
|
||||
encoded += base[(chunk >> j) & 31]
|
||||
return encoded
|
||||
|
||||
|
||||
# Strips and exports a key so that only the UID's with the provided email remain.
|
||||
# This is to comply with the following requirement, set forth in section 5 of the draft:
|
||||
#
|
||||
# The mail provider MUST make sure to publish a key in a way
|
||||
# that only the mail address belonging to the requested user
|
||||
# is part of the User ID packets included in the returned key.
|
||||
# Other User ID packets and their associated binding signatures
|
||||
# MUST be removed before publication.
|
||||
#
|
||||
# The arguments "buffer" and "context" are automatically added
|
||||
# by the pgp.fork_context() decorator.
|
||||
@pgp.fork_context
|
||||
def strip_and_export(fpr, target_email, buffer=None, context=None):
|
||||
context.armor = False # We need to disable armor output for this key
|
||||
k = pgp.get_key(fpr, context)
|
||||
if k is None:
|
||||
return None
|
||||
|
||||
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
|
||||
statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
|
||||
|
||||
def parse_key_dump(dump):
|
||||
UID_REGEX = r".*:.* <(.*)>:.*:([0-9]),.*"
|
||||
at_least_one_not_deleted = False
|
||||
lines = dump.decode().split("\n")
|
||||
|
||||
for line in lines:
|
||||
if line[0:3] != "uid":
|
||||
continue
|
||||
# It's a uid, find the email and the "tag"
|
||||
m = re.search(UID_REGEX, line)
|
||||
|
||||
if m.group(1) != target_email:
|
||||
statusref["sequence"].append(f"uid {m.group(2)}")
|
||||
else:
|
||||
at_least_one_not_deleted = True
|
||||
|
||||
if not at_least_one_not_deleted:
|
||||
raise WKDError("All UID's in this key would have been deleted!")
|
||||
|
||||
statusref["sequence"] += ["deluid", "save"]
|
||||
statusref["seq_read"] = True
|
||||
|
||||
def interaction(request, prompt):
|
||||
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
|
||||
return 0
|
||||
elif request == "GET_BOOL":
|
||||
# No way to confirm interactively, so we just say yes
|
||||
return "y" # Yeah, I'd also rather just return True but that doesn't work
|
||||
elif request == "GET_LINE" and prompt == "keyedit.prompt":
|
||||
if not statusref["seq_read"]:
|
||||
buffer.seek(0, os.SEEK_SET)
|
||||
parse_key_dump(buffer.read())
|
||||
|
||||
statusref["seq_number"] += 1
|
||||
seqnum = statusref["seq_number"]
|
||||
return statusref["sequence"][seqnum]
|
||||
else:
|
||||
raise Exception("No idea of what to do!")
|
||||
|
||||
buffer.seek(0, os.SEEK_SET)
|
||||
buffer.write(b'')
|
||||
context.interact(k, interaction, sink=buffer)
|
||||
return pgp.export_key(fpr, context)
|
||||
|
||||
|
||||
def email_compatible_with_key(email, fingerprint):
|
||||
# 1. Does the user exist?
|
||||
if not email in mailconfig.get_all_mail_addresses(env):
|
||||
raise ValueError(f"User or alias {email} not found!")
|
||||
|
||||
if fingerprint is not None:
|
||||
key = pgp.get_key(fingerprint)
|
||||
# 2. Does the key exist?
|
||||
if key is None:
|
||||
raise ValueError(f"The key \"{fingerprint}\" does not exist!")
|
||||
|
||||
# 3. Does the key have a user id with the email of the user?
|
||||
if email not in [u.email for u in key.uids]:
|
||||
raise WKDError(
|
||||
f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!"
|
||||
)
|
||||
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Gets a table with all the keys that can be served for each user and/or alias
|
||||
|
||||
|
||||
def get_user_fpr_maps():
|
||||
uk_maps = {}
|
||||
for email in mailconfig.get_all_mail_addresses(env):
|
||||
uk_maps[email] = set()
|
||||
for key in pgp.get_imported_keys() + [pgp.get_daemon_key()]:
|
||||
for userid in key.uids:
|
||||
try:
|
||||
uk_maps[userid.email].add(key.fpr)
|
||||
except:
|
||||
# We don't host this email address, so ignore
|
||||
pass
|
||||
return uk_maps
|
||||
|
||||
|
||||
# Gets the current WKD configuration
|
||||
|
||||
|
||||
def get_wkd_config():
|
||||
# Test
|
||||
try:
|
||||
with open(wkdpath, "x"):
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
with open(wkdpath, "r") as wkdfile:
|
||||
try:
|
||||
config = rtyaml.load(wkdfile)
|
||||
if (type(config) != dict):
|
||||
return {}
|
||||
return config
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
|
||||
# email: An user or alias on this box. e.g. "administrator@example.com"
|
||||
# fingerprint: The fingerprint of the key we want to bind it to. e.g "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
|
||||
|
||||
def update_wkd_config(config_sample):
|
||||
config = dict(config_sample)
|
||||
for email, fingerprint in config_sample.items():
|
||||
try:
|
||||
if fingerprint is None or fingerprint == "":
|
||||
config.pop(email)
|
||||
else:
|
||||
email_compatible_with_key(email, fingerprint)
|
||||
except Exception as err:
|
||||
raise err
|
||||
|
||||
# All conditions met, do the necessary modifications
|
||||
with open(wkdpath, "w") as wkdfile:
|
||||
wkdfile.write(rtyaml.dump(config))
|
||||
|
||||
|
||||
# Looks for incompatible email/key pairs on the WKD configuration file
|
||||
# and returns the uid indexes for compatible email/key pairs
|
||||
|
||||
|
||||
def parse_wkd_list():
|
||||
removed = []
|
||||
uidlist = []
|
||||
with open(wkdpath, "a+") as wkdfile:
|
||||
wkdfile.seek(0)
|
||||
config = {}
|
||||
try:
|
||||
config = rtyaml.load(wkdfile)
|
||||
if (type(config) != dict):
|
||||
config = {}
|
||||
except:
|
||||
config = {}
|
||||
|
||||
writeable = copy.deepcopy(config)
|
||||
for u, k in config.items():
|
||||
try:
|
||||
key = email_compatible_with_key(u, k)
|
||||
# Key is compatible
|
||||
|
||||
# Swap with the full-length fingerprint (if somehow this was changed by hand)
|
||||
writeable[u] = key.fpr
|
||||
uidlist.append((u, key.fpr))
|
||||
except:
|
||||
writeable.pop(u)
|
||||
removed.append((u, k))
|
||||
# Shove the updated configuration back in the file
|
||||
wkdfile.truncate(0)
|
||||
wkdfile.write(rtyaml.dump(writeable))
|
||||
return (removed, uidlist)
|
||||
|
||||
|
||||
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
|
||||
|
||||
|
||||
def build_wkd():
|
||||
# Clean everything
|
||||
try:
|
||||
shutil.rmtree(WKD_LOCATION)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.mkdir(WKD_LOCATION, mode=0o755)
|
||||
|
||||
# We serve WKD for all our emails and aliases (even if there are no keys)
|
||||
for domain in mailconfig.get_mail_domains(env, users_only=False):
|
||||
os.mkdir(f"{WKD_LOCATION}/{domain}/", mode=0o755)
|
||||
|
||||
for email, fpr in parse_wkd_list()[1]:
|
||||
local, domain = email.split("@", 1)
|
||||
localhash = zbase32(sha1(local.lower().encode()))
|
||||
with open(f"{WKD_LOCATION}/{domain}/{localhash}", "wb") as k:
|
||||
k.write(strip_and_export(fpr, email))
|
7
management/wsgi.py
Normal file
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)
|
119
security.md
Normal file
119
security.md
Normal file
|
@ -0,0 +1,119 @@
|
|||
Mail-in-a-Box Security Guide
|
||||
============================
|
||||
|
||||
Mail-in-a-Box turns a fresh Ubuntu 18.04 LTS 64-bit machine into a mail server appliance by installing and configuring various components.
|
||||
|
||||
This page documents the security 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
|
||||
------------
|
||||
|
||||
Nothing is perfectly secure, and an adversary with sufficient resources can always penetrate a system.
|
||||
|
||||
The primary goal of Mail-in-a-Box is to make deploying a good mail server easy, so we balance ― as everyone does ― privacy and security concerns with the practicality of actually deploying the system. That means we make certain assumptions about adversaries. We assume that adversaries . . .
|
||||
|
||||
* Do not have physical access to the box (i.e., we do not aim to protect the box from physical access).
|
||||
* Have not been given Unix accounts on the box (i.e., we assume all users with shell access are trusted).
|
||||
|
||||
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, with modern TLS settings.
|
||||
* Outbound mail is sent with the highest level of TLS possible.
|
||||
* The box advertises its support for [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities), when DNSSEC is enabled at the domain name registrar, so that inbound mail is more likely to be transmitted securely.
|
||||
|
||||
Additional details follow.
|
||||
|
||||
User Credentials
|
||||
----------------
|
||||
|
||||
The box's administrator and its (non-administrative) mail users must sometimes communicate their credentials to the box.
|
||||
|
||||
### Services behind TLS
|
||||
|
||||
These services are protected by [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security):
|
||||
|
||||
* SMTP Submission (ports 465/587). Mail users submit outbound mail through SMTP with TLS (port 465) or STARTTLS (port 587).
|
||||
* IMAP/POP (ports 993, 995). Mail users check for incoming mail through IMAP or POP over TLS.
|
||||
* HTTPS (port 443). Webmail, the Exchange/ActiveSync protocol, the administrative control panel, and any static hosted websites are accessed over HTTPS.
|
||||
|
||||
The services all follow these rules:
|
||||
|
||||
* 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.2+ are offered (the older SSL protocols 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).
|
||||
|
||||
Additionally:
|
||||
|
||||
* SMTP Submission on port 587 will not accept user credentials without STARTTLS (true also of SMTP on port 25 in case of client misconfiguration), and the submission port won't accept mail without encryption. The minimum cipher key length is 128 bits. (The box is of course configured not to be an open relay. User credentials are required to send outbound mail.) ([source](setup/mail-postfix.sh))
|
||||
* HTTPS (port 443): The HTTPS Strict Transport Security header is set. A redirect from HTTP to HTTPS is offered. The [Qualys SSL Labs test](https://www.ssllabs.com/ssltest) should report an A+ grade. ([source 1](conf/nginx-ssl.conf), [source 2](conf/nginx.conf))
|
||||
|
||||
### Password Storage
|
||||
|
||||
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py)) Password changes (as well as changes to control panel two-factor authentication settings) expire any control panel login sessions.
|
||||
|
||||
### Console access
|
||||
|
||||
Console access (e.g. via SSH) is configured by the system image used to create the box, typically from by a cloud virtual machine provider (e.g. Digital Ocean). Mail-in-a-Box does not set any console access settings, although it will warn the administrator in the System Status Checks if password-based login is turned on.
|
||||
|
||||
The [setup guide video](https://mailinabox.email/) explains how to verify the host key fingerprint on first login.
|
||||
|
||||
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
|
||||
-------------
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
-------------
|
||||
|
||||
### Encryption Settings
|
||||
|
||||
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
|
||||
|
||||
When DNSSEC is enabled at the box's domain name's registrar, [DANE TLSA](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities) records are automatically published in DNS. Senders supporting DANE will enforce encryption on-the-wire between them and the box --- see the section on DANE for outgoing mail above. ([source](management/dns_update.py))
|
||||
|
||||
### Filters
|
||||
|
||||
Incoming mail is run through several filters. Email is bounced if the sender's IP address is listed in the [Spamhaus Zen blacklist](http://www.spamhaus.org/zen/) or if the sender's domain is listed in the [Spamhaus Domain Block List](http://www.spamhaus.org/dbl/). Greylisting (with [postgrey](http://postgrey.schweikert.ch/)) is also used to cut down on spam. ([source](setup/mail-postfix.sh))
|
116
setup/bootstrap.sh
Executable file → Normal file
116
setup/bootstrap.sh
Executable file → Normal file
|
@ -2,41 +2,109 @@
|
|||
#########################################################
|
||||
# This script is intended to be run like this:
|
||||
#
|
||||
# curl https://.../bootstrap.sh | sudo bash
|
||||
# curl -L https://power-mailinabox.net/setup.sh | sudo bash
|
||||
#
|
||||
#########################################################
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=v0.02
|
||||
fi
|
||||
|
||||
# Are we running as root?
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root. Did you leave out sudo?"
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Go to root's home directory.
|
||||
cd
|
||||
if [ ! -f /usr/bin/lsb_release ]; then
|
||||
# Try installing it (apt install lsb-release)
|
||||
if [ ! -f /usr/bin/apt-get ]; then
|
||||
echo "This operating system does not have apt-get! This means it is unsupported!"
|
||||
echo "This script must be run on a system running one of the following OS-es:"
|
||||
echo "* Debian 10 (buster)"
|
||||
echo "* Debian 11 (bullseye)"
|
||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing lsb-release to understand which operating system we're running..."
|
||||
apt-get -q -q update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -q -q install -y lsb-release < /dev/null
|
||||
fi
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
# Make sure we're running on the correct operating system
|
||||
OS=$(lsb_release -d | sed 's/.*:\s*//')
|
||||
if [ "$OS" == "Debian GNU/Linux 11 (bullseye)" ] ||
|
||||
[ "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ] ||
|
||||
[ "$(echo $OS | grep -o 'Ubuntu 22.04')" == "Ubuntu 22.04" ]
|
||||
then
|
||||
TAG=v60.5
|
||||
elif [ "$OS" == "Debian GNU/Linux 10 (buster)" ]; then
|
||||
echo "We are going to install the last version of Power Mail-in-a-Box supporting Debian 10 (buster)."
|
||||
echo "IF THIS IS A NEW INSTALLATION, STOP NOW, AND USE A SUPPORTED DISTRIBUTION INSTEAD (ONE OF THESE):"
|
||||
echo "* Debian 11 (bullseye)"
|
||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
||||
echo
|
||||
echo "IF YOU'RE UPGRADING THE BOX TO THE LATEST VERSION, PLEASE VISIT THIS PAGE FOR NOTES ON HOW TO"
|
||||
echo "UPGRADE YOUR SISTEM TO DEBIAN 11 (bullseye)"
|
||||
echo "https://power-mailinabox.net/buster-eol"
|
||||
|
||||
while true; do
|
||||
read -p "Do you want to proceed? ([Y]es/[N]o) " yn
|
||||
|
||||
case $yn in
|
||||
Yes | Y | yes | y )
|
||||
break
|
||||
;;
|
||||
No | N | no | n )
|
||||
echo "Installation cancelled."
|
||||
exit 1
|
||||
;;
|
||||
* )
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
TAG=v56.5
|
||||
else
|
||||
echo "This script must be run on a system running one of the following OS-es:"
|
||||
echo "* Debian 11 (bullseye)"
|
||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clone the Mail-in-a-Box repository if it doesn't exist.
|
||||
if [ ! -d mailinabox ]; then
|
||||
echo Installing git . . .
|
||||
apt-get -q -q install -y git
|
||||
|
||||
echo Downloading Mail-in-a-Box . . .
|
||||
git clone -b $TAG --depth 1 https://github.com/mail-in-a-box/mailinabox 2> /dev/null
|
||||
cd mailinabox
|
||||
|
||||
# If it does exist, update it.
|
||||
else
|
||||
echo Updating Mail-in-a-Box to $TAG . . .
|
||||
cd mailinabox
|
||||
git fetch
|
||||
if ! git checkout -q $TAG; then
|
||||
echo "Update failed. Did you modify something in `pwd`?"
|
||||
exit
|
||||
if [ ! -d $HOME/mailinabox ]; then
|
||||
if [ ! -f /usr/bin/git ]; then
|
||||
echo Installing git . . .
|
||||
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. . .
|
||||
git clone \
|
||||
-b $TAG --depth 1 \
|
||||
https://git.nibbletools.com/beenull/power-mailinabox \
|
||||
$HOME/mailinabox \
|
||||
< /dev/null 2> /dev/null
|
||||
|
||||
echo
|
||||
fi
|
||||
|
||||
# Change directory to it.
|
||||
cd $HOME/mailinabox
|
||||
|
||||
# Update it.
|
||||
if [ "$TAG" != "$(git describe --tags)" ]; then
|
||||
echo Updating Mail-in-a-Box to $TAG . . .
|
||||
git fetch --depth 1 --force --prune origin tag $TAG
|
||||
if ! git checkout -q $TAG; then
|
||||
echo "Update failed. Did you modify something in $(pwd)?"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
# Start setup script.
|
||||
|
|
107
setup/dkim.sh
Normal file → Executable file
107
setup/dkim.sh
Normal file → Executable file
|
@ -1,13 +1,17 @@
|
|||
# OpenDKIM: Sign outgoing mail with DKIM
|
||||
########################################
|
||||
|
||||
# After this, you'll still need to run dns_update.sh to get the DKIM
|
||||
# signature in the DNS zones.
|
||||
#!/bin/bash
|
||||
# OpenDKIM
|
||||
# --------
|
||||
#
|
||||
# OpenDKIM provides a service that puts a DKIM signature on outbound mail.
|
||||
#
|
||||
# The DNS configuration for DKIM is done in the management daemon.
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install DKIM
|
||||
apt_install opendkim opendkim-tools
|
||||
# Install DKIM...
|
||||
echo Installing OpenDKIM/OpenDMARC...
|
||||
apt_install opendkim opendkim-tools opendmarc
|
||||
|
||||
# Make sure configuration directories exist.
|
||||
mkdir -p /etc/opendkim;
|
||||
|
@ -17,43 +21,104 @@ mkdir -p $STORAGE_ROOT/mail/dkim
|
|||
# Not quite sure why.
|
||||
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
|
||||
true; # already done
|
||||
true # already done #NODOC
|
||||
else
|
||||
# Add various configuration options to the end.
|
||||
# Add various configuration options to the end of `opendkim.conf`.
|
||||
cat >> /etc/opendkim.conf << EOF;
|
||||
Canonicalization relaxed/simple
|
||||
MinimumKeyBits 1024
|
||||
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||
InternalHosts refile:/etc/opendkim/TrustedHosts
|
||||
KeyTable refile:/etc/opendkim/KeyTable
|
||||
SigningTable refile:/etc/opendkim/SigningTable
|
||||
Socket inet:8891@localhost
|
||||
Socket inet:8891@127.0.0.1
|
||||
RequireSafeKeys false
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create a new DKIM key if we don't have one already. This creates
|
||||
# mail.private and mail.txt in $STORAGE_ROOT/mail/dkim. The former
|
||||
# is the actual private key and the latter is the suggested DNS TXT
|
||||
# entry which we'll want to include in our DNS setup.
|
||||
# Create a new DKIM key. This creates mail.private and mail.txt
|
||||
# in $STORAGE_ROOT/mail/dkim. The former is the private key and
|
||||
# the latter is the suggested DNS TXT entry which we'll include
|
||||
# in our DNS setup. Note 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
|
||||
# Should we specify -h rsa-sha256?
|
||||
opendkim-genkey -r -s mail -D $STORAGE_ROOT/mail/dkim
|
||||
opendkim-genkey -b 2048 -r -s mail -D $STORAGE_ROOT/mail/dkim
|
||||
fi
|
||||
|
||||
# Ensure files are owned by the opendkim user and are private otherwise.
|
||||
chown -R opendkim:opendkim $STORAGE_ROOT/mail/dkim
|
||||
chmod go-rwx $STORAGE_ROOT/mail/dkim
|
||||
|
||||
# Add OpenDKIM as a milter to postfix, which is how it intercepts outgoing
|
||||
# mail to perform the signing (by adding a mail header).
|
||||
# Be careful. If we add other milters later, it needs to be concatenated on the smtpd_milters line.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_milters=inet:127.0.0.1:8891 \
|
||||
management/editconf.py /etc/opendmarc.conf -s \
|
||||
"Syslog=true" \
|
||||
"Socket=inet:8893@[127.0.0.1]" \
|
||||
"FailureReports=true"
|
||||
|
||||
# SPFIgnoreResults causes the filter to ignore any SPF results in the header
|
||||
# of the message. This is useful if you want the filter to perfrom SPF checks
|
||||
# itself, or because you don't trust the arriving header. This added header is
|
||||
# used by spamassassin to evaluate the mail for spamminess.
|
||||
|
||||
management/editconf.py /etc/opendmarc.conf -s \
|
||||
"SPFIgnoreResults=true"
|
||||
|
||||
# SPFSelfValidate causes the filter to perform a fallback SPF check itself
|
||||
# when it can find no SPF results in the message header. If SPFIgnoreResults
|
||||
# is also set, it never looks for SPF results in headers and always performs
|
||||
# the SPF check itself when this is set. This added header is used by
|
||||
# spamassassin to evaluate the mail for spamminess.
|
||||
|
||||
management/editconf.py /etc/opendmarc.conf -s \
|
||||
"SPFSelfValidate=true"
|
||||
|
||||
# Enables generation of failure reports for sending domains that publish a
|
||||
# "none" policy.
|
||||
|
||||
management/editconf.py /etc/opendmarc.conf -s \
|
||||
"FailureReportsOnNone=true"
|
||||
|
||||
# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to
|
||||
# unsigned messages from domains with no "signs all" policy. The reported DKIM
|
||||
# result will be "none" in such cases. Normally unsigned mail from non-strict
|
||||
# domains does not cause the results header field to be added. This added header
|
||||
# is used by spamassassin to evaluate the mail for spamminess.
|
||||
|
||||
management/editconf.py /etc/opendkim.conf -s \
|
||||
"AlwaysAddARHeader=true"
|
||||
|
||||
# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM
|
||||
# intercepts outgoing mail to perform the signing (by adding a mail header)
|
||||
# and how they both intercept incoming mail to add Authentication-Results
|
||||
# headers. The order possibly/probably matters: OpenDMARC relies on the
|
||||
# OpenDKIM Authentication-Results header already being present.
|
||||
#
|
||||
# Be careful. If we add other milters later, this needs to be concatenated
|
||||
# on the smtpd_milters line.
|
||||
#
|
||||
# The OpenDMARC milter is skipped in the SMTP submission listener by
|
||||
# configuring smtpd_milters there to only list the OpenDKIM milter
|
||||
# (see mail-postfix.sh).
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
"smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\
|
||||
non_smtpd_milters=\$smtpd_milters \
|
||||
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_service opendkim
|
||||
restart_service opendmarc
|
||||
restart_service postfix
|
||||
|
||||
|
|
134
setup/dns.sh
134
setup/dns.sh
|
@ -1,5 +1,5 @@
|
|||
#!/bin/bash
|
||||
# DNS: Configure a DNS server to host our own DNS
|
||||
# DNS
|
||||
# -----------------------------------------------
|
||||
|
||||
# This script installs packages, but the DNS zone files are only
|
||||
|
@ -8,72 +8,142 @@
|
|||
# mail users & aliases created by the user later.
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install `nsd`, our DNS server software, and `ldnsutils` which helps
|
||||
# us sign zones for DNSSEC.
|
||||
# Prepare nsd's configuration.
|
||||
# We configure nsd before installation as we only want it to bind to some addresses
|
||||
# and it otherwise will have port / bind conflicts with bind9 used as the local resolver
|
||||
mkdir -p /var/run/nsd
|
||||
mkdir -p /etc/nsd
|
||||
mkdir -p /etc/nsd/zones
|
||||
touch /etc/nsd/zones.conf
|
||||
touch /etc/nsd/nsd.conf
|
||||
|
||||
# ...but first, we have to create the user because the
|
||||
# current Ubuntu forgets to do so in the .deb
|
||||
# see issue #25 and https://bugs.launchpad.net/ubuntu/+source/nsd/+bug/1311886
|
||||
if id nsd > /dev/null 2>&1; then
|
||||
true; #echo "nsd user exists... good"; #NODOC
|
||||
else
|
||||
useradd nsd;
|
||||
fi
|
||||
cat > /etc/nsd/nsd.conf << EOF;
|
||||
# Do not edit. Overwritten by Mail-in-a-Box setup.
|
||||
server:
|
||||
hide-version: yes
|
||||
logfile: "/var/log/nsd.log"
|
||||
|
||||
# Okay now install the packages.
|
||||
# identify the server (CH TXT ID.SERVER entry).
|
||||
identity: ""
|
||||
|
||||
# The directory for zonefile: files.
|
||||
zonesdir: "/etc/nsd/zones"
|
||||
|
||||
# Allows NSD to bind to IP addresses that are not (yet) added to the
|
||||
# network interface. This allows nsd to start even if the network stack
|
||||
# isn't fully ready, which apparently happens in some cases.
|
||||
# See https://www.nlnetlabs.nl/projects/nsd/nsd.conf.5.html.
|
||||
ip-transparent: yes
|
||||
|
||||
EOF
|
||||
|
||||
# Since we have bind9 listening on localhost for locally-generated
|
||||
# DNS queries that require a recursive nameserver, and the system
|
||||
# might have other network interfaces for e.g. tunnelling, we have
|
||||
# to be specific about the network interfaces that nsd binds to.
|
||||
for ip in $PRIVATE_IP $PRIVATE_IPV6; do
|
||||
echo " ip-address: $ip" >> /etc/nsd/nsd.conf;
|
||||
done
|
||||
|
||||
# Create a directory for additional configuration directives, including
|
||||
# the zones.conf file written out by our management daemon.
|
||||
echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf;
|
||||
|
||||
# Remove the old location of zones.conf that we generate. It will
|
||||
# now be stored in /etc/nsd/nsd.conf.d.
|
||||
rm -f /etc/nsd/zones.conf
|
||||
|
||||
# Add log rotation
|
||||
cat > /etc/logrotate.d/nsd <<EOF;
|
||||
/var/log/nsd.log {
|
||||
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
|
||||
|
||||
# Prepare nsd's configuration.
|
||||
|
||||
sudo mkdir -p /var/run/nsd
|
||||
|
||||
# Create DNSSEC signing keys.
|
||||
|
||||
mkdir -p "$STORAGE_ROOT/dns/dnssec";
|
||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/keys.conf" ]; then
|
||||
echo "Generating DNSSEC signing keys. This may take a few minutes..."
|
||||
|
||||
# Create the Key-Signing Key (KSK) (-k) which is the so-called
|
||||
# Secure Entry Point. Use a NSEC3-compatible algorithm (best
|
||||
# practice), and a nice and long keylength. The domain name we
|
||||
# provide ("_domain_") doesn't matter -- we'll use the same
|
||||
# keys for all our domains.
|
||||
KSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 2048 -k _domain_);
|
||||
# TLDs, registrars, and validating nameservers don't all support the same algorithms,
|
||||
# so we'll generate keys using a few different algorithms so that dns_update.py can
|
||||
# choose which algorithm to use when generating the zonefiles. See #1953 for recent
|
||||
# discussion. File for previously used algorithms (i.e. RSASHA1-NSEC3-SHA1) may still
|
||||
# be in the output directory, and we'll continue to support signing zones with them
|
||||
# so that trust isn't broken with deployed DS records, but we won't generate those
|
||||
# keys on new systems.
|
||||
FIRST=1 #NODOC
|
||||
for algo in RSASHA256 ECDSAP256SHA256; do
|
||||
if [ ! -f "$STORAGE_ROOT/dns/dnssec/$algo.conf" ]; then
|
||||
if [ $FIRST == 1 ]; then
|
||||
echo "Generating DNSSEC signing keys..."
|
||||
FIRST=0 #NODOC
|
||||
fi
|
||||
|
||||
# Create the Key-Signing Key (KSK) (with `-k`) which is the so-called
|
||||
# Secure Entry Point. The domain name we provide ("_domain_") doesn't
|
||||
# matter -- we'll use the same keys for all our domains.
|
||||
#
|
||||
# `ldns-keygen` outputs the new key's filename to stdout, which
|
||||
# we're capturing into the `KSK` variable.
|
||||
#
|
||||
# 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
|
||||
# rotated more often than a KSK, although we have no plans to
|
||||
# rotate it (and doing so would be difficult to do without
|
||||
# disturbing DNS availability.) Omit '-k' and use a shorter key.
|
||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -a RSASHA1-NSEC3-SHA1 -b 1024 _domain_);
|
||||
# disturbing DNS availability.) Omit `-k`.
|
||||
# (This previously used -b 1024 but it's unclear if this setting makes sense
|
||||
# for non-RSA keys, so it's removed.)
|
||||
ZSK=$(umask 077; cd $STORAGE_ROOT/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo _domain_);
|
||||
|
||||
# These generate two sets of files like:
|
||||
#
|
||||
# * `K_domain_.+007+08882.ds`: DS record to provide to domain name registrar
|
||||
# * `K_domain_.+007+08882.key`: public key (goes into DS record & upstream DNS provider like your registrar)
|
||||
# * `K_domain_.+007+08882.ds`: DS record normally provided to domain name registrar (but it's actually invalid with `_domain_` so we don't use this file)
|
||||
# * `K_domain_.+007+08882.key`: public key
|
||||
# * `K_domain_.+007+08882.private`: private key (secret!)
|
||||
|
||||
# The filenames are unpredictable and encode the key generation
|
||||
# options. So we'll store the names of the files we just generated.
|
||||
# We might have multiple keys down the road. This will identify
|
||||
# what keys are the current keys.
|
||||
cat > $STORAGE_ROOT/dns/dnssec/keys.conf << EOF;
|
||||
cat > $STORAGE_ROOT/dns/dnssec/$algo.conf << EOF;
|
||||
KSK=$KSK
|
||||
ZSK=$ZSK
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Force the dns_update script to be run every day to re-sign zones for DNSSEC.
|
||||
# And loop to do the next algorithm...
|
||||
done
|
||||
|
||||
# Force the dns_update script to be run every day to re-sign zones for DNSSEC
|
||||
# before they expire. When we sign zones (in `dns_update.py`) we specify a
|
||||
# 30-day validation window, so we had better re-sign before then.
|
||||
cat > /etc/cron.daily/mailinabox-dnssec << EOF;
|
||||
#!/bin/bash
|
||||
# Mail-in-a-Box
|
||||
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
|
||||
`pwd`/tools/dns_update
|
||||
$(pwd)/tools/dns_update
|
||||
EOF
|
||||
chmod +x /etc/cron.daily/mailinabox-dnssec
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
# If there aren't any mail users yet, create one.
|
||||
if [ -z "`tools/mail.py user`" ]; then
|
||||
# The outut of "tools/mail.py user" is a list of mail users. If there
|
||||
if [ -z "$(management/cli.py user)" ]; then
|
||||
# The outut of "management/cli.py user" is a list of mail users. If there
|
||||
# aren't any yet, it'll be empty.
|
||||
|
||||
# If we didn't ask for an email address at the start, do so now.
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
if [ -z "${EMAIL_ADDR:-}" ]; then
|
||||
# In an interactive shell, ask the user for an email address.
|
||||
if [ -z "$NONINTERACTIVE" ]; then
|
||||
if [ -z "${NONINTERACTIVE:-}" ]; then
|
||||
input_box "Mail Account" \
|
||||
"Let's create your first mail account.
|
||||
\n\nWhat email address do you want?" \
|
||||
me@`get_default_hostname` \
|
||||
me@$(get_default_hostname) \
|
||||
EMAIL_ADDR
|
||||
|
||||
if [ -z "$EMAIL_ADDR" ]; then
|
||||
|
@ -35,7 +35,7 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||
else
|
||||
# Use me@PRIMARY_HOSTNAME
|
||||
EMAIL_ADDR=me@$PRIMARY_HOSTNAME
|
||||
EMAIL_PW=1234
|
||||
EMAIL_PW=12345678
|
||||
echo
|
||||
echo "Creating a new administrative mail account for $EMAIL_ADDR with password $EMAIL_PW."
|
||||
echo
|
||||
|
@ -47,11 +47,14 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||
fi
|
||||
|
||||
# Create the user's mail account. This will ask for a password if none was given above.
|
||||
tools/mail.py user add $EMAIL_ADDR $EMAIL_PW
|
||||
management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
|
||||
|
||||
# Make it an admin.
|
||||
hide_output tools/mail.py user make-admin $EMAIL_ADDR
|
||||
hide_output management/cli.py user make-admin $EMAIL_ADDR
|
||||
|
||||
# Create an alias to which we'll direct all automatically-created administrative aliases.
|
||||
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR
|
||||
fi
|
||||
management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
|
||||
fi
|
||||
|
||||
# Kick everything that may have been missed
|
||||
management/mailconfig.py update
|
||||
|
|
|
@ -1,63 +1,59 @@
|
|||
# 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
|
||||
|
||||
function hide_output {
|
||||
# This function hides the output of a command unless the command fails
|
||||
# and returns a non-zero exit code.
|
||||
|
||||
# Get a temporary file.
|
||||
OUTPUT=$(tempfile)
|
||||
OUTPUT=$(mktemp)
|
||||
|
||||
# Execute command, redirecting stderr/stdout to the temporary file.
|
||||
$@ &> $OUTPUT
|
||||
# Execute command, redirecting stderr/stdout to the temporary file. Since we
|
||||
# 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 [ $? != 0 ]; then
|
||||
if [ $E != 0 ]; then
|
||||
# Something failed.
|
||||
echo
|
||||
echo FAILED: $@
|
||||
echo FAILED: "$@"
|
||||
echo -----------------------------------------
|
||||
cat $OUTPUT
|
||||
cat "$OUTPUT"
|
||||
echo -----------------------------------------
|
||||
exit $E
|
||||
fi
|
||||
|
||||
# Remove temporary file.
|
||||
rm -f $OUTPUT
|
||||
}
|
||||
|
||||
function apt_install {
|
||||
# Report any packages already installed.
|
||||
PACKAGES=$@
|
||||
TO_INSTALL=""
|
||||
ALREADY_INSTALLED=""
|
||||
for pkg in $PACKAGES; do
|
||||
if dpkg -s $pkg 2>/dev/null | grep "^Status: install ok installed" > /dev/null; then
|
||||
if [[ ! -z "$ALREADY_INSTALLED" ]]; then ALREADY_INSTALLED="$ALREADY_INSTALLED, "; fi
|
||||
ALREADY_INSTALLED="$ALREADY_INSTALLED$pkg (`dpkg -s $pkg | grep ^Version: | sed -e 's/.*: //'`)"
|
||||
else
|
||||
TO_INSTALL="$TO_INSTALL""$pkg "
|
||||
fi
|
||||
done
|
||||
|
||||
# List the packages already installed.
|
||||
if [[ ! -z "$ALREADY_INSTALLED" ]]; then
|
||||
echo already installed: $ALREADY_INSTALLED
|
||||
fi
|
||||
|
||||
# List the packages about to be installed.
|
||||
if [[ ! -z "$TO_INSTALL" ]]; then
|
||||
echo installing $TO_INSTALL...
|
||||
fi
|
||||
|
||||
# 'DEBIAN_FRONTEND=noninteractive' is to prevent dbconfig-common from asking you questions.
|
||||
function apt_get_quiet {
|
||||
# Run apt-get in a totally non-interactive mode.
|
||||
#
|
||||
# Somehow all of these options are needed to get it to not ask the user
|
||||
# questions about a) whether to proceed (-y), b) package options (noninteractive),
|
||||
# and c) what to do about files changed locally (we don't cause that to happen but
|
||||
# some VM providers muck with their images; -o).
|
||||
#
|
||||
# Although we could pass -qq to apt-get to make output quieter, many packages write to stdout
|
||||
# and stderr things that aren't really important. Use our hide_output function to capture
|
||||
# all of that and only show it if there is a problem (i.e. if apt_get returns a failure exit status).
|
||||
#
|
||||
# Also note that 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.
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
hide_output \
|
||||
apt-get -y install $PACKAGES
|
||||
DEBIAN_FRONTEND=noninteractive hide_output apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confnew" "$@"
|
||||
}
|
||||
|
||||
function apt_install {
|
||||
# Install a bunch of packages. We used to report which packages were already
|
||||
# installed and which needed installing, before just running an 'apt-get
|
||||
# install' for all of the packages. Calling `dpkg` on each package is slow,
|
||||
# and doesn't affect what we actually do, except in the messages, so let's
|
||||
# not do that anymore.
|
||||
apt_get_quiet install "$@"
|
||||
}
|
||||
|
||||
function get_default_hostname {
|
||||
|
@ -78,7 +74,7 @@ function get_publicip_from_web_service {
|
|||
#
|
||||
# Pass '4' or '6' as an argument to this function to specify
|
||||
# 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 {
|
||||
|
@ -121,7 +117,7 @@ function get_default_privateip {
|
|||
if [ "$1" == "6" ]; then target=2001:4860:4860::8888; fi
|
||||
|
||||
# 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.
|
||||
address=$(echo $route | sed "s/.* src \([^ ]*\).*/\1/")
|
||||
|
@ -134,13 +130,19 @@ function get_default_privateip {
|
|||
fi
|
||||
|
||||
echo $address
|
||||
|
||||
}
|
||||
|
||||
function ufw_allow {
|
||||
if [ -z "$DISABLE_FIREWALL" ]; then
|
||||
if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||
# 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
|
||||
}
|
||||
|
||||
|
@ -157,10 +159,13 @@ function input_box {
|
|||
# input_box "title" "prompt" "defaultvalue" VARIABLE
|
||||
# The user's input will be stored in the variable VARIABLE.
|
||||
# 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_code=$4_EXITCODE
|
||||
set +e
|
||||
result=$(dialog --stdout --title "$1" --inputbox "$2" 0 0 "$3")
|
||||
result_code=$?
|
||||
set -e
|
||||
}
|
||||
|
||||
function input_menu {
|
||||
|
@ -170,6 +175,88 @@ function input_menu {
|
|||
declare -n result=$4
|
||||
declare -n result_code=$4_EXITCODE
|
||||
local IFS=^$'\n'
|
||||
set +e
|
||||
result=$(dialog --stdout --title "$1" --menu "$2" 0 0 0 $3)
|
||||
result_code=$?
|
||||
set -e
|
||||
}
|
||||
|
||||
function wget_verify {
|
||||
# Downloads a file from the web and checks that it matches
|
||||
# a provided hash. If the comparison fails, exit immediately.
|
||||
URL=$1
|
||||
HASH=$2
|
||||
DEST=$3
|
||||
CHECKSUM="$HASH $DEST"
|
||||
rm -f $DEST
|
||||
hide_output wget -O $DEST $URL
|
||||
if ! echo "$CHECKSUM" | sha1sum --check --strict > /dev/null; then
|
||||
echo "------------------------------------------------------------"
|
||||
echo "Download of $URL did not match expected checksum."
|
||||
echo "Found:"
|
||||
sha1sum $DEST
|
||||
echo
|
||||
echo "Expected:"
|
||||
echo "$CHECKSUM"
|
||||
rm -f $DEST
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function git_clone {
|
||||
# Clones a git repository, checks out a particular commit or tag,
|
||||
# and moves the repository (or a subdirectory in it) to some path.
|
||||
# We use separate clone and checkout because -b only supports tags
|
||||
# and branches, but we sometimes want to reference a commit hash
|
||||
# directly when the repo doesn't provide a tag.
|
||||
REPO=$1
|
||||
TREEISH=$2
|
||||
SUBDIR=$3
|
||||
TARGETPATH=$4
|
||||
TMPPATH=/tmp/git-clone-$$
|
||||
rm -rf $TMPPATH $TARGETPATH
|
||||
git clone -q $REPO $TMPPATH || exit 1
|
||||
(cd $TMPPATH; git checkout -q $TREEISH;) || exit 1
|
||||
mv $TMPPATH/$SUBDIR $TARGETPATH
|
||||
rm -rf $TMPPATH
|
||||
}
|
||||
|
||||
function php_version {
|
||||
php --version | head -n 1 | cut -d " " -f 2 | cut -d "." -f 1,2
|
||||
}
|
||||
|
||||
function python_version {
|
||||
python3 --version | cut -d " " -f 2 | cut -d "." -f 1,2
|
||||
}
|
||||
|
||||
export OS_UNSUPPORTED=0
|
||||
export OS_DEBIAN_10=1
|
||||
export OS_UBUNTU_2004=2
|
||||
export OS_DEBIAN_11=3
|
||||
export OS_UBUNTU_2204=4
|
||||
|
||||
function get_os_code {
|
||||
# A lot of if-statements here - dirty code looking tasting today
|
||||
ID=$(lsb_release -is)
|
||||
VER=$(lsb_release -rs)
|
||||
|
||||
if [[ $ID == "Debian" ]]; then
|
||||
if [[ $VER == "10" ]]; then
|
||||
echo $OS_DEBIAN_10
|
||||
return 0
|
||||
elif [[ $VER == "11" ]]; then
|
||||
echo $OS_DEBIAN_11
|
||||
return 0
|
||||
fi
|
||||
elif [[ $ID == "Ubuntu" ]]; then
|
||||
if [[ $VER == "20.04" ]]; then
|
||||
echo $OS_UBUNTU_2004
|
||||
return 0
|
||||
elif [[ $VER == "22.04" ]]; then
|
||||
echo $OS_UBUNTU_2204
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo $OS_UNSUPPORTED
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Dovecot (IMAP and LDA)
|
||||
# Dovecot (IMAP/POP and LDA)
|
||||
# ----------------------
|
||||
#
|
||||
# Dovecot is *both* the IMAP server (the protocol that email applications
|
||||
# Dovecot is *both* the IMAP/POP server (the protocol that email applications
|
||||
# use to query a mailbox) as well as the local delivery agent (LDA),
|
||||
# meaning it is responsible for writing emails to mailbox storage on disk.
|
||||
# You could imagine why these things would be bundled together.
|
||||
|
@ -18,39 +18,83 @@
|
|||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# ### Install packages and basic setup
|
||||
|
||||
# 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 \
|
||||
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 \
|
||||
dovecot-sieve dovecot-managesieved
|
||||
|
||||
# The dovecot-imapd and dovecot-lmtpd packages automatically enable IMAP and LMTP protocols.
|
||||
# The `dovecot-imapd`, `dovecot-pop3d`, and `dovecot-lmtpd` packages automatically
|
||||
# enable IMAP, POP and LMTP protocols.
|
||||
|
||||
# Set the location where we'll store user mailboxes.
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
# Set basic daemon options.
|
||||
|
||||
# The `default_process_limit` is 100, which constrains the total number
|
||||
# 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
|
||||
# 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
|
||||
management/editconf.py /etc/dovecot/conf.d/10-master.conf \
|
||||
default_process_limit=$(echo "$(nproc) * 250" | bc) \
|
||||
default_vsz_limit=$(echo "$(free -tm | tail -1 | awk '{print $2}') / 3" | bc)M \
|
||||
log_path=/var/log/mail.log
|
||||
|
||||
# The inotify `max_user_instances` default is 128, which constrains
|
||||
# the total number of watched (IMAP IDLE push) folders by open connections.
|
||||
# See http://www.dovecot.org/pipermail/dovecot/2013-March/088834.html.
|
||||
# A reboot is required for this to take effect (which we don't do as
|
||||
# as a part of setup). Test with `cat /proc/sys/fs/inotify/max_user_instances`.
|
||||
management/editconf.py /etc/sysctl.conf \
|
||||
fs.inotify.max_user_instances=1024
|
||||
|
||||
# Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the
|
||||
# username part of the user's email address. We'll ensure that no bad domains or email addresses
|
||||
# are created within the management daemon.
|
||||
management/editconf.py /etc/dovecot/conf.d/10-mail.conf \
|
||||
mail_location=maildir:$STORAGE_ROOT/mail/mailboxes/%d/%n \
|
||||
mail_privileged_group=mail \
|
||||
first_valid_uid=0
|
||||
|
||||
# ### IMAP
|
||||
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
|
||||
cp conf/dovecot/conf.d/15-mailboxes.conf /etc/dovecot/conf.d/
|
||||
cp conf/dovecot/conf.d/20-imap.conf /etc/dovecot/conf.d/
|
||||
cp conf/dovecot/conf.d/90-quota.conf /etc/dovecot/conf.d/
|
||||
|
||||
# ### IMAP/POP
|
||||
|
||||
# Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms.
|
||||
# The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess
|
||||
# since we're using Dovecot to handle SMTP authentication?).
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
||||
management/editconf.py /etc/dovecot/conf.d/10-auth.conf \
|
||||
disable_plaintext_auth=yes \
|
||||
"auth_mechanisms=plain login"
|
||||
|
||||
# Enable SSL, specify the location of the SSL certificate and private key files,
|
||||
# and allow only good ciphers per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
tools/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||
# Enable SSL, specify the location of the SSL certificate and private key files.
|
||||
# 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.
|
||||
management/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||
ssl=required \
|
||||
"ssl_cert=<$STORAGE_ROOT/ssl/ssl_certificate.pem" \
|
||||
"ssl_key=<$STORAGE_ROOT/ssl/ssl_private_key.pem" \
|
||||
"ssl_cipher_list=TLSv1+HIGH !SSLv2 !RC4 !aNULL !eNULL !3DES @STRENGTH"
|
||||
"ssl_min_protocol=TLSv1.2" \
|
||||
"ssl_cipher_list=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384" \
|
||||
"ssl_prefer_server_ciphers=no" \
|
||||
"ssl_dh_parameters_length=2048" \
|
||||
"ssl_dh=<$STORAGE_ROOT/ssl/dh2048.pem"
|
||||
|
||||
# Disable in-the-clear IMAP and POP because we're paranoid (we haven't even
|
||||
# enabled POP).
|
||||
# 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
|
||||
# are made available (IMAPS on port 993; POP3S on port 995).
|
||||
sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
|
||||
|
||||
|
@ -59,14 +103,22 @@ 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
|
||||
# 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
|
||||
# by a peer. See #129 and http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/.
|
||||
tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||
# 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/).
|
||||
management/editconf.py /etc/dovecot/conf.d/20-imap.conf \
|
||||
imap_idle_notify_interval="4 mins"
|
||||
|
||||
# Set POP3 UIDL.
|
||||
# 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
|
||||
# and UID values, the default in Dovecot.
|
||||
management/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
|
||||
pop3_uidl_format="%08Xu%08Xv"
|
||||
|
||||
# ### LDA (LMTP)
|
||||
|
||||
# Enable Dovecot's LDA service with the LMTP protocol. It will listen
|
||||
# in port 10026, and Spamassassin will be configured to pass mail there.
|
||||
# on port 10026, and Spamassassin will be configured to pass mail there.
|
||||
#
|
||||
# The disabled unix socket listener is normally how Postfix and Dovecot
|
||||
# would communicate (see the Postfix setup script for the corresponding
|
||||
|
@ -86,43 +138,63 @@ 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 {
|
||||
mail_max_userip_connections = 20
|
||||
mail_max_userip_connections = 40
|
||||
}
|
||||
EOF
|
||||
|
||||
# Setting a postmaster_address seems to be required or LMTP won't start.
|
||||
tools/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
||||
# Setting a `postmaster_address` is required or LMTP won't start. An alias
|
||||
# will be created automatically by our management daemon.
|
||||
management/editconf.py /etc/dovecot/conf.d/15-lda.conf \
|
||||
postmaster_address=postmaster@$PRIMARY_HOSTNAME
|
||||
|
||||
# ### Sieve
|
||||
|
||||
# Enable the Dovecot sieve plugin which let's users run scripts that process
|
||||
# mail as it comes in. We'll also set a global script that moves mail marked
|
||||
# as spam by Spamassassin into the user's Spam folder.
|
||||
# mail as it comes in.
|
||||
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf
|
||||
|
||||
# Configure sieve. We'll create a global script that moves mail marked
|
||||
# as spam by Spamassassin into the user's 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
|
||||
# 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 I suppose it might appear to the user as one of their scripts).
|
||||
# * `sieve_dir`: Directory for :personal include scripts for the include extension. This
|
||||
# is also where the ManageSieve service stores the user's scripts.
|
||||
cat > /etc/dovecot/conf.d/99-local-sieve.conf << EOF;
|
||||
plugin {
|
||||
# The path to our global sieve which handles moving spam to the Spam folder.
|
||||
sieve_before = /etc/dovecot/sieve-spam.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
|
||||
# (because then it might appear as a folder) and it should not be in the sieve_dir
|
||||
# (because then I suppose it might appear to the user as one of their scripts).
|
||||
sieve_before2 = $STORAGE_ROOT/mail/sieve/global_before
|
||||
sieve_after = $STORAGE_ROOT/mail/sieve/global_after
|
||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||
|
||||
# Directory for :personal include scripts for the include extension. This
|
||||
# is also where the ManageSieve service stores the user's scripts.
|
||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||
sieve_redirect_envelope_from = recipient
|
||||
sieve_vacation_send_from_recipient = yes
|
||||
}
|
||||
EOF
|
||||
|
||||
# Copy the global sieve script into where we've told Dovecot to look for it. Then
|
||||
# compile it. Global scripts must be compiled now because Dovecot won't have
|
||||
# permission later.
|
||||
cp `pwd`/conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
||||
cp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
|
||||
sievec /etc/dovecot/sieve-spam.sieve
|
||||
|
||||
# PERMISSIONS
|
||||
|
@ -137,10 +209,16 @@ chown -R mail.mail $STORAGE_ROOT/mail/mailboxes
|
|||
|
||||
# Same for the sieve scripts.
|
||||
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
|
||||
|
||||
# Allow the IMAP port in the firewall.
|
||||
# Allow the IMAP/POP ports in the firewall.
|
||||
ufw_allow imaps
|
||||
ufw_allow pop3s
|
||||
|
||||
# Allow the Sieve port in the firewall.
|
||||
ufw_allow sieve
|
||||
|
||||
# Restart services.
|
||||
restart_service dovecot
|
||||
|
|
|
@ -13,11 +13,11 @@
|
|||
# destinations according to aliases, and passses email on to
|
||||
# another service for local mail delivery.
|
||||
#
|
||||
# The first hop in local mail delivery is to Spamassassin via
|
||||
# LMTP. Spamassassin then passes mail over to Dovecot for
|
||||
# The first hop in local mail delivery is to spampd via
|
||||
# LMTP. spampd then passes mail over to Dovecot for
|
||||
# storage in the user's mailbox.
|
||||
#
|
||||
# Postfix also listens on port 587 (SMTP+STARTLS) for
|
||||
# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for
|
||||
# connections from users who can authenticate and then sends
|
||||
# their email out to the outside world. Postfix queries Dovecot
|
||||
# to authenticate users.
|
||||
|
@ -32,127 +32,283 @@ source /etc/mailinabox.conf # load global vars
|
|||
|
||||
# ### Install packages.
|
||||
|
||||
apt_install postfix postgrey postfix-pcre ca-certificates
|
||||
# Install postfix's packages.
|
||||
#
|
||||
# * `postfix`: The SMTP server.
|
||||
# * `postfix-pcre`: Enables header filtering.
|
||||
# * `postgrey`: A mail policy service that soft-rejects mail the first time
|
||||
# it is received. Spammers don't usually try agian. Legitimate mail
|
||||
# always will.
|
||||
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
||||
# untrusted opportunistically-encrypted connections.
|
||||
echo "Installing Postfix (SMTP server)..."
|
||||
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates libsasl2-modules
|
||||
|
||||
# ### Basic Settings
|
||||
|
||||
# Have postfix listen on all network interfaces, set our name (the Debian default seems to be localhost),
|
||||
# and set the name of the local machine to localhost for xxx@localhost mail (but I don't think this will have any effect because
|
||||
# there is no true local mail delivery). Also set the banner (must have the hostname first, then anything).
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# Set some basic settings...
|
||||
#
|
||||
# * 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 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).
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
inet_interfaces=all \
|
||||
smtp_bind_address=$PRIVATE_IP \
|
||||
smtp_bind_address6=$PRIVATE_IPV6 \
|
||||
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 Power Mail-in-a-Box (Postfix)" \
|
||||
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.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
delay_warning_time=3h \
|
||||
maximal_queue_lifetime=2d \
|
||||
bounce_queue_lifetime=1d
|
||||
|
||||
# ### Outgoing Mail
|
||||
|
||||
# Enable the 'submission' port 587 smtpd server and tweak its settings.
|
||||
# Enable the 'submission' ports 465 and 587 and tweak their settings.
|
||||
#
|
||||
# * Require the best ciphers for incoming connections per http://baldric.net/2013/12/07/tls-ciphers-in-postfix-and-dovecot/.
|
||||
# but without affecting opportunistic TLS on incoming mail, which will allow any cipher (it's better than none).
|
||||
# * 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
|
||||
# on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the
|
||||
# OpenDKIM milter only. See dkim.sh.
|
||||
# * Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant
|
||||
# send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
|
||||
# Setting smtpd_tls_security_level=encrypt also triggers the use of the 'mandatory' settings below (but this is ignored with smtpd_tls_wrappermode=yes.)
|
||||
# * Give it a different name in syslog to distinguish it from the port 25 smtpd server.
|
||||
# * Add a new cleanup service specific to the submission service ('authclean')
|
||||
# that filters out privacy-sensitive headers on mail being sent out by
|
||||
# authenticated users.
|
||||
tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||
"submission=inet n - - - - smtpd
|
||||
# authenticated users. By default Postfix also applies this to attached
|
||||
# emails but we turn this off by setting nested_header_checks empty.
|
||||
management/editconf.py /etc/postfix/master.cf -s -w \
|
||||
"smtps=inet n - - - - smtpd
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
|
||||
-o smtpd_milters=inet:127.0.0.1:8891
|
||||
-o cleanup_service_name=authclean" \
|
||||
"submission=inet n - - - - smtpd
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_milters=inet:127.0.0.1:8891
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o cleanup_service_name=authclean" \
|
||||
"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.
|
||||
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
|
||||
# require TLS before a user is allowed to authenticate. This also makes
|
||||
# opportunistic TLS available on *incoming* mail.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# Modify the `outgoing_mail_header_filters` file to use the local machine name and ip
|
||||
# on the first received header line. This may help reduce the spam score of email by
|
||||
# removing the 127.0.0.1 reference.
|
||||
sed -i "s/PRIMARY_HOSTNAME/$PRIMARY_HOSTNAME/" /etc/postfix/outgoing_mail_header_filters
|
||||
sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
||||
|
||||
# Enable TLS on 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&version=3.4.8&config=old&openssl=1.1.1&guideline=5.6
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_tls_security_level=may\
|
||||
smtpd_tls_auth_only=yes \
|
||||
smtpd_tls_cert_file=$STORAGE_ROOT/ssl/ssl_certificate.pem \
|
||||
smtpd_tls_key_file=$STORAGE_ROOT/ssl/ssl_private_key.pem \
|
||||
smtpd_tls_dh1024_param_file=$STORAGE_ROOT/ssl/dh2048.pem \
|
||||
smtpd_tls_protocols="!SSLv2,!SSLv3" \
|
||||
smtpd_tls_ciphers=medium \
|
||||
tls_medium_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA \
|
||||
smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 \
|
||||
tls_preempt_cipherlist=yes \
|
||||
smtpd_tls_received_header=yes
|
||||
|
||||
# For ports 465/587 (via the 'mandatory' settings):
|
||||
# * Use Mozilla's "Intermediate" TLS recommendations from https://ssl-config.mozilla.org/#server=postfix&server-version=3.3.0&config=intermediate&openssl-version=1.1.1
|
||||
# using and overriding the "high" cipher list so we don't conflict with the more permissive settings for port 25.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_tls_mandatory_protocols="!SSLv2,!SSLv3,!TLSv1,!TLSv1.1" \
|
||||
smtpd_tls_mandatory_ciphers=high \
|
||||
tls_high_cipherlist=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 \
|
||||
smtpd_tls_mandatory_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4
|
||||
|
||||
# Prevent non-authenticated users from sending mail that requires being
|
||||
# relayed elsewhere. We don't want to be an "open relay". On outbound
|
||||
# mail, require one of:
|
||||
#
|
||||
# * permit_sasl_authenticated: Authenticated users (i.e. on port 587).
|
||||
# * permit_mynetworks: Mail that originates locally.
|
||||
# * reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 465/587).
|
||||
# * `permit_mynetworks`: Mail that originates locally.
|
||||
# * `reject_unauth_destination`: No one else. (Permits mail whose destination is local and rejects other mail.)
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_relay_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_unauth_destination
|
||||
|
||||
|
||||
# ### DANE
|
||||
#
|
||||
|
||||
# When connecting to remote SMTP servers, prefer TLS and use DANE if available.
|
||||
#
|
||||
# Prefering ("opportunistic") TLS means Postfix will accept whatever SSL certificate the remote
|
||||
# end provides, if the remote end offers STARTTLS during the connection. DANE takes this a
|
||||
# step further:
|
||||
#
|
||||
# Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end
|
||||
# offers it, otherwise it will transmit the message in the clear. Postfix will
|
||||
# accept whatever SSL certificate the remote end provides. Opportunistic TLS
|
||||
# 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:
|
||||
# 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
|
||||
# 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
|
||||
# relies on our local bind9 server being present and smtp_dns_support_level being set to dnssec
|
||||
# to use it.
|
||||
# relies on our local DNS server (see system.sh) and `smtp_dns_support_level=dnssec`.
|
||||
#
|
||||
# The smtp_tls_CAfile is superflous, but it turns warnings in the logs about untrusted certs
|
||||
# into notices about trusted certs. Since in these cases Postfix is doing opportunistic TLS,
|
||||
# it does not care about whether the remote certificate is trusted. But, looking at the logs,
|
||||
# it's nice to be able to see that the connection was in fact encrypted for the right party.
|
||||
# The CA file is provided by the package ca-certificates.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# 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,
|
||||
# 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`.
|
||||
management/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_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_loglevel=2
|
||||
|
||||
# ### Incoming Mail
|
||||
|
||||
# Pass any incoming mail over to a local delivery agent. Spamassassin
|
||||
# will act as the LDA agent at first. It is listening on port 10025
|
||||
# with LMTP. Spamassassin will pass the mail over to Dovecot after.
|
||||
# Pass mail to spampd, which acts as the local delivery agent (LDA),
|
||||
# which then passes the mail over to the Dovecot LMTP server after.
|
||||
# spampd runs on port 10025 by default.
|
||||
#
|
||||
# In a basic setup we would pass mail directly to Dovecot by setting
|
||||
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
||||
#
|
||||
tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
|
||||
management/editconf.py /etc/postfix/main.cf "virtual_transport=lmtp:[127.0.0.1]:10025"
|
||||
# Clear the lmtp_destination_recipient_limit setting which in previous
|
||||
# versions of Mail-in-a-Box was set to 1 because of a spampd bug.
|
||||
# See https://github.com/mail-in-a-box/mailinabox/issues/1523.
|
||||
management/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
|
||||
|
||||
# Who can send mail to us? Some basic filters.
|
||||
#
|
||||
# * reject_non_fqdn_sender: Reject not-nice-looking return paths.
|
||||
# * reject_unknown_sender_domain: Reject return paths with invalid 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_mynetworks: Mail that originates locally can skip further checks.
|
||||
# * reject_rbl_client: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
||||
# * reject_unlisted_recipient: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
|
||||
# * check_policy_service: Apply greylisting using postgrey.
|
||||
# * `reject_non_fqdn_sender`: Reject not-nice-looking return paths.
|
||||
# * `reject_unknown_sender_domain`: Reject return paths with invalid domains.
|
||||
# * `reject_authenticated_sender_login_mismatch`: Reject if mail FROM address does not match the client SASL login
|
||||
# * `reject_rhsbl_sender`: Reject return paths that use blacklisted domains.
|
||||
# * `permit_sasl_authenticated`: Authenticated users (i.e. on port 587) can skip further checks.
|
||||
# * `permit_mynetworks`: Mail that originates locally can skip further checks.
|
||||
# * `reject_rbl_client`: Reject connections from IP addresses blacklisted in zen.spamhaus.org
|
||||
# * `reject_unlisted_recipient`: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
|
||||
# * `check_policy_service`: Apply greylisting using postgrey.
|
||||
#
|
||||
# Notes:
|
||||
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting
|
||||
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not
|
||||
# whitelisted) then postfix does a DEFER_IF_REJECT, which results in all "unknown user" sorts of messages turning into
|
||||
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce.
|
||||
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_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"
|
||||
# Notes: #NODOC
|
||||
# permit_dnswl_client can pass through mail from whitelisted IP addresses, which would be good to put before greylisting #NODOC
|
||||
# so these IPs get mail delivered quickly. But when an IP is not listed in the permit_dnswl_client list (i.e. it is not #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
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org" \
|
||||
smtpd_recipient_restrictions="check_recipient_access sqlite:/etc/postfix/noreply-addresses.cf",permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023","check_policy_service inet:127.0.0.1:12340"
|
||||
|
||||
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
|
||||
# Postgrey listens on the same interface (and not IPv6, for instance).
|
||||
# A lot of legit mail servers try to resend before 300 seconds.
|
||||
# As a matter of fact RFC is not strict about retry timer so postfix and
|
||||
# other MTA have their own intervals. To fix the problem of receiving
|
||||
# e-mails really latter, delay of greylisting has been set to
|
||||
# 180 seconds (default is 300 seconds). We will move the postgrey database
|
||||
# under $STORAGE_ROOT. This prevents a "warming up" that would have occured
|
||||
# previously with a migrated or reinstalled OS. We will specify this new path
|
||||
# with the --dbdir=... option. Arguments within POSTGREY_OPTS can not have spaces,
|
||||
# including dbdir. This is due to the way the init script sources the
|
||||
# /etc/default/postgrey file. --dbdir=... either needs to be a path without spaces
|
||||
# (luckily $STORAGE_ROOT does not currently work with spaces), or it needs to be a
|
||||
# symlink without spaces that can point to a folder with spaces). We'll just assume
|
||||
# $STORAGE_ROOT won't have spaces to simplify things.
|
||||
management/editconf.py /etc/default/postgrey \
|
||||
POSTGREY_OPTS=\""--inet=127.0.0.1:10023 --delay=180 --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.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
message_size_limit=134217728
|
||||
|
||||
# Store default configurations for SMTP relays:
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtp_sasl_auth_enable=yes \
|
||||
smtp_sasl_password_maps="hash:/etc/postfix/sasl_passwd" \
|
||||
smtp_sasl_security_options=noanonymous \
|
||||
smtp_sasl_tls_security_options=noanonymous \
|
||||
smtp_tls_security_level=encrypt \
|
||||
header_size_limit=4096000
|
||||
|
||||
touch /etc/postfix/sasl_passwd
|
||||
chmod 600 /etc/postfix/sasl_passwd
|
||||
postmap /etc/postfix/sasl_passwd
|
||||
|
||||
# Allow the two SMTP ports in the firewall.
|
||||
|
||||
ufw_allow smtp
|
||||
ufw_allow smtps
|
||||
ufw_allow submission
|
||||
|
||||
# Restart services
|
||||
|
||||
restart_service postfix
|
||||
restart_service postgrey
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# This script configures user authentication for Dovecot
|
||||
# and Postfix (which relies on Dovecot) and destination
|
||||
# validation by quering an Sqlite3 database of mail users.
|
||||
# validation by quering an Sqlite3 database of mail users.
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
@ -20,10 +20,25 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
|
|||
# Create an empty database if it doesn't yet exist.
|
||||
if [ ! -f $db_path ]; then
|
||||
echo Creating new user database: $db_path;
|
||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path;
|
||||
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path;
|
||||
echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');" | sqlite3 $db_path;
|
||||
echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
||||
echo "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);" | sqlite3 $db_path
|
||||
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path;
|
||||
echo "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
|
||||
else
|
||||
sql=$(sqlite3 $db_path "SELECT sql FROM sqlite_master WHERE name = 'users'");
|
||||
if echo $sql | grep --invert-match quota; then
|
||||
echo "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';" | sqlite3 $db_path;
|
||||
fi
|
||||
fi
|
||||
|
||||
# Recover the database if it was hit by the Roundcube password changer "bug" (#85)
|
||||
# If the journal_mode is set to wal, postfix cannot read it and we wouldn't
|
||||
# be able to send or receive mail.
|
||||
#
|
||||
# This operation is idempotent so it's safe to run even in healthy databases, too.
|
||||
echo "PRAGMA journal_mode=delete;" | sqlite3 $db_path > /dev/null
|
||||
|
||||
# ### User Authentication
|
||||
|
||||
# Have Dovecot query our database, and not system users, for authentication.
|
||||
|
@ -38,22 +53,23 @@ passdb {
|
|||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
userdb {
|
||||
driver = static
|
||||
args = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
|
||||
driver = sql
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
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;
|
||||
driver = sqlite
|
||||
connect = $db_path
|
||||
default_pass_scheme = SHA512-CRYPT
|
||||
password_query = SELECT email as user, password FROM users WHERE email='%u';
|
||||
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u';
|
||||
iterate_query = SELECT email AS user FROM users;
|
||||
EOF
|
||||
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions
|
||||
|
||||
# Have Dovecot provide an authorization service that Postfix can access & use.
|
||||
# Drew Crawford sets the auth-worker process to run as the mail user, but we don't care if it runs as root.
|
||||
cat > /etc/dovecot/conf.d/99-local-auth.conf << EOF;
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
|
@ -64,38 +80,96 @@ service auth {
|
|||
}
|
||||
EOF
|
||||
|
||||
# And have Postfix use that service.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# 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.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sasl_type=dovecot \
|
||||
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.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf
|
||||
|
||||
# Postfix will query the exact address first, where the priority will be alias
|
||||
# records first, then user records. If there are no matches for the exact
|
||||
# address, then Postfix will query just the domain part, which we call
|
||||
# catch-alls and domain aliases. A NULL permitted_senders column means to
|
||||
# take the value from the destination column.
|
||||
cat > /etc/postfix/sender-login-maps.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;
|
||||
EOF
|
||||
|
||||
# ### Destination Validation
|
||||
|
||||
# Use a Sqlite3 database to check whether a destination email address exists,
|
||||
# and to perform any email alias rewrites in Postfix.
|
||||
tools/editconf.py /etc/postfix/main.cf \
|
||||
# and to perform any email alias rewrites in Postfix. Additionally, we disable
|
||||
# SMTPUTF8 because Dovecot's LMTP server that delivers mail to inboxes does
|
||||
# not support it, and if a message is received with the SMTPUTF8 flag it will
|
||||
# bounce.
|
||||
management/editconf.py /etc/postfix/main.cf \
|
||||
smtputf8_enable=no \
|
||||
virtual_mailbox_domains=sqlite:/etc/postfix/virtual-mailbox-domains.cf \
|
||||
virtual_mailbox_maps=sqlite:/etc/postfix/virtual-mailbox-maps.cf \
|
||||
virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \
|
||||
local_recipient_maps=\$virtual_mailbox_maps
|
||||
|
||||
# SQL statement to check if we handle mail for a domain, either for users or aliases.
|
||||
# SQL statement to check if we handle incoming mail for a domain, either for users or aliases.
|
||||
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'
|
||||
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' UNION SELECT 1 FROM auto_aliases WHERE source LIKE '%%@%s'
|
||||
EOF
|
||||
|
||||
# SQL statement to check if we handle mail for a user.
|
||||
# SQL statement to check if we handle incoming mail for a user.
|
||||
cat > /etc/postfix/virtual-mailbox-maps.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT 1 FROM users WHERE email='%s'
|
||||
EOF
|
||||
|
||||
# SQL statement to rewrite an email address if an alias is present.
|
||||
#
|
||||
# Postfix makes multiple queries for each incoming mail. It first
|
||||
# queries the whole email address, then just the user part in certain
|
||||
# locally-directed cases (but we don't use this), then just `@`+the
|
||||
# domain part. The first query that returns something wins. See
|
||||
# http://www.postfix.org/virtual.5.html.
|
||||
#
|
||||
# virtual-alias-maps has precedence over virtual-mailbox-maps, but
|
||||
# we don't want catch-alls and domain aliases to catch mail for users
|
||||
# that have been defined on those domains. To fix this, we not only
|
||||
# query the aliases table but also the users table when resolving
|
||||
# aliases, i.e. we turn users into aliases from themselves to
|
||||
# themselves. That means users will match in postfix's first query
|
||||
# before postfix gets to the third query for catch-alls/domain alises.
|
||||
#
|
||||
# If there is both an alias and a user for the same address either
|
||||
# might be returned by the UNION, so the whole query is wrapped in
|
||||
# another select that prioritizes the alias definition to preserve
|
||||
# postfix's preference for aliases for whole email addresses.
|
||||
#
|
||||
# Since we might have alias records with an empty destination because
|
||||
# it might have just permitted_senders, skip any records with an
|
||||
# empty destination here so that other lower priority rules might match.
|
||||
cat > /etc/postfix/virtual-alias-maps.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT destination FROM aliases WHERE source='%s'
|
||||
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s' UNION SELECT destination, 2 as priority FROM auto_aliases WHERE source='%s' AND destination<>'') ORDER BY priority LIMIT 1;
|
||||
EOF
|
||||
|
||||
# SQL statement to check if we're sending to a noreply address.
|
||||
cat > /etc/postfix/noreply-addresses.cf << EOF;
|
||||
dbpath=$db_path
|
||||
query = SELECT 'REJECT This address is not ready to receive email.' FROM noreply WHERE email='%s'
|
||||
EOF
|
||||
|
||||
# Restart Services
|
||||
|
@ -104,4 +178,5 @@ EOF
|
|||
restart_service postfix
|
||||
restart_service dovecot
|
||||
|
||||
|
||||
# force a recalculation of all user quotas
|
||||
doveadm quota recalc -A
|
||||
|
|
|
@ -1,9 +1,69 @@
|
|||
#!/bin/bash
|
||||
|
||||
source setup/functions.sh
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
|
||||
hide_output pip3 install rtyaml
|
||||
echo "Installing Mail-in-a-Box system management daemon..."
|
||||
|
||||
# DEPENDENCIES
|
||||
|
||||
# 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.
|
||||
#
|
||||
# gcc and build tools are required to install the latest version
|
||||
# of duplicity
|
||||
apt_install python3-pip python3-gpg virtualenv certbot rsync librsync2 python3-fasteners python3-future python3-lockfile \
|
||||
gcc python3-dev librsync-dev gettext
|
||||
|
||||
apt_get_quiet remove --autoremove --purge duplicity || /bin/true
|
||||
|
||||
# Duplicity does the actual backups.
|
||||
# b2sdk is used for backblaze backups.
|
||||
# boto3 is used for amazon aws backups.
|
||||
# Both are installed outside the pipenv, so they can be used by duplicity
|
||||
hide_output pip3 install --upgrade b2sdk boto3 duplicity
|
||||
|
||||
# Create a virtualenv for the installation of Python 3 packages
|
||||
# used by the management daemon.
|
||||
inst_dir=/usr/local/lib/mailinabox
|
||||
mkdir -p $inst_dir
|
||||
venv=$inst_dir/env
|
||||
if [ ! -d $venv ]; then
|
||||
hide_output virtualenv -ppython3 $venv
|
||||
elif [ ! -f $venv/.oscode ]; then
|
||||
echo "Re-creating Python environment..."
|
||||
rm -rf $venv
|
||||
hide_output virtualenv -ppython3 $venv
|
||||
elif [ "$(cat $venv/.oscode)" != $(get_os_code) ]; then
|
||||
echo "Existing management environment is from an earlier version of the OS you're running."
|
||||
echo "Re-creating Python environment..."
|
||||
rm -rf $venv
|
||||
hide_output virtualenv -ppython3 $venv
|
||||
fi
|
||||
|
||||
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
||||
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==2.2.2" boto psutil postfix-mta-sts-resolver boto3 b2sdk
|
||||
|
||||
# Make the venv use the packaged gpgme bindings (the ones pip provides are severely out-of-date)
|
||||
if [ ! -d $venv/lib/python$(python_version)/site-packages/gpg/ ]; then
|
||||
ln -s /usr/lib/python3/dist-packages/gpg/ $venv/lib/python$(python_version)/site-packages/
|
||||
fi
|
||||
|
||||
echo $(get_os_code) > $venv/.oscode
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
# Create a backup directory and a random key for encrypting backups.
|
||||
mkdir -p $STORAGE_ROOT/backup
|
||||
|
@ -11,24 +71,90 @@ if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then
|
|||
$(umask 077; openssl rand -base64 2048 > $STORAGE_ROOT/backup/secret_key.txt)
|
||||
fi
|
||||
|
||||
# Link the management server daemon into a well known location.
|
||||
rm -f /usr/local/bin/mailinabox-daemon
|
||||
ln -s `pwd`/management/daemon.py /usr/local/bin/mailinabox-daemon
|
||||
|
||||
# Download jQuery and Bootstrap local files
|
||||
|
||||
# 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=3.6.1
|
||||
jquery_url=https://code.jquery.com # Check this link for new versions
|
||||
|
||||
# Get jQuery
|
||||
wget_verify $jquery_url/jquery-$jquery_version.min.js ea61688671d0c3044f2c5b2f2c4af0a6620ac6c2 $assets_dir/jquery.min.js
|
||||
|
||||
# Bootstrap CDN URL
|
||||
# See https://github.com/twbs/bootstrap/releases to check for new versions
|
||||
bootstrap_version=5.2.2
|
||||
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
|
||||
|
||||
# Get Bootstrap
|
||||
wget_verify $bootstrap_url 740b34c22cef5c2f12a34f084b813ea308fedf74 /tmp/bootstrap.zip
|
||||
unzip -q /tmp/bootstrap.zip -d $assets_dir
|
||||
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
|
||||
rm -f /tmp/bootstrap.zip
|
||||
|
||||
# FontAwesome CDN URL
|
||||
# See https://github.com/FortAwesome/Font-Awesome/releases to check for new versions
|
||||
fontawesome_version=6.2.1
|
||||
fontawesome_url=https://github.com/FortAwesome/Font-Awesome/releases/download/$fontawesome_version/fontawesome-free-$fontawesome_version-web.zip
|
||||
|
||||
# Get FontAwesome
|
||||
wget_verify $fontawesome_url cd0f2bcc9653b56e3e2dd82d6598aa6bbca8d796 /tmp/fontawesome.zip
|
||||
unzip -q /tmp/fontawesome.zip -d $assets_dir
|
||||
mv $assets_dir/fontawesome-free-$fontawesome_version-web $assets_dir/fontawesome
|
||||
rm -f /tmp/fontawesome.zip
|
||||
|
||||
# Create an init script to start the management daemon and keep it
|
||||
# running after a reboot.
|
||||
rm -f /etc/init.d/mailinabox
|
||||
ln -s $(pwd)/conf/management-initscript /etc/init.d/mailinabox
|
||||
hide_output update-rc.d mailinabox defaults
|
||||
|
||||
# Perform a daily backup.
|
||||
cat > /etc/cron.daily/mailinabox-backup << EOF;
|
||||
# Set a long timeout since some commands take a while to run, matching
|
||||
# the timeout we set for PHP (fastcgi_read_timeout in the nginx confs).
|
||||
# Note: Authentication currently breaks with more than 1 gunicorn worker.
|
||||
cat > $inst_dir/start <<EOF;
|
||||
#!/bin/bash
|
||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||
# Perform a backup.
|
||||
$(pwd)/management/backup.py
|
||||
EOF
|
||||
chmod +x /etc/cron.daily/mailinabox-backup
|
||||
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
|
||||
export LANGUAGE=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_TYPE=en_US.UTF-8
|
||||
|
||||
# Start it.
|
||||
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
|
||||
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 nightly tasks at 3am in system time: take a backup, run
|
||||
# status checks and email the administrator any changes.
|
||||
|
||||
minute=$((RANDOM % 60)) # avoid overloading mailinabox.email
|
||||
cat > /etc/cron.d/mailinabox-nightly << EOF;
|
||||
# Mail-in-a-Box --- Do not edit / will be overwritten on update.
|
||||
# Run nightly tasks: backup, status checks.
|
||||
$minute 3 * * * root (cd $(pwd) && management/daily_tasks.sh)
|
||||
EOF
|
||||
|
||||
# Start the management server.
|
||||
restart_service mailinabox
|
||||
|
||||
# FOR DEVELOPMENT PURPOSES ONLY:
|
||||
# If there is a CA certificate in the folder, install it.
|
||||
# MIAB will only accept a manual certificate installation
|
||||
# if it is signed by a CA trusted by it.
|
||||
if [[ -f mailinabox-ca.crt ]]; then
|
||||
echo "Custom CA certificate detected. Installing..."
|
||||
rm -f /usr/local/share/ca-certificates/mailinabox-ca.crt
|
||||
cp mailinabox-ca.crt /usr/local/share/ca-certificates/
|
||||
hide_output update-ca-certificates --fresh
|
||||
fi
|
||||
|
|
153
setup/migrate.py
153
setup/migrate.py
|
@ -60,6 +60,144 @@ def migration_5(env):
|
|||
# The secret key for encrypting backups was world readable. Fix here.
|
||||
os.chmod(os.path.join(env["STORAGE_ROOT"], 'backup/secret_key.txt'), 0o600)
|
||||
|
||||
def migration_6(env):
|
||||
# We now will generate multiple DNSSEC keys for different algorithms, since TLDs may
|
||||
# not support them all. .email only supports RSA/SHA-256. Rename the keys.conf file
|
||||
# to be algorithm-specific.
|
||||
basepath = os.path.join(env["STORAGE_ROOT"], 'dns/dnssec')
|
||||
shutil.move(os.path.join(basepath, 'keys.conf'), os.path.join(basepath, 'RSASHA1-NSEC3-SHA1.conf'))
|
||||
|
||||
def migration_7(env):
|
||||
# I previously wanted domain names to be stored in Unicode in the database. Now I want them
|
||||
# to be in IDNA. Affects aliases only.
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/users.sqlite"))
|
||||
|
||||
# Get existing alias source addresses.
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT source FROM aliases')
|
||||
aliases = [ row[0] for row in c.fetchall() ]
|
||||
|
||||
# Update to IDNA-encoded domains.
|
||||
for email in aliases:
|
||||
try:
|
||||
localpart, domainpart = email.split("@")
|
||||
domainpart = domainpart.encode("idna").decode("ascii")
|
||||
newemail = localpart + "@" + domainpart
|
||||
if newemail != email:
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE aliases SET source=? WHERE source=?", (newemail, email))
|
||||
if c.rowcount != 1: raise ValueError("Alias not found.")
|
||||
print("Updated alias", email, "to", newemail)
|
||||
except Exception as e:
|
||||
print("Error updating IDNA alias", email, e)
|
||||
|
||||
# Save.
|
||||
conn.commit()
|
||||
|
||||
def migration_8(env):
|
||||
# Delete DKIM keys. We had generated 1024-bit DKIM keys.
|
||||
# By deleting the key file we'll automatically generate
|
||||
# a new key, which will be 2048 bits.
|
||||
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
|
||||
|
||||
def migration_9(env):
|
||||
# Add a column to the aliases table to store permitted_senders,
|
||||
# which is a list of user account email addresses that are
|
||||
# permitted to send mail using this alias instead of their own
|
||||
# address. This was motivated by the addition of #427 ("Reject
|
||||
# outgoing mail if FROM does not match Login") - which introduced
|
||||
# the notion of outbound permitted-senders.
|
||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||
shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD permitted_senders TEXT"])
|
||||
|
||||
def 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 "noreply" table for noreply addresses.
|
||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||
shell("check_call", ["sqlite3", db, "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);"])
|
||||
|
||||
def migration_15(env):
|
||||
# Add the "auto_aliases" table.
|
||||
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
|
||||
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
|
||||
|
||||
###########################################################
|
||||
|
||||
def get_current_migration():
|
||||
ver = 0
|
||||
while True:
|
||||
|
@ -77,13 +215,22 @@ def run_migrations():
|
|||
env = load_environment()
|
||||
|
||||
migration_id_file = os.path.join(env['STORAGE_ROOT'], 'mailinabox.version')
|
||||
migration_id = None
|
||||
if os.path.exists(migration_id_file):
|
||||
with open(migration_id_file) as f:
|
||||
ourver = int(f.read().strip())
|
||||
else:
|
||||
migration_id = f.read().strip();
|
||||
|
||||
if migration_id is None:
|
||||
# Load the legacy location of the migration ID. We'll drop support
|
||||
# for this eventually.
|
||||
ourver = int(env.get("MIGRATIONID", "0"))
|
||||
migration_id = env.get("MIGRATIONID")
|
||||
|
||||
if migration_id is None:
|
||||
print()
|
||||
print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
|
||||
return
|
||||
|
||||
ourver = int(migration_id)
|
||||
|
||||
while True:
|
||||
next_ver = (ourver + 1)
|
||||
|
|
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
|
||||
management/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
|
|
@ -1,6 +1,6 @@
|
|||
# Install the 'host', 'sed', and and 'nc' tools. This script is run before
|
||||
# the rest of the system setup so we may not yet have things installed.
|
||||
hide_output apt-get -y install bind9-host sed netcat-openbsd
|
||||
apt_get_quiet install bind9-host sed netcat-openbsd
|
||||
|
||||
# Stop if the PRIMARY_HOSTNAME is listed in the Spamhaus Domain Block List.
|
||||
# The user might have chosen a name that was previously in use by a spammer
|
||||
|
|
464
setup/nextcloud.sh
Executable file
464
setup/nextcloud.sh
Executable file
|
@ -0,0 +1,464 @@
|
|||
#!/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=24.0.7
|
||||
nextcloud_hash=7fb1afeb3c212bf5530c3d234b23bf314b47655a
|
||||
|
||||
# Nextcloud apps
|
||||
# --------------
|
||||
# * Find the most recent tag that is compatible with the Nextcloud version above by
|
||||
# consulting the <dependencies>...<nextcloud> node at:
|
||||
# https://github.com/nextcloud-releases/contacts/blob/master/appinfo/info.xml
|
||||
# https://github.com/nextcloud-releases/calendar/blob/master/appinfo/info.xml
|
||||
# https://github.com/nextcloud-releases/user_external
|
||||
# * The hash is the SHA1 hash of the ZIP package, which you can find by just running this script and
|
||||
# copying it from the error message when it doesn't match what is below.
|
||||
contacts_ver=4.2.2
|
||||
contacts_hash=cbab9a7acdc11a9e2779c20b850bb21faec1c80f
|
||||
calendar_ver=3.5.2
|
||||
calendar_hash=dcf2cba6933dc8805ca4b4d04ed7b993ff4652a1
|
||||
user_external_ver=3.0.0
|
||||
user_external_hash=0df781b261f55bbde73d8c92da3f99397000972f
|
||||
|
||||
# Clear prior packages and install dependencies from apt.
|
||||
|
||||
# Just in case someone tries to cross over from the upstream version.
|
||||
apt-get purge -qq -y owncloud* 2> /dev/null || /bin/true
|
||||
|
||||
apt_install php php-fpm \
|
||||
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
|
||||
php-dev php-xml php-mbstring php-zip php-apcu php-json \
|
||||
php-intl php-imagick php-gmp php-bcmath
|
||||
|
||||
phpenmod apcu
|
||||
|
||||
management/editconf.py /etc/php/$(php_version)/mods-available/apcu.ini -c ';' \
|
||||
apc.enabled=1 \
|
||||
apc.enable_cli=1
|
||||
|
||||
management/editconf.py /etc/php/$(php_version)/cli/php.ini -c ';' \
|
||||
apc.enable_cli=1
|
||||
|
||||
InstallNextcloud() {
|
||||
|
||||
version=$1
|
||||
hash=$2
|
||||
version_contacts=$3
|
||||
hash_contacts=$4
|
||||
version_calendar=$5
|
||||
hash_calendar=$6
|
||||
version_user_external=${7:-}
|
||||
hash_user_external=${8:-}
|
||||
|
||||
echo
|
||||
echo "Upgrading to Nextcloud version $version"
|
||||
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
|
||||
|
||||
IFS='.'
|
||||
read -a checkVer <<< "$version_contacts"
|
||||
unset IFS
|
||||
if [ "${checkVer[0]}" -gt 4 ] || [ "${checkVer[0]}" -eq 4 -a "${checkVer[1]}" -gt 0 ] || [ "${checkVer[0]}" -eq 4 -a "${checkVer[2]}" -gt 0 ]; then
|
||||
# Contacts 4.0.1 and later are downloaded from here
|
||||
wget_verify https://github.com/nextcloud-releases/contacts/releases/download/v$version_contacts/contacts-v$version_contacts.tar.gz $hash_contacts /tmp/contacts.tgz
|
||||
else
|
||||
# 4.0.0 and earlier are downloaded from here
|
||||
wget_verify https://github.com/Nextcloud/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz
|
||||
fi
|
||||
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
|
||||
rm /tmp/contacts.tgz
|
||||
|
||||
IFS='.'
|
||||
read -a checkVer <<< "$version_calendar"
|
||||
unset IFS
|
||||
if [ "${checkVer[0]}" -eq 2 -a "${checkVer[1]}" -gt 2 ] || [ "${checkVer[0]}" -gt 2 ]; then
|
||||
# Calendar 2.3.0 and later are downloaded from here
|
||||
wget_verify https://github.com/nextcloud-releases/calendar/releases/download/v$version_calendar/calendar-v$version_calendar.tar.gz $hash_calendar /tmp/calendar.tgz
|
||||
else
|
||||
wget_verify https://github.com/nextcloud/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz
|
||||
fi
|
||||
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
|
||||
rm /tmp/calendar.tgz
|
||||
|
||||
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
||||
# we will install from their github repository.
|
||||
if [ -n "$version_user_external" ]; then
|
||||
IFS='.'
|
||||
read -a checkVer <<< "$version_user_external"
|
||||
unset IFS
|
||||
if [ "${checkVer[0]}" -gt 2 ]; then
|
||||
wget_verify https://github.com/nextcloud-releases/user_external/releases/download/v$version_user_external/user_external-v$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
|
||||
else
|
||||
wget_verify https://github.com/nextcloud/user_external/releases/download/v$version_user_external/user_external-$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
|
||||
fi
|
||||
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
|
||||
rm /tmp/user_external.tgz
|
||||
fi
|
||||
|
||||
# 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 /usr/local/lib/owncloud/occ upgrade
|
||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then
|
||||
echo "Trying ownCloud upgrade again to work around ownCloud upgrade bug..."
|
||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
||||
sudo -u www-data php /usr/local/lib/owncloud/occ maintenance:mode --off
|
||||
echo "...which seemed to work."
|
||||
fi
|
||||
|
||||
# Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.
|
||||
sudo -u www-data php /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 /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.
|
||||
#
|
||||
# Config unlocking, power-mailinabox#86
|
||||
# If a configuration file already exists, remove the "readonly" tag before starting the upgrade. This is
|
||||
# necessary (otherwise upgrades will fail).
|
||||
#
|
||||
# The lock will be re-applied further down the line when it's safe to do so.
|
||||
CONFIG_TEMP=$(/bin/mktemp)
|
||||
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
|
||||
CURRENT_NEXTCLOUD_VER=$(php -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
|
||||
# Unlock configuration directory for upgrades
|
||||
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
||||
<?php
|
||||
include("$STORAGE_ROOT/owncloud/config.php");
|
||||
|
||||
\$CONFIG['config_is_read_only'] = false;
|
||||
|
||||
echo "<?php\n\\\$CONFIG = ";
|
||||
var_export(\$CONFIG);
|
||||
echo ";";
|
||||
?>
|
||||
EOF
|
||||
else
|
||||
CURRENT_NEXTCLOUD_VER=""
|
||||
fi
|
||||
|
||||
# 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 theyre not running (which happens on a previously failed install), dont bail.
|
||||
service php$(php_version)-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} =~ ^13 ]]; then
|
||||
# If we are running Nextcloud 13, upgrade to Nextcloud 14
|
||||
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466
|
||||
CURRENT_NEXTCLOUD_VER="14.0.6"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
|
||||
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
|
||||
# We will disable it here before the upgrade and install it again after the upgrade.
|
||||
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
|
||||
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||
CURRENT_NEXTCLOUD_VER="15.0.8"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
||||
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||
CURRENT_NEXTCLOUD_VER="16.0.6"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
|
||||
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||
CURRENT_NEXTCLOUD_VER="17.0.6"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then
|
||||
# Don't exit the install if this column already exists (see #2076)
|
||||
(echo "ALTER TABLE oc_flow_operations ADD COLUMN entity VARCHAR;" | sqlite3 $STORAGE_ROOT/owncloud/owncloud.db 2>/dev/null) || true
|
||||
InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||
CURRENT_NEXTCLOUD_VER="18.0.10"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
|
||||
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||
CURRENT_NEXTCLOUD_VER="19.0.4"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^19 ]]; then
|
||||
InstallNextcloud 20.0.14 92cac708915f51ee2afc1787fd845476fd090c81 4.0.8 9f368bb2be98c5555b7118648f4cc9fa51e8cb30 3.0.6 ca49bb1ce23f20e10911e39055fd59d7f7a84c30 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||
# Nextcloud 20 needs to have some optional columns added
|
||||
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns
|
||||
|
||||
CURRENT_NEXTCLOUD_VER="20.0.14"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^20 ]]; then
|
||||
InstallNextcloud 21.0.9 cf8785107c3c079a1f450743558f4f13c85f37a8 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 2.1.0 6e5afe7f36f398f864bfdce9cad72200e70322aa
|
||||
CURRENT_NEXTCLOUD_VER="21.0.9"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^21 ]]; then
|
||||
InstallNextcloud 22.2.6 9d39741f051a8da42ff7df46ceef2653a1dc70d9 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
|
||||
CURRENT_NEXTCLOUD_VER="22.2.6"
|
||||
fi
|
||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^22 ]]; then
|
||||
InstallNextcloud 23.0.4 87afec0bf90b3c66289e6fedd851867bc5a58f01 4.1.0 38653b507bd7d953816bbc5e8bea7855867eb1cd 3.2.2 54e9a836adc739be4a2a9301b8d6d2e9d88e02f4 3.0.0 0df781b261f55bbde73d8c92da3f99397000972f
|
||||
CURRENT_NEXTCLOUD_VER="23.0.4"
|
||||
fi
|
||||
fi
|
||||
|
||||
InstallNextcloud $nextcloud_ver $nextcloud_hash $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',
|
||||
'dbname' => 'owncloud',
|
||||
|
||||
# create an administrator account with a random password so that
|
||||
# the user does not have to enter anything on first load of Nextcloud
|
||||
'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 /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)
|
||||
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
||||
<?php
|
||||
include("$STORAGE_ROOT/owncloud/config.php");
|
||||
|
||||
\$CONFIG['config_is_read_only'] = true;
|
||||
|
||||
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
||||
|
||||
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
|
||||
\$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 /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
|
||||
|
||||
# When upgrading, run the upgrade script again now that apps are enabled. It seems like
|
||||
# the first upgrade at the top won't work because apps may be disabled during upgrade?
|
||||
# Check for success (0=ok, 3=no upgrade needed).
|
||||
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
|
||||
if [ \( $? -ne 0 \) -a \( $? -ne 3 \) ]; then exit 1; fi
|
||||
|
||||
# Disable default apps that we don't support
|
||||
sudo -u www-data \
|
||||
php /usr/local/lib/owncloud/occ app:disable photos dashboard activity \
|
||||
| (grep -v "No such app enabled" || /bin/true)
|
||||
|
||||
# Set PHP FPM values to support large file uploads
|
||||
# (semicolon is the comment character in this file, hashes produce deprecation warnings)
|
||||
management/editconf.py /etc/php/$(php_version)/fpm/php.ini -c ';' \
|
||||
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
|
||||
management/editconf.py /etc/php/$(php_version)/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 -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_version)-fpm
|
|
@ -1,141 +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=7.0.2
|
||||
|
||||
# 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
|
||||
|
||||
echo installing ownCloud...
|
||||
rm -f /tmp/owncloud.zip
|
||||
wget -qO /tmp/owncloud.zip https://download.owncloud.org/community/owncloud-$owncloud_ver.zip
|
||||
unzip -u -o -q /tmp/owncloud.zip -d /usr/local/lib #either extracts new or replaces current files
|
||||
hide_output php /usr/local/lib/owncloud/occ upgrade #if OC is up-to-date it wont matter
|
||||
rm -f /tmp/owncloud.zip
|
||||
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.
|
||||
hide_output php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
|
||||
hide_output php /usr/local/lib/owncloud/console.php app:enable user_external
|
||||
|
||||
# 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
|
54
setup/pgp.sh
Executable file
54
setup/pgp.sh
Executable file
|
@ -0,0 +1,54 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Daemon PGP Keyring
|
||||
# ------------------
|
||||
#
|
||||
# Initializes the PGP keyring at /home/user-data/.gnupg
|
||||
# For this, we will generate a new PGP keypair (if one isn't already present)
|
||||
|
||||
source setup/functions.sh # load our functions
|
||||
source /etc/mailinabox.conf # load global vars
|
||||
export GNUPGHOME # Dump into the environment so that gpg uses it as homedir
|
||||
|
||||
# Install gnupg
|
||||
apt_install gnupg
|
||||
|
||||
function gpg_keygen {
|
||||
# Generates a private key.
|
||||
gpg --generate-key --batch 2> /tmp/gpg_keygen_log << EOF;
|
||||
%no-protection
|
||||
Key-Type: RSA
|
||||
Key-Length: 4096
|
||||
Key-Usage: sign,encrypt,auth
|
||||
Name-Real: System Management Daemon
|
||||
Name-Email: noreply-daemon@${PRIMARY_HOSTNAME}
|
||||
Expire-Date: 180d
|
||||
%commit
|
||||
EOF
|
||||
}
|
||||
|
||||
# Generate a new key if:
|
||||
# - There isn't a fingerprint on /etc/mailinabox.conf
|
||||
# - The configured fingerprint doesn't actually exist
|
||||
|
||||
if [ -z "${PGPKEY:-}" -o "$(gpg --list-secret-keys 2> /dev/null | grep ${PGPKEY:-\r\n})" = "" ]; then
|
||||
echo "No keypair found. Generating daemon's PGP keypair..."
|
||||
gpg_keygen
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Key generation failed!" 1>&2
|
||||
echo "============================" 1>&2
|
||||
cat /tmp/gpg_keygen_log 1>&2
|
||||
echo "============================" 1>&2
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FPR=$(cat /tmp/gpg_keygen_log | tr -d '\n' | sed -r 's/.*([0-9A-F]{40}).*/\1/g')
|
||||
echo "Generated key with fingerprint $FPR"
|
||||
|
||||
chown -R root:root $GNUPGHOME
|
||||
# Remove the old key fingerprint from the configuration if it exists, and add the new one
|
||||
echo "$(cat /etc/mailinabox.conf | grep -v "PGPKEY")" > /etc/mailinabox.conf
|
||||
echo "PGPKEY=$FPR" >> /etc/mailinabox.conf
|
||||
fi
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue