Compare commits
6 commits
main
...
dev-nginx-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6848e0056d | ||
![]() |
ab31dc9011 | ||
![]() |
ee2428742b | ||
![]() |
84bfa84ff8 | ||
![]() |
205b6784a1 | ||
![]() |
24264ff852 |
69 changed files with 5153 additions and 7551 deletions
|
@ -4,23 +4,30 @@
|
||||||
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[Vagrantfile]
|
[*]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 4
|
||||||
|
|
||||||
[*.rb]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[**.html]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[**.py]
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[**]
|
|
||||||
indent_style = tab
|
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Vagrantfile]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.rb]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.js]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
|
5
.gitattributes
vendored
5
.gitattributes
vendored
|
@ -1,5 +0,0 @@
|
||||||
# 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
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
ko_fi: davness
|
|
|
@ -1,3 +0,0 @@
|
||||||
[style]
|
|
||||||
based_on_style = pep8
|
|
||||||
use_tabs = True
|
|
80
CHANGELOG.md
80
CHANGELOG.md
|
@ -1,86 +1,6 @@
|
||||||
CHANGELOG
|
CHANGELOG
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Version 60.1 (October 30, 2022)
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
* A setup issue where the DNS server nsd isn't running at the end of setup is (hopefully) fixed.
|
|
||||||
* Nextcloud is updated to 23.0.10 (contacts to 4.2.2, calendar to 3.5.1).
|
|
||||||
|
|
||||||
Version 60 (October 11, 2022)
|
|
||||||
-----------------------------
|
|
||||||
|
|
||||||
This is the first release for Ubuntu 22.04.
|
|
||||||
|
|
||||||
**Before upgrading**, you must **first upgrade your existing Ubuntu 18.04 box to Mail-in-a-Box v0.51 or later**, if you haven't already done so. That may not be possible after Ubuntu 18.04 reaches its end of life in April 2023, so please complete the upgrade well before then. (If you are not using Nextcloud's contacts or calendar, you can migrate to the latest version of Mail-in-a-Box from any previous version.)
|
|
||||||
|
|
||||||
For complete upgrade instructions, see:
|
|
||||||
|
|
||||||
https://discourse.mailinabox.email/t/version-60-for-ubuntu-22-04-is-about-to-be-released/9558
|
|
||||||
|
|
||||||
No major features of Mail-in-a-Box have changed in this release, although some minor fixes were made.
|
|
||||||
|
|
||||||
With the newer version of Ubuntu the following software packages we use are updated:
|
|
||||||
|
|
||||||
* dovecot is upgraded to 2.3.16, postfix to 3.6.4, opendmark to 1.4 (which adds ARC-Authentication-Results headers), and spampd to 2.53 (alleviating a mail delivery rate limiting bug).
|
|
||||||
* Nextcloud is upgraded to 23.0.4 (contacts to 4.2.0, calendar to 3.5.0).
|
|
||||||
* Roundcube is upgraded to 1.6.0.
|
|
||||||
* certbot is upgraded to 1.21 (via the Ubuntu repository instead of a PPA).
|
|
||||||
* fail2ban is upgraded to 0.11.2.
|
|
||||||
* nginx is upgraded to 1.18.
|
|
||||||
* PHP is upgraded from 7.2 to 8.0.
|
|
||||||
|
|
||||||
Also:
|
|
||||||
|
|
||||||
* Roundcube's login session cookie was tightened. Existing sessions may require a manual logout.
|
|
||||||
* Moved Postgrey's database under $STORAGE_ROOT.
|
|
||||||
|
|
||||||
Version 57a (June 19, 2022)
|
|
||||||
---------------------------
|
|
||||||
|
|
||||||
* The Backblaze backups fix posted in Version 57 was incomplete. It's now fixed.
|
|
||||||
|
|
||||||
Version 57 (June 12, 2022)
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
Setup:
|
|
||||||
|
|
||||||
* Fixed issue upgrading from Mail-in-a-Box v0.40-v0.50 because of a changed URL that Nextcloud is downloaded from.
|
|
||||||
|
|
||||||
Backups:
|
|
||||||
|
|
||||||
* Fixed S3 backups which broke with duplicity 0.8.23.
|
|
||||||
* Fixed Backblaze backups which broke with latest b2sdk package by rolling back its version.
|
|
||||||
|
|
||||||
Control panel:
|
|
||||||
|
|
||||||
* Fixed spurious changes in system status checks messages by sorting DNSSEC DS records.
|
|
||||||
* Fixed fail2ban lockout over IPv6 from excessive loads of the system status checks.
|
|
||||||
* Fixed an incorrect IPv6 system status check message.
|
|
||||||
|
|
||||||
Version 56 (January 19, 2022)
|
|
||||||
-----------------------------
|
|
||||||
|
|
||||||
Software updates:
|
|
||||||
|
|
||||||
* Roundcube updated to 1.5.2 (from 1.5.0), and the persistent_login and CardDAV (to 4.3.0 from 3.0.3) plugins are updated.
|
|
||||||
* Nextcloud updated to 20.0.14 (from 20.0.8), contacts to 4.0.7 (from 3.5.1), and calendar to 3.0.4 (from 2.2.0).
|
|
||||||
|
|
||||||
Setup:
|
|
||||||
|
|
||||||
* Fixed failed setup if a previous attempt failed while updating Nextcloud.
|
|
||||||
|
|
||||||
Control panel:
|
|
||||||
|
|
||||||
* Fixed a crash if a custom DNS entry is not under a zone managed by the box.
|
|
||||||
* Fix DNSSEC instructions typo.
|
|
||||||
|
|
||||||
Other:
|
|
||||||
|
|
||||||
* Set systemd journald log retention to 10 days (from no limit) to reduce disk usage.
|
|
||||||
* Fixed log processing for submission lines that have a sasl_sender or other extra information.
|
|
||||||
* Fix DNS secondary nameserver refesh failure retry period.
|
|
||||||
|
|
||||||
Version 55 (October 18, 2021)
|
Version 55 (October 18, 2021)
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
|
|
@ -20,9 +20,9 @@ _If you're seeing an error message about your *IP address being listed in the Sp
|
||||||
|
|
||||||
### Modifying your `hosts` file
|
### 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:
|
After a while, Mail-in-a-Box will be available at `192.168.50.4` (unless you changed that in your `Vagrantfile`). To be able to use the web-based bits, we recommend to add a hostname to your `hosts` file:
|
||||||
|
|
||||||
$ echo "192.168.56.4 mailinabox.lan" | sudo tee -a /etc/hosts
|
$ echo "192.168.50.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`.
|
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`.
|
||||||
|
|
||||||
|
|
36
README.md
36
README.md
|
@ -1,8 +1,5 @@
|
||||||
# Power Mail-in-a-Box
|
# Power Mail-in-a-Box
|
||||||
## **[Installation](#installation)** (current version: v60.5)
|
**[Installation](#installation)** (current version: v55.0)
|
||||||
## **[Upgrading Quick Start](#upgrading)**
|
|
||||||
|
|
||||||
[](https://ko-fi.com/davness)
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
@ -20,7 +17,7 @@ It's main difference to the main project is focused on ad-hoc, advanced features
|
||||||
|
|
||||||
## Goals
|
## 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.
|
- **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.
|
- **Privacy, security and independence** - keeping your mail safe from the big companies.
|
||||||
- **Accessible customizability** - bring the features closer to the people instead of tucking them away in configuration files.
|
- **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.
|
- **Customizability potential** - allow for deep customization by power users.
|
||||||
- **Concentration** - all the services you need in just one box.
|
- **Concentration** - all the services you need in just one box.
|
||||||
|
@ -39,13 +36,10 @@ The machine this appliance will be installed on needs to have the following spec
|
||||||
- 512MB of RAM (**at least 1GB** is recommended);
|
- 512MB of RAM (**at least 1GB** is recommended);
|
||||||
- 10GB of disk;
|
- 10GB of disk;
|
||||||
- **One of the following operating systems:**
|
- **One of the following operating systems:**
|
||||||
- - Debian GNU/Linux 11 (Bullseye)
|
- - Debian GNU/Linux 10 (buster)
|
||||||
|
- - Debian GNU/Linux 11 (bullseye)
|
||||||
- - Ubuntu LTS 20.04 (Focal Fossa)
|
- - Ubuntu LTS 20.04 (Focal Fossa)
|
||||||
- - Ubuntu LTS 22.04 (Jammy Jellyfish)
|
- - <small> Ubuntu LTS 18.04 (Bionic Beaver) is not supported</small>
|
||||||
|
|
||||||
## Legacy Support
|
|
||||||
The following distributions are no longer supported for the latest version, but they used to be supported at a earlier time:
|
|
||||||
- **Debian 10 (Buster)** <= **v56.5**
|
|
||||||
|
|
||||||
<small>_These network requirements are usually not provided by residential ISP's. They are not **strictly required** for Power Mail-in-a-Box to install, but it will take more work to get it running as intended._</small>
|
<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);
|
- Static, public IPv4 (most residential connections **do not** provide static addresses);
|
||||||
|
@ -79,23 +73,5 @@ sudo dpkg-reconfigure locales
|
||||||
|
|
||||||
3. Run the following command, and then follow the instructions that appear on the screen:
|
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
|
curl https://raw.githubusercontent.com/ddavness/power-mailinabox/master/setup/bootstrap.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
# Upgrading
|
|
||||||
|
|
||||||
To upgrade an existing box to the latest version, run the same command as you do to perform a new installation:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -L https://power-mailinabox.net/setup.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
19
Vagrantfile
vendored
19
Vagrantfile
vendored
|
@ -15,31 +15,27 @@ machines = [
|
||||||
{
|
{
|
||||||
'iso' => "debian/bullseye64",
|
'iso' => "debian/bullseye64",
|
||||||
'host' => "bullseye"
|
'host' => "bullseye"
|
||||||
},
|
}
|
||||||
{
|
|
||||||
'iso' => "generic/ubuntu2204",
|
|
||||||
'host' => "jammy"
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
Vagrant.configure("2") do |config|
|
Vagrant.configure("2") do |config|
|
||||||
config.vm.provider :virtualbox do |vb|
|
config.vm.provider :virtualbox do |vb|
|
||||||
vb.customize ["modifyvm", :id, "--cpus", 1, "--memory", 768]
|
vb.customize ["modifyvm", :id, "--cpus", 1, "--memory", 512]
|
||||||
end
|
end
|
||||||
config.vm.provider :libvirt do |v|
|
config.vm.provider :libvirt do |v|
|
||||||
v.memory = 768
|
v.memory = 512
|
||||||
v.cpus = 1
|
v.cpus = 1
|
||||||
v.nested = true
|
v.nested = true
|
||||||
end
|
end
|
||||||
config.vm.provider :kvm do |kvm|
|
config.vm.provider :kvm do |kvm|
|
||||||
kvm.memory_size = '768m'
|
kvm.memory_size = '512m'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Network config: Since it's a mail server, the machine must be connected
|
# Network config: Since it's a mail server, the machine must be connected
|
||||||
# to the public web. However, we currently don't want to expose SSH since
|
# to the public web. However, we currently don't want to expose SSH since
|
||||||
# the machine's box will let anyone log into it. So instead we'll put the
|
# the machine's box will let anyone log into it. So instead we'll put the
|
||||||
# machine on a private network.
|
# machine on a private network.
|
||||||
config.vm.synced_folder ".", "/vagrant", nfs_version: "4"
|
config.vm.synced_folder ".", "/vagrant", nfs_version: "3"
|
||||||
|
|
||||||
(0..(machines.size - 1)).each do |n|
|
(0..(machines.size - 1)).each do |n|
|
||||||
node = machines[n]
|
node = machines[n]
|
||||||
|
@ -49,8 +45,9 @@ Vagrant.configure("2") do |config|
|
||||||
m.vm.network "private_network", ip: "192.168.168.#{ip+n}"
|
m.vm.network "private_network", ip: "192.168.168.#{ip+n}"
|
||||||
|
|
||||||
m.vm.provision "shell", :inline => <<-SH
|
m.vm.provision "shell", :inline => <<-SH
|
||||||
git config --global --add safe.directory /vagrant
|
# Make sure we have IPv6 loopback (::1)
|
||||||
|
sysctl -w net.ipv6.conf.lo.disable_ipv6=0
|
||||||
|
echo -e "fs.inotify.max_user_instances=1024\nnet.ipv6.conf.lo.disable_ipv6=0" > /etc/sysctl.conf
|
||||||
# Set environment variables so that the setup script does
|
# Set environment variables so that the setup script does
|
||||||
# not ask any questions during provisioning. We'll let the
|
# not ask any questions during provisioning. We'll let the
|
||||||
# machine figure out its own public IP.
|
# machine figure out its own public IP.
|
||||||
|
|
|
@ -15,7 +15,7 @@ info:
|
||||||
license:
|
license:
|
||||||
name: CC0 1.0 Universal
|
name: CC0 1.0 Universal
|
||||||
url: https://creativecommons.org/publicdomain/zero/1.0/legalcode
|
url: https://creativecommons.org/publicdomain/zero/1.0/legalcode
|
||||||
version: 60.5
|
version: 55.0
|
||||||
x-logo:
|
x-logo:
|
||||||
url: https://mailinabox.email/static/logo.png
|
url: https://mailinabox.email/static/logo.png
|
||||||
altText: Mail-in-a-Box logo
|
altText: Mail-in-a-Box logo
|
||||||
|
@ -77,7 +77,7 @@ paths:
|
||||||
x-codeSamples:
|
x-codeSamples:
|
||||||
- lang: curl
|
- lang: curl
|
||||||
source: |
|
source: |
|
||||||
curl -X POST "https://{host}/admin/login" \
|
curl -X GET "https://{host}/admin/login" \
|
||||||
-u "<email>:<password>"
|
-u "<email>:<password>"
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
|
@ -109,15 +109,13 @@ paths:
|
||||||
x-codeSamples:
|
x-codeSamples:
|
||||||
- lang: curl
|
- lang: curl
|
||||||
source: |
|
source: |
|
||||||
curl -X POST "https://{host}/admin/logout" \
|
curl -X GET "https://{host}/admin/logout" \
|
||||||
-u "<email>:<session_key>"
|
-u "<email>:<session_key>"
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: Successful operation
|
description: Successful operation
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/LogoutResponse'
|
|
||||||
/system/status:
|
/system/status:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
@ -3417,8 +3415,3 @@ components:
|
||||||
nullable: true
|
nullable: true
|
||||||
MfaDisableSuccessResponse:
|
MfaDisableSuccessResponse:
|
||||||
type: string
|
type: string
|
||||||
LogoutResponse:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
# Whitelist our own IP addresses. 127.0.0.1/8 is the default. But our status checks
|
# 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
|
# ping services over the public interface so we should whitelist that address of
|
||||||
# ours too. The string is substituted during installation.
|
# ours too. The string is substituted during installation.
|
||||||
ignoreip = 127.0.0.1/8 PUBLIC_IP ::1 PUBLIC_IPV6
|
ignoreip = 127.0.0.1/8 PUBLIC_IP
|
||||||
|
|
||||||
[dovecot]
|
[dovecot]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
|
@ -4,7 +4,6 @@ After=multi-user.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=idle
|
Type=idle
|
||||||
IgnoreSIGPIPE=False
|
|
||||||
ExecStart=/usr/local/lib/mailinabox/start
|
ExecStart=/usr/local/lib/mailinabox/start
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
version: STSv1
|
version: STSv1
|
||||||
mode: MODE
|
mode: MODE
|
||||||
mx: PRIMARY_HOSTNAME
|
mx: PRIMARY_HOSTNAME
|
||||||
max_age: 604800
|
max_age: 604800
|
||||||
|
|
|
@ -90,7 +90,7 @@
|
||||||
<br><br>
|
<br><br>
|
||||||
|
|
||||||
<h2 align=center>Oh and by the way: thank you for sticking by!</h2>
|
<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>.
|
<p align=center>This project is maintained <a href="https://github.com/ddavness/power-mailinabox">on GitHub</a>.
|
||||||
Feel free to take a peek and maybe leave a star. It is very much appreciated! ❤</p>
|
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>
|
<p align=center>Also feel free report any issues you may find, or leave your ideas.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,4 +103,4 @@
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,9 +1,4 @@
|
||||||
import base64
|
import base64, os, os.path, hmac, json, secrets
|
||||||
import os
|
|
||||||
import os.path
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from expiringdict import ExpiringDict
|
from expiringdict import ExpiringDict
|
||||||
|
@ -12,27 +7,35 @@ import utils
|
||||||
from mailconfig import get_mail_password, get_mail_user_privileges
|
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||||
from mfa import get_hash_mfa_state, validate_auth_mfa
|
from mfa import get_hash_mfa_state, validate_auth_mfa
|
||||||
|
|
||||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||||
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.auth_realm = DEFAULT_AUTH_REALM
|
self.auth_realm = DEFAULT_AUTH_REALM
|
||||||
self.key_path = DEFAULT_KEY_PATH
|
self.key_path = DEFAULT_KEY_PATH
|
||||||
self.max_session_duration = timedelta(days=2)
|
self.max_session_duration = timedelta(days=2)
|
||||||
|
|
||||||
self.init_system_api_key()
|
self.init_system_api_key()
|
||||||
self.sessions = ExpiringDict(
|
self.sessions = ExpiringDict(max_len=64, max_age_seconds=self.max_session_duration.total_seconds())
|
||||||
max_len=64,
|
|
||||||
max_age_seconds=self.max_session_duration.total_seconds())
|
|
||||||
|
|
||||||
def init_system_api_key(self):
|
def init_system_api_key(self):
|
||||||
"""Write an API key to a local file so local processes can use the API"""
|
"""Write an API key to a local file so local processes can use the API"""
|
||||||
|
|
||||||
with open(self.key_path, 'r') as file:
|
def create_file_with_mode(path, mode):
|
||||||
self.key = file.read()
|
# 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)
|
||||||
|
|
||||||
|
self.key = secrets.token_hex(32)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
|
||||||
|
|
||||||
|
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):
|
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,
|
"""Test if the HTTP Authorization header's username matches the system key, a session key,
|
||||||
|
@ -43,10 +46,8 @@ class AuthService:
|
||||||
this key is not associated with a user."""
|
this key is not associated with a user."""
|
||||||
|
|
||||||
def parse_http_authorization_basic(header):
|
def parse_http_authorization_basic(header):
|
||||||
|
|
||||||
def decode(s):
|
def decode(s):
|
||||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||||
|
|
||||||
if " " not in header:
|
if " " not in header:
|
||||||
return None, None
|
return None, None
|
||||||
scheme, credentials = header.split(maxsplit=1)
|
scheme, credentials = header.split(maxsplit=1)
|
||||||
|
@ -58,15 +59,12 @@ class AuthService:
|
||||||
username, password = credentials.split(':', maxsplit=1)
|
username, password = credentials.split(':', maxsplit=1)
|
||||||
return username, password
|
return username, password
|
||||||
|
|
||||||
username, password = parse_http_authorization_basic(
|
username, password = parse_http_authorization_basic(request.headers.get('Authorization', ''))
|
||||||
request.headers.get('Authorization', ''))
|
|
||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
raise ValueError("Authorization header invalid.")
|
raise ValueError("Authorization header invalid.")
|
||||||
|
|
||||||
if username.strip() == "" and password.strip() == "":
|
if username.strip() == "" and password.strip() == "":
|
||||||
raise ValueError(
|
raise ValueError("No email address, password, session key, or API key provided.")
|
||||||
"No email address, password, session key, or API key provided."
|
|
||||||
)
|
|
||||||
|
|
||||||
# If user passed the system API key, grant administrative privs. This key
|
# If user passed the system API key, grant administrative privs. This key
|
||||||
# is not associated with a user.
|
# is not associated with a user.
|
||||||
|
@ -74,8 +72,7 @@ class AuthService:
|
||||||
return (None, ["admin"])
|
return (None, ["admin"])
|
||||||
|
|
||||||
# If the password corresponds with a session token for the user, grant access for that user.
|
# If the password corresponds with a session token for the user, grant access for that user.
|
||||||
if self.get_session(username, password, "login",
|
if self.get_session(username, password, "login", env) and not login_only:
|
||||||
env) and not login_only:
|
|
||||||
sessionid = password
|
sessionid = password
|
||||||
session = self.sessions[sessionid]
|
session = self.sessions[sessionid]
|
||||||
if logout:
|
if logout:
|
||||||
|
@ -99,8 +96,7 @@ class AuthService:
|
||||||
# deleted after the session was granted. On error the call will return a tuple
|
# deleted after the session was granted. On error the call will return a tuple
|
||||||
# of an error message and an HTTP status code.
|
# of an error message and an HTTP status code.
|
||||||
privs = get_mail_user_privileges(username, env)
|
privs = get_mail_user_privileges(username, env)
|
||||||
if isinstance(privs, tuple):
|
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||||
raise ValueError(privs[0])
|
|
||||||
|
|
||||||
# Return the authorization information.
|
# Return the authorization information.
|
||||||
return (username, privs)
|
return (username, privs)
|
||||||
|
@ -124,13 +120,10 @@ class AuthService:
|
||||||
# a non-zero exit status if the credentials are no good,
|
# a non-zero exit status if the credentials are no good,
|
||||||
# and check_call will raise an exception in that case.
|
# and check_call will raise an exception in that case.
|
||||||
utils.shell('check_call', [
|
utils.shell('check_call', [
|
||||||
"/usr/bin/doveadm",
|
"/usr/bin/doveadm", "pw",
|
||||||
"pw",
|
"-p", pw,
|
||||||
"-p",
|
"-t", pw_hash,
|
||||||
pw,
|
])
|
||||||
"-t",
|
|
||||||
pw_hash,
|
|
||||||
])
|
|
||||||
except:
|
except:
|
||||||
# Login failed.
|
# Login failed.
|
||||||
raise ValueError("Incorrect email address or password.")
|
raise ValueError("Incorrect email address or password.")
|
||||||
|
@ -148,8 +141,7 @@ class AuthService:
|
||||||
|
|
||||||
# Add to the message the current MFA state, which is a list of MFA information.
|
# Add to the message the current MFA state, which is a list of MFA information.
|
||||||
# Turn it into a string stably.
|
# Turn it into a string stably.
|
||||||
msg += b" " + json.dumps(get_hash_mfa_state(email, env),
|
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
|
||||||
sort_keys=True).encode("utf8")
|
|
||||||
|
|
||||||
# Make a HMAC using the system API key as a hash key.
|
# Make a HMAC using the system API key as a hash key.
|
||||||
hash_key = self.key.encode('ascii')
|
hash_key = self.key.encode('ascii')
|
||||||
|
@ -160,21 +152,15 @@ class AuthService:
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
self.sessions[token] = {
|
self.sessions[token] = {
|
||||||
"email": username,
|
"email": username,
|
||||||
"password_token":
|
"password_token": self.create_user_password_state_token(username, env),
|
||||||
self.create_user_password_state_token(username, env),
|
|
||||||
"type": type,
|
"type": type,
|
||||||
}
|
}
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def get_session(self, user_email, session_key, session_type, env):
|
def get_session(self, user_email, session_key, session_type, env):
|
||||||
if session_key not in self.sessions:
|
if session_key not in self.sessions: return None
|
||||||
return None
|
|
||||||
session = self.sessions[session_key]
|
session = self.sessions[session_key]
|
||||||
if session_type == "login" and session["email"] != user_email:
|
if session_type == "login" and session["email"] != user_email: return None
|
||||||
return None
|
if session["type"] != session_type: return None
|
||||||
if session["type"] != session_type:
|
if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None
|
||||||
return None
|
|
||||||
if session["password_token"] != self.create_user_password_state_token(
|
|
||||||
session["email"], env):
|
|
||||||
return None
|
|
||||||
return session
|
return session
|
||||||
|
|
|
@ -7,53 +7,52 @@
|
||||||
# 4) The stopped services are restarted.
|
# 4) The stopped services are restarted.
|
||||||
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
|
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
|
||||||
|
|
||||||
import os
|
import os, os.path, shutil, glob, re, datetime, sys
|
||||||
import os.path
|
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||||
import shutil
|
|
||||||
import glob
|
|
||||||
import re
|
|
||||||
import datetime
|
|
||||||
import sys
|
|
||||||
import dateutil.parser
|
|
||||||
import dateutil.relativedelta
|
|
||||||
import dateutil.tz
|
|
||||||
import rtyaml
|
import rtyaml
|
||||||
from exclusiveprocess import Lock, CannotAcquireLock
|
from exclusiveprocess import Lock, CannotAcquireLock
|
||||||
|
|
||||||
from utils import load_environment, shell, wait_for_service, get_php_version
|
from utils import load_environment, shell, wait_for_service, fix_boto, get_php_version, get_os_code
|
||||||
|
|
||||||
|
def rsync_ssh_options(port = 22, direct = False):
|
||||||
|
# Just in case we pass a string
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except Exception:
|
||||||
|
port = 22
|
||||||
|
|
||||||
|
if direct:
|
||||||
|
return f"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab"
|
||||||
|
else:
|
||||||
|
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\"",
|
||||||
|
]
|
||||||
|
|
||||||
def backup_status(env):
|
def backup_status(env):
|
||||||
# If backups are disabled, return no status.
|
# If backups are disabled, return no status.
|
||||||
config = get_backup_config(env)
|
config = get_backup_config(env)
|
||||||
if config["target"] == "off":
|
if config["target"] == "off":
|
||||||
return {}
|
return { }
|
||||||
|
|
||||||
# Query duplicity to get a list of all full and incremental
|
# Query duplicity to get a list of all full and incremental
|
||||||
# backups available.
|
# backups available.
|
||||||
|
|
||||||
backups = {}
|
backups = { }
|
||||||
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
now = datetime.datetime.now(dateutil.tz.tzlocal())
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
|
||||||
def reldate(date, ref, clip):
|
def reldate(date, ref, clip):
|
||||||
if ref < date:
|
if ref < date: return clip
|
||||||
return clip
|
|
||||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||||
if rd.years > 1:
|
if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months)
|
||||||
return "%d years, %d months" % (rd.years, rd.months)
|
if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months)
|
||||||
if rd.years == 1:
|
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
||||||
return "%d year, %d months" % (rd.years, rd.months)
|
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days)
|
||||||
if rd.months > 1:
|
if rd.days >= 7: return "%d days" % rd.days
|
||||||
return "%d months, %d days" % (rd.months, rd.days)
|
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours)
|
||||||
if rd.months == 1:
|
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours)
|
||||||
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)
|
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||||
|
|
||||||
# Get duplicity collection status and parse for a list of backups.
|
# Get duplicity collection status and parse for a list of backups.
|
||||||
|
@ -65,26 +64,24 @@ def backup_status(env):
|
||||||
"date_str": date.strftime("%Y-%m-%d %X") + " " + now.tzname(),
|
"date_str": date.strftime("%Y-%m-%d %X") + " " + now.tzname(),
|
||||||
"date_delta": reldate(date, now, "the future?"),
|
"date_delta": reldate(date, now, "the future?"),
|
||||||
"full": keys[0] == "full",
|
"full": keys[0] == "full",
|
||||||
"size": 0, # collection-status doesn't give us the size
|
"size": 0, # collection-status doesn't give us the size
|
||||||
# number of archive volumes for this backup (not really helpful)
|
"volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful)
|
||||||
"volumes": int(keys[2]),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code, collection_status = shell('check_output', [
|
code, collection_status = shell('check_output', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"collection-status",
|
"collection-status",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
"--log-fd", "1",
|
"--log-fd", "1",
|
||||||
get_duplicity_target_url(config),
|
config["target"],
|
||||||
] + get_duplicity_additional_args(env),
|
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||||
get_duplicity_env_vars(env),
|
get_env(env),
|
||||||
trap=True)
|
trap=True)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
# Command failed. This is likely due to an improperly configured remote
|
# Command failed. This is likely due to an improperly configured remote
|
||||||
# destination for the backups or the last backup job terminated unexpectedly.
|
# destination for the backups or the last backup job terminated unexpectedly.
|
||||||
raise Exception("Something is wrong with the backup: " +
|
raise Exception("Something is wrong with the backup: " + collection_status)
|
||||||
collection_status)
|
|
||||||
for line in collection_status.split('\n'):
|
for line in collection_status.split('\n'):
|
||||||
if line.startswith(" full") or line.startswith(" inc"):
|
if line.startswith(" full") or line.startswith(" inc"):
|
||||||
backup = parse_line(line)
|
backup = parse_line(line)
|
||||||
|
@ -97,11 +94,8 @@ def backup_status(env):
|
||||||
# space is used for those.
|
# space is used for those.
|
||||||
unmatched_file_size = 0
|
unmatched_file_size = 0
|
||||||
for fn, size in list_target_files(config):
|
for fn, size in list_target_files(config):
|
||||||
m = re.match(
|
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)
|
||||||
r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.",
|
if not m: continue # not a part of a current backup chain
|
||||||
fn)
|
|
||||||
if not m:
|
|
||||||
continue # not a part of a current backup chain
|
|
||||||
key = m.group("date")
|
key = m.group("date")
|
||||||
if key in backups:
|
if key in backups:
|
||||||
backups[key]["size"] += size
|
backups[key]["size"] += size
|
||||||
|
@ -110,7 +104,7 @@ def backup_status(env):
|
||||||
|
|
||||||
# Ensure the rows are sorted reverse chronologically.
|
# Ensure the rows are sorted reverse chronologically.
|
||||||
# This is relied on by should_force_full() and the next step.
|
# This is relied on by should_force_full() and the next step.
|
||||||
backups = sorted(backups.values(), key=lambda b: b["date"], reverse=True)
|
backups = sorted(backups.values(), key = lambda b : b["date"], reverse=True)
|
||||||
|
|
||||||
# Get the average size of incremental backups, the size of the
|
# Get the average size of incremental backups, the size of the
|
||||||
# most recent full backup, and the date of the most recent
|
# most recent full backup, and the date of the most recent
|
||||||
|
@ -139,23 +133,16 @@ def backup_status(env):
|
||||||
if incremental_count > 0 and incremental_size > 0 and first_full_size is not None:
|
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
|
# How many days until the next incremental backup? First, the part of
|
||||||
# the algorithm based on increment sizes:
|
# the algorithm based on increment sizes:
|
||||||
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (
|
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count)
|
||||||
incremental_size / incremental_count)
|
est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full)
|
||||||
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:
|
# ...And then the part of the algorithm based on full backup age:
|
||||||
est_time_of_next_full = min(
|
est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1))
|
||||||
est_time_of_next_full, first_full_date +
|
|
||||||
datetime.timedelta(days=config["min_age_in_days"] * 10 + 1))
|
|
||||||
|
|
||||||
# It still can't be deleted until it's old enough.
|
# It still can't be deleted until it's old enough.
|
||||||
est_deleted_on = max(
|
est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"]))
|
||||||
est_time_of_next_full,
|
|
||||||
first_date + datetime.timedelta(days=config["min_age_in_days"]))
|
|
||||||
|
|
||||||
deleted_in = "approx. %d days" % round(
|
deleted_in = "approx. %d days" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5)
|
||||||
(est_deleted_on - now).total_seconds() / 60 / 60 / 24 + .5)
|
|
||||||
|
|
||||||
# When will a backup be deleted? Set the deleted_in field of each backup.
|
# When will a backup be deleted? Set the deleted_in field of each backup.
|
||||||
saw_full = False
|
saw_full = False
|
||||||
|
@ -171,11 +158,7 @@ def backup_status(env):
|
||||||
elif saw_full and not deleted_in:
|
elif saw_full and not deleted_in:
|
||||||
# We're now on backups prior to the most recent full backup. These are
|
# 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.
|
# free to be deleted as soon as they are min_age_in_days old.
|
||||||
deleted_in = reldate(
|
deleted_in = reldate(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup")
|
||||||
now,
|
|
||||||
dateutil.parser.parse(bak["date"]) +
|
|
||||||
datetime.timedelta(days=config["min_age_in_days"]),
|
|
||||||
"on next daily backup")
|
|
||||||
bak["deleted_in"] = deleted_in
|
bak["deleted_in"] = deleted_in
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -183,7 +166,6 @@ def backup_status(env):
|
||||||
"unmatched_file_size": unmatched_file_size,
|
"unmatched_file_size": unmatched_file_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def should_force_full(config, env):
|
def should_force_full(config, env):
|
||||||
# Force a full backup when the total size of the increments
|
# Force a full backup when the total size of the increments
|
||||||
# since the last full backup is greater than half the size
|
# since the last full backup is greater than half the size
|
||||||
|
@ -199,11 +181,9 @@ def should_force_full(config, env):
|
||||||
# Return if we should to a full backup, which is based
|
# Return if we should to a full backup, which is based
|
||||||
# on the size of the increments relative to the full
|
# on the size of the increments relative to the full
|
||||||
# backup, as well as the age of the full backup.
|
# backup, as well as the age of the full backup.
|
||||||
if inc_size > .5 * bak["size"]:
|
if inc_size > .5*bak["size"]:
|
||||||
return True
|
return True
|
||||||
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(
|
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
|
||||||
days=config["min_age_in_days"] * 10 +
|
|
||||||
1) < datetime.datetime.now(dateutil.tz.tzlocal()):
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
|
@ -211,7 +191,6 @@ def should_force_full(config, env):
|
||||||
# (I love for/else blocks. Here it's just to show off.)
|
# (I love for/else blocks. Here it's just to show off.)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_passphrase(env):
|
def get_passphrase(env):
|
||||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||||
# bits base64-encoded and with line breaks every 65 characters.
|
# bits base64-encoded and with line breaks every 65 characters.
|
||||||
|
@ -222,59 +201,14 @@ def get_passphrase(env):
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||||
passphrase = f.readline().strip()
|
passphrase = f.readline().strip()
|
||||||
if len(passphrase) < 43:
|
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
||||||
raise Exception("secret_key.txt's first line is too short!")
|
|
||||||
|
|
||||||
return passphrase
|
return passphrase
|
||||||
|
|
||||||
def get_duplicity_target_url(config):
|
def get_env(env):
|
||||||
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)
|
config = get_backup_config(env)
|
||||||
|
|
||||||
env = {"PASSPHRASE": get_passphrase(env)}
|
env = { "PASSPHRASE" : get_passphrase(env) }
|
||||||
|
|
||||||
if get_target_type(config) == 's3':
|
if get_target_type(config) == 's3':
|
||||||
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
|
env["AWS_ACCESS_KEY_ID"] = config["target_user"]
|
||||||
|
@ -282,12 +216,10 @@ def get_duplicity_env_vars(env):
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def get_target_type(config):
|
def get_target_type(config):
|
||||||
protocol = config["target"].split(":")[0]
|
protocol = config["target"].split(":")[0]
|
||||||
return protocol
|
return protocol
|
||||||
|
|
||||||
|
|
||||||
def perform_backup(full_backup, user_initiated=False):
|
def perform_backup(full_backup, user_initiated=False):
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
php_fpm = f"php{get_php_version()}-fpm"
|
php_fpm = f"php{get_php_version()}-fpm"
|
||||||
|
@ -303,7 +235,7 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
return "Another backup is already being done!"
|
return "Another backup is already being done!"
|
||||||
else:
|
else:
|
||||||
lock.forever()
|
lock.forever()
|
||||||
|
|
||||||
config = get_backup_config(env)
|
config = get_backup_config(env)
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
@ -328,10 +260,7 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
# Stop services.
|
# Stop services.
|
||||||
def service_command(service, command, quit=None):
|
def service_command(service, command, quit=None):
|
||||||
# Execute silently, but if there is an error then display the output & exit.
|
# Execute silently, but if there is an error then display the output & exit.
|
||||||
code, ret = shell('check_output',
|
code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
|
||||||
["/usr/sbin/service", service, command],
|
|
||||||
capture_stderr=True,
|
|
||||||
trap=True)
|
|
||||||
if code != 0:
|
if code != 0:
|
||||||
print(ret)
|
print(ret)
|
||||||
if quit:
|
if quit:
|
||||||
|
@ -340,7 +269,6 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
service_command(php_fpm, "stop", quit=True)
|
service_command(php_fpm, "stop", quit=True)
|
||||||
service_command("postfix", "stop", quit=True)
|
service_command("postfix", "stop", quit=True)
|
||||||
service_command("dovecot", "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.
|
# Execute a pre-backup script that copies files outside the homedir.
|
||||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
|
@ -356,7 +284,7 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
# after the first backup. See #396.
|
# after the first backup. See #396.
|
||||||
try:
|
try:
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"full" if full_backup else "incr",
|
"full" if full_backup else "incr",
|
||||||
"--verbosity", "warning", "--no-print-statistics",
|
"--verbosity", "warning", "--no-print-statistics",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
|
@ -364,13 +292,12 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
"--volsize", "250",
|
"--volsize", "250",
|
||||||
"--gpg-options", "--cipher-algo=AES256",
|
"--gpg-options", "--cipher-algo=AES256",
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
get_duplicity_target_url(config),
|
config["target"],
|
||||||
"--allow-source-mismatch"
|
"--allow-source-mismatch"
|
||||||
] + get_duplicity_additional_args(env),
|
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||||
get_duplicity_env_vars(env))
|
get_env(env))
|
||||||
finally:
|
finally:
|
||||||
# Start services again.
|
# Start services again.
|
||||||
service_command("postgrey", "start", quit=False)
|
|
||||||
service_command("dovecot", "start", quit=False)
|
service_command("dovecot", "start", quit=False)
|
||||||
service_command("postfix", "start", quit=False)
|
service_command("postfix", "start", quit=False)
|
||||||
service_command(php_fpm, "start", quit=False)
|
service_command(php_fpm, "start", quit=False)
|
||||||
|
@ -378,15 +305,15 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
# Remove old backups. This deletes all backup data no longer needed
|
# Remove old backups. This deletes all backup data no longer needed
|
||||||
# from more than 3 days ago.
|
# from more than 3 days ago.
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"remove-older-than",
|
"remove-older-than",
|
||||||
"%dD" % config["min_age_in_days"],
|
"%dD" % config["min_age_in_days"],
|
||||||
"--verbosity", "error",
|
"--verbosity", "error",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
get_duplicity_target_url(config)
|
config["target"]
|
||||||
] + get_duplicity_additional_args(env),
|
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||||
get_duplicity_env_vars(env))
|
get_env(env))
|
||||||
|
|
||||||
# From duplicity's manual:
|
# From duplicity's manual:
|
||||||
# "This should only be necessary after a duplicity session fails or is
|
# "This should only be necessary after a duplicity session fails or is
|
||||||
|
@ -394,20 +321,19 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
# That may be unlikely here but we may as well ensure we tidy up if
|
# 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.
|
# that does happen - it might just have been a poorly timed reboot.
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"cleanup",
|
"cleanup",
|
||||||
"--verbosity", "error",
|
"--verbosity", "error",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--force",
|
"--force",
|
||||||
get_duplicity_target_url(config)
|
config["target"]
|
||||||
] + get_duplicity_additional_args(env),
|
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||||
get_duplicity_env_vars(env))
|
get_env(env))
|
||||||
|
|
||||||
# Change ownership of backups to the user-data user, so that the after-bcakup
|
# Change ownership of backups to the user-data user, so that the after-bcakup
|
||||||
# script can access them.
|
# script can access them.
|
||||||
if get_target_type(config) == 'file':
|
if get_target_type(config) == 'file':
|
||||||
shell('check_call',
|
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||||
["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
|
||||||
|
|
||||||
# Execute a post-backup script that does the copying to a remote server.
|
# Execute a post-backup script that does the copying to a remote server.
|
||||||
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
# Run as the STORAGE_USER user, not as root. Pass our settings in
|
||||||
|
@ -430,7 +356,6 @@ def perform_backup(full_backup, user_initiated=False):
|
||||||
wait_for_service(25, True, env, 10)
|
wait_for_service(25, True, env, 10)
|
||||||
wait_for_service(993, True, env, 10)
|
wait_for_service(993, True, env, 10)
|
||||||
|
|
||||||
|
|
||||||
def run_duplicity_verification():
|
def run_duplicity_verification():
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
@ -438,28 +363,27 @@ def run_duplicity_verification():
|
||||||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||||
|
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"--verbosity",
|
"--verbosity", "info",
|
||||||
"info",
|
|
||||||
"verify",
|
"verify",
|
||||||
"--compare-data",
|
"--compare-data",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
"--exclude", backup_root,
|
"--exclude", backup_root,
|
||||||
get_duplicity_target_url(config),
|
config["target"],
|
||||||
env["STORAGE_ROOT"],
|
env["STORAGE_ROOT"],
|
||||||
] + get_duplicity_additional_args(env), get_duplicity_env_vars(env))
|
] + rsync_ssh_options(port = config["target_rsync_port"]), get_env(env))
|
||||||
|
|
||||||
def run_duplicity_restore(args):
|
def run_duplicity_restore(args):
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
config = get_backup_config(env)
|
config = get_backup_config(env)
|
||||||
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
|
backup_cache_dir = os.path.join(env["STORAGE_ROOT"], 'backup', 'cache')
|
||||||
shell('check_call', [
|
shell('check_call', [
|
||||||
"/usr/local/bin/duplicity",
|
"/usr/bin/duplicity",
|
||||||
"restore",
|
"restore",
|
||||||
"--archive-dir", backup_cache_dir,
|
"--archive-dir", backup_cache_dir,
|
||||||
get_duplicity_target_url(config),
|
config["target"],
|
||||||
] + get_duplicity_additional_args(env) + args,
|
] + rsync_ssh_options(port = config["target_rsync_port"]) + args,
|
||||||
get_duplicity_env_vars(env))
|
get_env(env))
|
||||||
|
|
||||||
def list_target_files(config):
|
def list_target_files(config):
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
@ -469,8 +393,7 @@ def list_target_files(config):
|
||||||
return "invalid target"
|
return "invalid target"
|
||||||
|
|
||||||
if target.scheme == "file":
|
if target.scheme == "file":
|
||||||
return [(fn, os.path.getsize(os.path.join(target.path, fn)))
|
return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]
|
||||||
for fn in os.listdir(target.path)]
|
|
||||||
|
|
||||||
elif target.scheme == "rsync":
|
elif target.scheme == "rsync":
|
||||||
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
||||||
|
@ -482,24 +405,23 @@ def list_target_files(config):
|
||||||
if target_path.startswith('/'):
|
if target_path.startswith('/'):
|
||||||
target_path = target_path[1:]
|
target_path = target_path[1:]
|
||||||
|
|
||||||
rsync_command = [
|
rsync_command = [ 'rsync',
|
||||||
'rsync', '-e',
|
'-e',
|
||||||
f"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {int(config.get('target_rsync_port', 22))} -i /root/.ssh/id_rsa_miab",
|
rsync_ssh_options(config["target_rsync_port"], direct = True),
|
||||||
'--list-only', '-r',
|
'--list-only',
|
||||||
rsync_target.format(host=target.netloc, path=target_path)
|
'-r',
|
||||||
]
|
rsync_target.format(
|
||||||
|
host=target.netloc,
|
||||||
|
path=target_path)
|
||||||
|
]
|
||||||
|
|
||||||
code, listing = shell('check_output',
|
code, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True)
|
||||||
rsync_command,
|
|
||||||
trap=True,
|
|
||||||
capture_stderr=True)
|
|
||||||
if code == 0:
|
if code == 0:
|
||||||
ret = []
|
ret = []
|
||||||
for l in listing.split('\n'):
|
for l in listing.split('\n'):
|
||||||
match = rsync_fn_size_re.match(l)
|
match = rsync_fn_size_re.match(l)
|
||||||
if match:
|
if match:
|
||||||
ret.append((match.groups()[1],
|
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
|
||||||
int(match.groups()[0].replace(',', ''))))
|
|
||||||
return ret
|
return ret
|
||||||
else:
|
else:
|
||||||
if 'Permission denied (publickey).' in listing:
|
if 'Permission denied (publickey).' in listing:
|
||||||
|
@ -507,26 +429,36 @@ def list_target_files(config):
|
||||||
elif 'No such file or directory' in listing:
|
elif 'No such file or directory' in listing:
|
||||||
reason = "Provided path {} is invalid.".format(target_path)
|
reason = "Provided path {} is invalid.".format(target_path)
|
||||||
elif 'Network is unreachable' in listing:
|
elif 'Network is unreachable' in listing:
|
||||||
reason = "The IP address {} is unreachable.".format(
|
reason = "The IP address {} is unreachable.".format(target.hostname)
|
||||||
target.hostname)
|
|
||||||
elif 'Could not resolve hostname' in listing:
|
elif 'Could not resolve hostname' in listing:
|
||||||
reason = "The hostname {} cannot be resolved.".format(
|
reason = "The hostname {} cannot be resolved.".format(target.hostname)
|
||||||
target.hostname)
|
|
||||||
else:
|
else:
|
||||||
reason = "Unknown error. " \
|
reason = "Unknown error. " \
|
||||||
"Please check running 'management/backup.py --verify' " \
|
"Please check running 'management/backup.py --verify' " \
|
||||||
"from mailinabox sources to debug the issue."
|
"from mailinabox sources to debug the issue."
|
||||||
raise ValueError(
|
raise ValueError("Connection to rsync host failed: {}".format(reason))
|
||||||
"Connection to rsync host failed: {}".format(reason))
|
|
||||||
|
|
||||||
elif target.scheme == "s3":
|
elif target.scheme == "s3":
|
||||||
import boto3.s3
|
# match to a Region
|
||||||
from botocore.exceptions import ClientError
|
fix_boto() # must call prior to importing boto
|
||||||
|
import boto.s3
|
||||||
|
from boto.exception import BotoServerError
|
||||||
|
custom_region = False
|
||||||
|
for region in boto.s3.regions():
|
||||||
|
if region.endpoint == target.hostname:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If region is not found this is a custom region
|
||||||
|
custom_region = True
|
||||||
|
|
||||||
# separate bucket from path in target
|
|
||||||
bucket = target.path[1:].split('/')[0]
|
bucket = target.path[1:].split('/')[0]
|
||||||
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
|
path = '/'.join(target.path[1:].split('/')[1:]) + '/'
|
||||||
|
|
||||||
|
# Create a custom region with custom endpoint
|
||||||
|
if custom_region:
|
||||||
|
from boto.s3.connection import S3Connection
|
||||||
|
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)
|
||||||
|
|
||||||
# If no prefix is specified, set the path to '', otherwise boto won't list the files
|
# If no prefix is specified, set the path to '', otherwise boto won't list the files
|
||||||
if path == '/':
|
if path == '/':
|
||||||
path = ''
|
path = ''
|
||||||
|
@ -536,40 +468,46 @@ def list_target_files(config):
|
||||||
|
|
||||||
# connect to the region & bucket
|
# connect to the region & bucket
|
||||||
try:
|
try:
|
||||||
s3 = boto3.client('s3', \
|
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"])
|
||||||
endpoint_url=f'https://{target.hostname}', \
|
bucket = conn.get_bucket(bucket)
|
||||||
aws_access_key_id=config['target_user'], \
|
except BotoServerError as e:
|
||||||
aws_secret_access_key=config['target_pass'])
|
if e.status == 403:
|
||||||
bucket_objects = s3.list_objects_v2(Bucket=bucket, Prefix=path).get("Contents", [])
|
raise ValueError("Invalid S3 access key or secret access key.")
|
||||||
backup_list = [(key['Key'][len(path):], key['Size']) for key in bucket_objects]
|
elif e.status == 404:
|
||||||
except ClientError as e:
|
raise ValueError("Invalid S3 bucket name.")
|
||||||
raise ValueError(e)
|
elif e.status == 301:
|
||||||
return backup_list
|
raise ValueError("Incorrect region for this bucket.")
|
||||||
|
raise ValueError(e.reason)
|
||||||
|
|
||||||
|
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
||||||
elif target.scheme == 'b2':
|
elif target.scheme == 'b2':
|
||||||
InMemoryAccountInfo = None
|
InMemoryAccountInfo = None
|
||||||
B2Api = None
|
B2Api = None
|
||||||
NonExistentBucket = None
|
NonExistentBucket = None
|
||||||
|
|
||||||
from b2sdk.v1 import InMemoryAccountInfo, B2Api
|
if get_os_code() == "Debian10":
|
||||||
from b2sdk.v1.exception import NonExistentBucket
|
# WARNING: This is deprecated code using a legacy library.
|
||||||
|
# We need it because Debian 10 ships with an old version of Duplicity
|
||||||
|
from b2.account_info import InMemoryAccountInfo
|
||||||
|
from b2.api import B2Api
|
||||||
|
from b2.exception import NonExistentBucket
|
||||||
|
else:
|
||||||
|
from b2sdk.v1 import InMemoryAccountInfo, B2Api
|
||||||
|
from b2sdk.v1.exception import NonExistentBucket
|
||||||
|
|
||||||
info = InMemoryAccountInfo()
|
info = InMemoryAccountInfo()
|
||||||
b2_api = B2Api(info)
|
b2_api = B2Api(info)
|
||||||
|
|
||||||
# Extract information from target
|
# Extract information from target
|
||||||
b2_application_keyid = target.netloc[:target.netloc.index(':')]
|
b2_application_keyid = target.netloc[:target.netloc.index(':')]
|
||||||
b2_application_key = target.netloc[target.netloc.index(':') +
|
b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]
|
||||||
1:target.netloc.index('@')]
|
b2_bucket = target.netloc[target.netloc.index('@')+1:]
|
||||||
b2_bucket = target.netloc[target.netloc.index('@') + 1:]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
b2_api.authorize_account("production", b2_application_keyid,
|
b2_api.authorize_account("production", b2_application_keyid, b2_application_key)
|
||||||
b2_application_key)
|
|
||||||
bucket = b2_api.get_bucket_by_name(b2_bucket)
|
bucket = b2_api.get_bucket_by_name(b2_bucket)
|
||||||
except NonExistentBucket as e:
|
except NonExistentBucket as e:
|
||||||
raise ValueError(
|
raise ValueError("B2 Bucket does not exist. Please double check your information!")
|
||||||
"B2 Bucket does not exist. Please double check your information!"
|
|
||||||
)
|
|
||||||
return [(key.file_name, key.size) for key, _ in bucket.ls()]
|
return [(key.file_name, key.size) for key, _ in bucket.ls()]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -582,7 +520,7 @@ def backup_set_custom(env, target, target_user, target_pass, target_rsync_port,
|
||||||
# min_age must be an int
|
# min_age must be an int
|
||||||
if isinstance(min_age, str):
|
if isinstance(min_age, str):
|
||||||
min_age = int(min_age)
|
min_age = int(min_age)
|
||||||
|
|
||||||
if isinstance(target_rsync_port, str):
|
if isinstance(target_rsync_port, str):
|
||||||
try:
|
try:
|
||||||
target_rsync_port = int(target_rsync_port)
|
target_rsync_port = int(target_rsync_port)
|
||||||
|
@ -608,19 +546,20 @@ def backup_set_custom(env, target, target_user, target_pass, target_rsync_port,
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def get_backup_config(env, for_save=False, for_ui=False):
|
def get_backup_config(env, for_save=False, for_ui=False):
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
|
|
||||||
# Defaults.
|
# Defaults.
|
||||||
config = {"min_age_in_days": 3, "target": "local", "target_rsync_port": 22}
|
config = {
|
||||||
|
"min_age_in_days": 3,
|
||||||
|
"target": "local",
|
||||||
|
"target_rsync_port": 22
|
||||||
|
}
|
||||||
|
|
||||||
# Merge in anything written to custom.yaml.
|
# Merge in anything written to custom.yaml.
|
||||||
try:
|
try:
|
||||||
custom_config = rtyaml.load(
|
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
|
||||||
open(os.path.join(backup_root, 'custom.yaml')))
|
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
||||||
if not isinstance(custom_config, dict):
|
|
||||||
raise ValueError() # caught below
|
|
||||||
config.update(custom_config)
|
config.update(custom_config)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
@ -648,13 +587,11 @@ def get_backup_config(env, for_save=False, for_ui=False):
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def write_backup_config(env, newconfig):
|
def write_backup_config(env, newconfig):
|
||||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||||
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f:
|
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f:
|
||||||
f.write(rtyaml.dump(newconfig))
|
f.write(rtyaml.dump(newconfig))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
if sys.argv[-1] == "--verify":
|
if sys.argv[-1] == "--verify":
|
||||||
|
@ -664,8 +601,7 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
elif sys.argv[-1] == "--list":
|
elif sys.argv[-1] == "--list":
|
||||||
# List the saved backup files.
|
# List the saved backup files.
|
||||||
for fn, size in list_target_files(get_backup_config(
|
for fn, size in list_target_files(get_backup_config(load_environment())):
|
||||||
load_environment())):
|
|
||||||
print("{}\t{}".format(fn, size))
|
print("{}\t{}".format(fn, size))
|
||||||
|
|
||||||
elif sys.argv[-1] == "--status":
|
elif sys.argv[-1] == "--status":
|
||||||
|
|
|
@ -6,14 +6,7 @@
|
||||||
# root API key. This file is readable only by root, so this
|
# root API key. This file is readable only by root, so this
|
||||||
# tool can only be used as root.
|
# tool can only be used as root.
|
||||||
|
|
||||||
import sys
|
import sys, getpass, urllib.request, urllib.error, json, re, csv
|
||||||
import getpass
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import csv
|
|
||||||
|
|
||||||
|
|
||||||
def mgmt(cmd, data=None, is_json=False):
|
def mgmt(cmd, data=None, is_json=False):
|
||||||
# The base URL for the management daemon. (Listens on IPv4 only.)
|
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||||
|
@ -21,9 +14,7 @@ def mgmt(cmd, data=None, is_json=False):
|
||||||
|
|
||||||
setup_key_auth(mgmt_uri)
|
setup_key_auth(mgmt_uri)
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
|
||||||
mgmt_uri + cmd,
|
|
||||||
urllib.parse.urlencode(data).encode("utf8") if data else None)
|
|
||||||
try:
|
try:
|
||||||
response = urllib.request.urlopen(req)
|
response = urllib.request.urlopen(req)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
|
@ -32,74 +23,70 @@ def mgmt(cmd, data=None, is_json=False):
|
||||||
print(e.read().decode("utf8"))
|
print(e.read().decode("utf8"))
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
print(
|
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
|
||||||
"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'):
|
elif hasattr(e, 'read'):
|
||||||
print(e.read().decode('utf8'), file=sys.stderr)
|
print(e.read().decode('utf8'), file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(e, file=sys.stderr)
|
print(e, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
resp = response.read().decode('utf8')
|
resp = response.read().decode('utf8')
|
||||||
if is_json:
|
if is_json: resp = json.loads(resp)
|
||||||
resp = json.loads(resp)
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def read_password():
|
def read_password():
|
||||||
while True:
|
while True:
|
||||||
first = getpass.getpass('password: ')
|
first = getpass.getpass('password: ')
|
||||||
if len(first) < 8:
|
if len(first) < 8:
|
||||||
print("Passwords must be at least eight characters.")
|
print("Passwords must be at least eight characters.")
|
||||||
continue
|
continue
|
||||||
second = getpass.getpass(' (again): ')
|
second = getpass.getpass(' (again): ')
|
||||||
if first != second:
|
if first != second:
|
||||||
print("Passwords not the same. Try again.")
|
print("Passwords not the same. Try again.")
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
return first
|
return first
|
||||||
|
|
||||||
|
|
||||||
def setup_key_auth(mgmt_uri):
|
def setup_key_auth(mgmt_uri):
|
||||||
key = open('/var/lib/mailinabox/api.key').read().strip()
|
key = open('/var/lib/mailinabox/api.key').read().strip()
|
||||||
|
|
||||||
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||||
auth_handler.add_password(realm='Mail-in-a-Box Management Server',
|
auth_handler.add_password(
|
||||||
uri=mgmt_uri,
|
realm='Mail-in-a-Box Management Server',
|
||||||
user=key,
|
uri=mgmt_uri,
|
||||||
passwd='')
|
user=key,
|
||||||
|
passwd='')
|
||||||
opener = urllib.request.build_opener(auth_handler)
|
opener = urllib.request.build_opener(auth_handler)
|
||||||
urllib.request.install_opener(opener)
|
urllib.request.install_opener(opener)
|
||||||
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("""Usage:
|
print("""Usage:
|
||||||
{cli} system default-quota [new default] (set default quota for system)
|
{cli} system default-quota [new default] (set default quota for system)
|
||||||
{cli} user (lists users)
|
{cli} user (lists users)
|
||||||
{cli} user add user@domain.com [password]
|
{cli} user add user@domain.com [password]
|
||||||
{cli} user password user@domain.com [password]
|
{cli} user password user@domain.com [password]
|
||||||
{cli} user remove user@domain.com
|
{cli} user remove user@domain.com
|
||||||
{cli} user make-admin user@domain.com
|
{cli} user make-admin user@domain.com
|
||||||
{cli} user quota user@domain [new-quota]
|
{cli} user quota user@domain [new-quota]
|
||||||
{cli} user remove-admin user@domain.com
|
{cli} user remove-admin user@domain.com
|
||||||
{cli} user admins (lists admins)
|
{cli} user admins (lists admins)
|
||||||
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
|
{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} user mfa disable user@domain.com [id] (disables MFA for user)
|
||||||
{cli} alias (lists aliases)
|
{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
|
||||||
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@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
|
{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.
|
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
|
||||||
""".format(cli="management/cli.py"))
|
""".format(
|
||||||
|
cli="management/cli.py"
|
||||||
|
))
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||||
users = mgmt("/mail/users?format=json", is_json=True)
|
users = mgmt("/mail/users?format=json", is_json=True)
|
||||||
for domain in users:
|
for domain in users:
|
||||||
for user in domain["users"]:
|
for user in domain["users"]:
|
||||||
if user['status'] == 'inactive':
|
if user['status'] == 'inactive': continue
|
||||||
continue
|
|
||||||
print(user['email'], end='')
|
print(user['email'], end='')
|
||||||
if "admin" in user['privileges']:
|
if "admin" in user['privileges']:
|
||||||
print("*", end='')
|
print("*", end='')
|
||||||
|
@ -120,25 +107,19 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||||
email, pw = sys.argv[3:5]
|
email, pw = sys.argv[3:5]
|
||||||
|
|
||||||
if sys.argv[2] == "add":
|
if sys.argv[2] == "add":
|
||||||
print(mgmt("/mail/users/add", {"email": email, "password": pw}))
|
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
|
||||||
elif sys.argv[2] == "password":
|
elif sys.argv[2] == "password":
|
||||||
print(mgmt("/mail/users/password", {"email": email, "password": pw}))
|
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
print(mgmt("/mail/users/remove", {"email": sys.argv[3]}))
|
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin",
|
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
|
||||||
"remove-admin") and len(
|
|
||||||
sys.argv) == 4:
|
|
||||||
if sys.argv[2] == "make-admin":
|
if sys.argv[2] == "make-admin":
|
||||||
action = "add"
|
action = "add"
|
||||||
else:
|
else:
|
||||||
action = "remove"
|
action = "remove"
|
||||||
print(
|
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
|
||||||
mgmt("/mail/users/privileges/" + action, {
|
|
||||||
"email": sys.argv[3],
|
|
||||||
"privilege": "admin"
|
|
||||||
}))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||||
# Dump a list of admin users.
|
# Dump a list of admin users.
|
||||||
|
@ -154,51 +135,36 @@ elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
|
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
|
||||||
# Set a user's quota
|
# Set a user's quota
|
||||||
users = mgmt("/mail/users/quota", {
|
users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] })
|
||||||
"email": sys.argv[3],
|
|
||||||
"quota": sys.argv[4]
|
|
||||||
})
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and len(
|
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
|
||||||
sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
|
|
||||||
# Show MFA status for a user.
|
# Show MFA status for a user.
|
||||||
status = mgmt("/mfa/status", {"user": sys.argv[4]}, is_json=True)
|
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
|
||||||
W = csv.writer(sys.stdout)
|
W = csv.writer(sys.stdout)
|
||||||
W.writerow(["id", "type", "label"])
|
W.writerow(["id", "type", "label"])
|
||||||
for mfa in status["enabled_mfa"]:
|
for mfa in status["enabled_mfa"]:
|
||||||
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and len(
|
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||||
sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
|
||||||
# Disable MFA (all or a particular device) for a user.
|
# Disable MFA (all or a particular device) for a user.
|
||||||
print(
|
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
|
||||||
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:
|
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||||
print(mgmt("/mail/aliases"))
|
print(mgmt("/mail/aliases"))
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||||
print(
|
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
||||||
mgmt("/mail/aliases/add", {
|
|
||||||
"address": sys.argv[3],
|
|
||||||
"forwards_to": sys.argv[4]
|
|
||||||
}))
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
print(mgmt("/mail/aliases/remove", {"address": sys.argv[3]}))
|
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
|
||||||
|
|
||||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 3:
|
||||||
sys.argv) == 3:
|
|
||||||
print(mgmt("/system/default-quota?text=1"))
|
print(mgmt("/system/default-quota?text=1"))
|
||||||
|
|
||||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 4:
|
||||||
sys.argv) == 4:
|
print(mgmt("/system/default-quota", { "default_quota": sys.argv[3]}))
|
||||||
print(mgmt("/system/default-quota", {"default_quota": sys.argv[3]}))
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Invalid command-line arguments.")
|
print("Invalid command-line arguments.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -24,18 +24,9 @@
|
||||||
|
|
||||||
# create the new config file in memory
|
# create the new config file in memory
|
||||||
|
|
||||||
import sys
|
import sys, re
|
||||||
import re
|
|
||||||
|
|
||||||
|
def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_lines = False, testing = False):
|
||||||
def edit_conf(filename,
|
|
||||||
settings,
|
|
||||||
delimiter_re,
|
|
||||||
delimiter,
|
|
||||||
comment_char,
|
|
||||||
folded_lines=False,
|
|
||||||
testing=False,
|
|
||||||
erase_setting=False):
|
|
||||||
found = set()
|
found = set()
|
||||||
buf = ""
|
buf = ""
|
||||||
input_lines = list(open(filename, "r+"))
|
input_lines = list(open(filename, "r+"))
|
||||||
|
@ -51,38 +42,34 @@ def edit_conf(filename,
|
||||||
|
|
||||||
# See if this line is for any settings passed on the command line.
|
# See if this line is for any settings passed on the command line.
|
||||||
for i in range(len(settings)):
|
for i in range(len(settings)):
|
||||||
# Check whether this line contain this setting from the command-line arguments.
|
# Check that this line contain this setting from the command-line arguments.
|
||||||
name, val = settings[i].split("=", 1)
|
name, val = settings[i].split("=", 1)
|
||||||
m = re.match(
|
m = re.match(
|
||||||
"(\s*)" + "(" + re.escape(comment_char) + "\s*)?" +
|
"(\s*)"
|
||||||
re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
|
+ "(" + re.escape(comment_char) + "\s*)?"
|
||||||
if not m:
|
+ re.escape(name) + delimiter_re + "(.*?)\s*$",
|
||||||
continue
|
line, re.S)
|
||||||
|
if not m: continue
|
||||||
indent, is_comment, existing_val = m.groups()
|
indent, is_comment, existing_val = m.groups()
|
||||||
|
|
||||||
# If this is already the setting, keep it in the file, except:
|
# If this is already the setting, do nothing.
|
||||||
# * If we've already seen it before, then remove this duplicate line.
|
if is_comment is None and existing_val == val:
|
||||||
# * 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
|
# It may be that we've already inserted this setting higher
|
||||||
# in the file so check for that first.
|
# in the file so check for that first.
|
||||||
if i in found:
|
if i in found: break
|
||||||
break
|
|
||||||
buf += line
|
buf += line
|
||||||
found.add(i)
|
found.add(i)
|
||||||
break
|
break
|
||||||
|
|
||||||
# comment-out the existing line (also comment any folded lines)
|
# comment-out the existing line (also comment any folded lines)
|
||||||
if is_comment is None:
|
if is_comment is None:
|
||||||
buf += comment_char + line.rstrip().replace(
|
buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
|
||||||
"\n", "\n" + comment_char) + "\n"
|
|
||||||
else:
|
else:
|
||||||
# the line is already commented, pass it through
|
# the line is already commented, pass it through
|
||||||
buf += line
|
buf += line
|
||||||
|
|
||||||
# if this option oddly appears more than once, don't add the setting again
|
# 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:
|
||||||
if (i in found) or (not val and erase_setting):
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# add the new setting
|
# add the new setting
|
||||||
|
@ -96,12 +83,11 @@ def edit_conf(filename,
|
||||||
# If did not match any setting names, pass this line through.
|
# If did not match any setting names, pass this line through.
|
||||||
buf += line
|
buf += line
|
||||||
|
|
||||||
# Put any settings we didn't see at the end of the file, except those being erased.
|
# Put any settings we didn't see at the end of the file.
|
||||||
for i in range(len(settings)):
|
for i in range(len(settings)):
|
||||||
if i not in found:
|
if i not in found:
|
||||||
name, val = settings[i].split("=", 1)
|
name, val = settings[i].split("=", 1)
|
||||||
if not (not val and erase_setting):
|
buf += name + delimiter + val + "\n"
|
||||||
buf += name + delimiter + val + "\n"
|
|
||||||
|
|
||||||
if not testing:
|
if not testing:
|
||||||
# Write out the new file.
|
# Write out the new file.
|
||||||
|
@ -111,14 +97,11 @@ def edit_conf(filename,
|
||||||
# Just print the new file to stdout.
|
# Just print the new file to stdout.
|
||||||
print(buf)
|
print(buf)
|
||||||
|
|
||||||
|
|
||||||
# Run standalone
|
# Run standalone
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# sanity check
|
# sanity check
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print(
|
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
|
||||||
"usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# parse command line arguments
|
# parse command line arguments
|
||||||
|
@ -130,16 +113,12 @@ if __name__ == "__main__":
|
||||||
comment_char = "#"
|
comment_char = "#"
|
||||||
folded_lines = False
|
folded_lines = False
|
||||||
testing = False
|
testing = False
|
||||||
erase_setting = False
|
|
||||||
while settings[0][0] == "-" and settings[0] != "--":
|
while settings[0][0] == "-" and settings[0] != "--":
|
||||||
opt = settings.pop(0)
|
opt = settings.pop(0)
|
||||||
if opt == "-s":
|
if opt == "-s":
|
||||||
# Space is the delimiter
|
# Space is the delimiter
|
||||||
delimiter = " "
|
delimiter = " "
|
||||||
delimiter_re = r"\s+"
|
delimiter_re = r"\s+"
|
||||||
elif opt == "-e":
|
|
||||||
# Erase settings that have empty values.
|
|
||||||
erase_setting = True
|
|
||||||
elif opt == "-w":
|
elif opt == "-w":
|
||||||
# Line folding is possible in this file.
|
# Line folding is possible in this file.
|
||||||
folded_lines = True
|
folded_lines = True
|
||||||
|
@ -161,5 +140,4 @@ if __name__ == "__main__":
|
||||||
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
|
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
edit_conf(filename, settings, delimiter_re, delimiter, comment_char,
|
edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_lines, testing)
|
||||||
folded_lines, testing, erase_setting)
|
|
||||||
|
|
|
@ -39,26 +39,24 @@ msg = MIMEMultipart('alternative')
|
||||||
noreply = "noreply-daemon@" + env['PRIMARY_HOSTNAME']
|
noreply = "noreply-daemon@" + env['PRIMARY_HOSTNAME']
|
||||||
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
|
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon",
|
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon", "noreply-daemon@" + env['PRIMARY_HOSTNAME'])
|
||||||
"noreply-daemon@" + env['PRIMARY_HOSTNAME'])
|
|
||||||
msg['To'] = "administrator@" + env['PRIMARY_HOSTNAME']
|
msg['To'] = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||||
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
||||||
|
|
||||||
content_html = "<html><body><pre>{}</pre></body></html>".format(
|
content_html = "<html><body><pre>{}</pre></body></html>".format(html.escape(content))
|
||||||
html.escape(content))
|
|
||||||
|
|
||||||
msg.attach(MIMEText(create_signature(content.encode()).decode(), 'plain'))
|
msg.attach(MIMEText(create_signature(content.encode()).decode(), 'plain'))
|
||||||
msg.attach(MIMEText(content_html, 'html'))
|
msg.attach(MIMEText(content_html, 'html'))
|
||||||
|
|
||||||
# In Python 3.6:
|
# In Python 3.6:
|
||||||
# msg.set_content(content)
|
#msg.set_content(content)
|
||||||
#msg.add_alternative(content_html, "html")
|
#msg.add_alternative(content_html, "html")
|
||||||
|
|
||||||
# send
|
# send
|
||||||
smtpclient = smtplib.SMTP('127.0.0.1', 25)
|
smtpclient = smtplib.SMTP('127.0.0.1', 25)
|
||||||
smtpclient.ehlo()
|
smtpclient.ehlo()
|
||||||
smtpclient.sendmail(
|
smtpclient.sendmail(
|
||||||
noreply, # MAIL FROM
|
noreply, # MAIL FROM
|
||||||
admin_addr, # RCPT TO
|
admin_addr, # RCPT TO
|
||||||
msg.as_string())
|
msg.as_string())
|
||||||
smtpclient.quit()
|
smtpclient.quit()
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -9,16 +9,11 @@
|
||||||
# Python 3 in setup/questions.sh to validate the email
|
# Python 3 in setup/questions.sh to validate the email
|
||||||
# address entered by the user.
|
# address entered by the user.
|
||||||
|
|
||||||
import subprocess
|
import subprocess, shutil, os, sqlite3, re
|
||||||
import shutil
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import re
|
|
||||||
import utils
|
import utils
|
||||||
from email_validator import validate_email as validate_email_, EmailNotValidError
|
from email_validator import validate_email as validate_email_, EmailNotValidError
|
||||||
import idna
|
import idna
|
||||||
|
|
||||||
|
|
||||||
def validate_email(email, mode=None):
|
def validate_email(email, mode=None):
|
||||||
# Checks that an email address is syntactically valid. Returns True/False.
|
# Checks that an email address is syntactically valid. Returns True/False.
|
||||||
# An email address may contain ASCII characters only because Dovecot's
|
# An email address may contain ASCII characters only because Dovecot's
|
||||||
|
@ -34,9 +29,10 @@ def validate_email(email, mode=None):
|
||||||
# Check the syntax of the address.
|
# Check the syntax of the address.
|
||||||
try:
|
try:
|
||||||
validate_email_(email,
|
validate_email_(email,
|
||||||
allow_smtputf8=False,
|
allow_smtputf8=False,
|
||||||
check_deliverability=False,
|
check_deliverability=False,
|
||||||
allow_empty_local=(mode == "alias"))
|
allow_empty_local=(mode=="alias")
|
||||||
|
)
|
||||||
except EmailNotValidError:
|
except EmailNotValidError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -49,15 +45,13 @@ def validate_email(email, mode=None):
|
||||||
# Our database is case sensitive (oops), which affects mail delivery
|
# Our database is case sensitive (oops), which affects mail delivery
|
||||||
# (Postfix always queries in lowercase?), so also only permit lowercase
|
# (Postfix always queries in lowercase?), so also only permit lowercase
|
||||||
# letters.
|
# letters.
|
||||||
if len(email) > 255:
|
if len(email) > 255: return False
|
||||||
return False
|
|
||||||
if re.search(r'[^\@\.a-z0-9_\-]+', email):
|
if re.search(r'[^\@\.a-z0-9_\-]+', email):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Everything looks good.
|
# Everything looks good.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def sanitize_idn_email_address(email):
|
def sanitize_idn_email_address(email):
|
||||||
# The user may enter Unicode in an email address. Convert the domain part
|
# The user may enter Unicode in an email address. Convert the domain part
|
||||||
# to IDNA before going into our database. Leave the local part alone ---
|
# to IDNA before going into our database. Leave the local part alone ---
|
||||||
|
@ -78,7 +72,6 @@ def sanitize_idn_email_address(email):
|
||||||
# validate_email.
|
# validate_email.
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
def prettify_idn_email_address(email):
|
def prettify_idn_email_address(email):
|
||||||
# This is the opposite of sanitize_idn_email_address. We store domain
|
# This is the opposite of sanitize_idn_email_address. We store domain
|
||||||
# names in IDNA in the database, but we want to show Unicode to the user.
|
# names in IDNA in the database, but we want to show Unicode to the user.
|
||||||
|
@ -91,17 +84,13 @@ def prettify_idn_email_address(email):
|
||||||
# single @-sign. Should never happen.
|
# single @-sign. Should never happen.
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
def is_dcv_address(email):
|
def is_dcv_address(email):
|
||||||
email = email.lower()
|
email = email.lower()
|
||||||
for localpart in ("admin", "administrator", "postmaster", "hostmaster",
|
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"):
|
||||||
"webmaster", "abuse"):
|
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
|
||||||
if email.startswith(localpart + "@") or email.startswith(localpart +
|
|
||||||
"+"):
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def open_database(env, with_connection=False):
|
def open_database(env, with_connection=False):
|
||||||
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
||||||
if not with_connection:
|
if not with_connection:
|
||||||
|
@ -109,17 +98,15 @@ def open_database(env, with_connection=False):
|
||||||
else:
|
else:
|
||||||
return conn, conn.cursor()
|
return conn, conn.cursor()
|
||||||
|
|
||||||
|
|
||||||
def get_mail_users(env):
|
def get_mail_users(env):
|
||||||
# Returns a flat, sorted list of all user accounts.
|
# Returns a flat, sorted list of all user accounts.
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT email FROM users')
|
c.execute('SELECT email FROM users')
|
||||||
users = [row[0] for row in c.fetchall()]
|
users = [ row[0] for row in c.fetchall() ]
|
||||||
return utils.sort_email_addresses(users, env)
|
return utils.sort_email_addresses(users, env)
|
||||||
|
|
||||||
|
|
||||||
def sizeof_fmt(num):
|
def sizeof_fmt(num):
|
||||||
for unit in ['', 'K', 'M', 'G', 'T']:
|
for unit in ['','K','M','G','T']:
|
||||||
if abs(num) < 1024.0:
|
if abs(num) < 1024.0:
|
||||||
if abs(num) > 99:
|
if abs(num) > 99:
|
||||||
return "%3.0f%s" % (num, unit)
|
return "%3.0f%s" % (num, unit)
|
||||||
|
@ -130,7 +117,6 @@ def sizeof_fmt(num):
|
||||||
|
|
||||||
return str(num)
|
return str(num)
|
||||||
|
|
||||||
|
|
||||||
def get_mail_users_ex(env, with_archived=False):
|
def get_mail_users_ex(env, with_archived=False):
|
||||||
# Returns a complex data structure of all user accounts, optionally
|
# Returns a complex data structure of all user accounts, optionally
|
||||||
# including archived (status="inactive") accounts.
|
# including archived (status="inactive") accounts.
|
||||||
|
@ -164,9 +150,7 @@ def get_mail_users_ex(env, with_archived=False):
|
||||||
box_quota = 0
|
box_quota = 0
|
||||||
percent = ''
|
percent = ''
|
||||||
try:
|
try:
|
||||||
dirsize_file = os.path.join(
|
dirsize_file = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes/%s/%s/maildirsize' % (domain, user))
|
||||||
env['STORAGE_ROOT'],
|
|
||||||
'mail/mailboxes/%s/%s/maildirsize' % (domain, user))
|
|
||||||
with open(dirsize_file, 'r') as f:
|
with open(dirsize_file, 'r') as f:
|
||||||
box_quota = int(f.readline().split('S')[0])
|
box_quota = int(f.readline().split('S')[0])
|
||||||
for line in f.readlines():
|
for line in f.readlines():
|
||||||
|
@ -191,11 +175,10 @@ def get_mail_users_ex(env, with_archived=False):
|
||||||
user = {
|
user = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": parse_privs(privileges),
|
"privileges": parse_privs(privileges),
|
||||||
"quota": quota,
|
"quota": quota,
|
||||||
"box_quota": box_quota,
|
"box_quota": box_quota,
|
||||||
"box_size": sizeof_fmt(box_size) if box_size != '?' else box_size,
|
"box_size": sizeof_fmt(box_size) if box_size != '?' else box_size,
|
||||||
"percent":
|
"percent": '%3.0f%%' % percent if type(percent) != str else percent,
|
||||||
'%3.0f%%' % percent if type(percent) != str else percent,
|
|
||||||
"box_count": box_count,
|
"box_count": box_count,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
}
|
}
|
||||||
|
@ -209,41 +192,39 @@ def get_mail_users_ex(env, with_archived=False):
|
||||||
for user in os.listdir(os.path.join(root, domain)):
|
for user in os.listdir(os.path.join(root, domain)):
|
||||||
email = user + "@" + domain
|
email = user + "@" + domain
|
||||||
mbox = os.path.join(root, domain, user)
|
mbox = os.path.join(root, domain, user)
|
||||||
if email in active_accounts:
|
if email in active_accounts: continue
|
||||||
continue
|
|
||||||
user = {
|
user = {
|
||||||
"email": email,
|
"email": email,
|
||||||
"privileges": [],
|
"privileges": [],
|
||||||
"status": "inactive",
|
"status": "inactive",
|
||||||
"mailbox": mbox,
|
"mailbox": mbox,
|
||||||
"box_count": '?',
|
"box_count": '?',
|
||||||
"box_size": '?',
|
"box_size": '?',
|
||||||
"box_quota": '?',
|
"box_quota": '?',
|
||||||
"percent": '?',
|
"percent": '?',
|
||||||
}
|
}
|
||||||
users.append(user)
|
users.append(user)
|
||||||
|
|
||||||
# Group by domain.
|
# Group by domain.
|
||||||
domains = {}
|
domains = { }
|
||||||
for user in users:
|
for user in users:
|
||||||
domain = get_domain(user["email"])
|
domain = get_domain(user["email"])
|
||||||
if domain not in domains:
|
if domain not in domains:
|
||||||
domains[domain] = {"domain": domain, "users": []}
|
domains[domain] = {
|
||||||
|
"domain": domain,
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
domains[domain]["users"].append(user)
|
domains[domain]["users"].append(user)
|
||||||
|
|
||||||
# Sort domains.
|
# Sort domains.
|
||||||
domains = [
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sort users within each domain first by status then lexicographically by email address.
|
# Sort users within each domain first by status then lexicographically by email address.
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
domain["users"].sort(
|
domain["users"].sort(key = lambda user : (user["status"] != "active", user["email"]))
|
||||||
key=lambda user: (user["status"] != "active", user["email"]))
|
|
||||||
|
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
def get_admins(env):
|
def get_admins(env):
|
||||||
# Returns a set of users with admin privileges.
|
# Returns a set of users with admin privileges.
|
||||||
users = set()
|
users = set()
|
||||||
|
@ -253,23 +234,16 @@ def get_admins(env):
|
||||||
users.add(user["email"])
|
users.add(user["email"])
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
def get_mail_aliases(env):
|
def get_mail_aliases(env):
|
||||||
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders, auto).
|
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders, auto).
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute(
|
c.execute('SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases')
|
||||||
'SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases'
|
aliases = { row[0]: row for row in c.fetchall() } # make dict
|
||||||
)
|
|
||||||
aliases = {row[0]: row for row in c.fetchall()} # make dict
|
|
||||||
|
|
||||||
# put in a canonical order: sort by domain, then by email address lexicographically
|
# put in a canonical order: sort by domain, then by email address lexicographically
|
||||||
aliases = [
|
aliases = [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ]
|
||||||
aliases[address]
|
|
||||||
for address in utils.sort_email_addresses(aliases.keys(), env)
|
|
||||||
]
|
|
||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
def get_mail_aliases_ex(env):
|
def get_mail_aliases_ex(env):
|
||||||
# Returns a complex data structure of all mail aliases, similar
|
# Returns a complex data structure of all mail aliases, similar
|
||||||
# to get_mail_users_ex.
|
# to get_mail_users_ex.
|
||||||
|
@ -294,8 +268,7 @@ def get_mail_aliases_ex(env):
|
||||||
domains = {}
|
domains = {}
|
||||||
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
||||||
# skip auto domain maps since these are not informative in the control panel's aliases list
|
# skip auto domain maps since these are not informative in the control panel's aliases list
|
||||||
if auto and address.startswith("@"):
|
if auto and address.startswith("@"): continue
|
||||||
continue
|
|
||||||
|
|
||||||
# get alias info
|
# get alias info
|
||||||
domain = get_domain(address)
|
domain = get_domain(address)
|
||||||
|
@ -307,42 +280,28 @@ def get_mail_aliases_ex(env):
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
}
|
}
|
||||||
domains[domain]["aliases"].append({
|
domains[domain]["aliases"].append({
|
||||||
"address":
|
"address": address,
|
||||||
address,
|
"address_display": prettify_idn_email_address(address),
|
||||||
"address_display":
|
"forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(",")],
|
||||||
prettify_idn_email_address(address),
|
"permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None,
|
||||||
"forwards_to": [
|
"auto": bool(auto),
|
||||||
prettify_idn_email_address(r.strip())
|
|
||||||
for r in forwards_to.split(",")
|
|
||||||
],
|
|
||||||
"permitted_senders": [
|
|
||||||
prettify_idn_email_address(s.strip())
|
|
||||||
for s in permitted_senders.split(",")
|
|
||||||
] if permitted_senders is not None else None,
|
|
||||||
"auto":
|
|
||||||
bool(auto),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort domains.
|
# Sort domains.
|
||||||
domains = [
|
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)]
|
||||||
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Sort aliases within each domain first by required-ness then lexicographically by address.
|
# Sort aliases within each domain first by required-ness then lexicographically by address.
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
domain["aliases"].sort(
|
domain["aliases"].sort(key = lambda alias : (alias["auto"], alias["address"]))
|
||||||
key=lambda alias: (alias["auto"], alias["address"]))
|
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
def get_noreply_addresses(env):
|
def get_noreply_addresses(env):
|
||||||
# Returns a set of noreply addresses:
|
# Returns a set of noreply addresses:
|
||||||
# Noreply addresses are a special type of addresses that are send-only.
|
# Noreply addresses are a special type of addresses that are send-only.
|
||||||
# Mail sent to these addresses is automatically bounced with a customized message..
|
# Mail sent to these addresses is automatically bounced with a customized message..
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT email FROM noreply')
|
c.execute('SELECT email FROM noreply')
|
||||||
return set(row[0] for row in c.fetchall())
|
return set( row[0] for row in c.fetchall() )
|
||||||
|
|
||||||
|
|
||||||
def get_domain(emailaddr, as_unicode=True):
|
def get_domain(emailaddr, as_unicode=True):
|
||||||
# Gets the domain part of an email address. Turns IDNA
|
# Gets the domain part of an email address. Turns IDNA
|
||||||
|
@ -357,7 +316,6 @@ def get_domain(emailaddr, as_unicode=True):
|
||||||
pass
|
pass
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def get_all_mail_addresses(env, no_catchalls=True):
|
def get_all_mail_addresses(env, no_catchalls=True):
|
||||||
# Gets the email addresses on literally all tables (users, aliases and noreplies)
|
# Gets the email addresses on literally all tables (users, aliases and noreplies)
|
||||||
emails = get_mail_users(env)
|
emails = get_mail_users(env)
|
||||||
|
@ -370,28 +328,18 @@ def get_all_mail_addresses(env, no_catchalls=True):
|
||||||
|
|
||||||
return set(emails)
|
return set(emails)
|
||||||
|
|
||||||
|
def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
|
||||||
def get_mail_domains(env, filter_aliases=lambda alias: True, users_only=False):
|
|
||||||
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
# Returns the domain names (IDNA-encoded) of all of the email addresses
|
||||||
# configured on the system. If users_only is True, only return domains
|
# configured on the system. If users_only is True, only return domains
|
||||||
# with email addresses that correspond to user accounts. Exclude Unicode
|
# with email addresses that correspond to user accounts. Exclude Unicode
|
||||||
# forms of domain names listed in the automatic aliases table.
|
# forms of domain names listed in the automatic aliases table.
|
||||||
domains = []
|
domains = []
|
||||||
domains.extend(
|
domains.extend([get_domain(login, as_unicode=False) for login in get_mail_users(env)])
|
||||||
[get_domain(login, as_unicode=False) for login in get_mail_users(env)])
|
|
||||||
if not users_only:
|
if not users_only:
|
||||||
domains.extend([
|
domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ])
|
||||||
get_domain(address, as_unicode=False)
|
domains.extend([get_domain(address, as_unicode=False) for address in get_noreply_addresses(env)])
|
||||||
for address, _, _, auto in get_mail_aliases(env)
|
|
||||||
if filter_aliases(address) and not auto
|
|
||||||
])
|
|
||||||
domains.extend([
|
|
||||||
get_domain(address, as_unicode=False)
|
|
||||||
for address in get_noreply_addresses(env)
|
|
||||||
])
|
|
||||||
return set(domains)
|
return set(domains)
|
||||||
|
|
||||||
|
|
||||||
def add_mail_user(email, pw, privs, quota, env):
|
def add_mail_user(email, pw, privs, quota, env):
|
||||||
# validate email
|
# validate email
|
||||||
if email.strip() == "":
|
if email.strip() == "":
|
||||||
|
@ -399,16 +347,12 @@ def add_mail_user(email, pw, privs, quota, env):
|
||||||
elif not validate_email(email):
|
elif not validate_email(email):
|
||||||
return ("Invalid email address.", 400)
|
return ("Invalid email address.", 400)
|
||||||
elif not validate_email(email, mode='user'):
|
elif not validate_email(email, mode='user'):
|
||||||
return (
|
return ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400)
|
||||||
"User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).",
|
|
||||||
400)
|
|
||||||
elif is_dcv_address(email) and len(get_mail_users(env)) > 0:
|
elif is_dcv_address(email) and len(get_mail_users(env)) > 0:
|
||||||
# Make domain control validation hijacking a little harder to mess up by preventing the usual
|
# Make domain control validation hijacking a little harder to mess up by preventing the usual
|
||||||
# addresses used for DCV from being user accounts. Except let it be the first account because
|
# addresses used for DCV from being user accounts. Except let it be the first account because
|
||||||
# during box setup the user won't know the rules.
|
# during box setup the user won't know the rules.
|
||||||
return (
|
return ("You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.", 400)
|
||||||
"You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.",
|
|
||||||
400)
|
|
||||||
|
|
||||||
# validate password
|
# validate password
|
||||||
validate_password(pw)
|
validate_password(pw)
|
||||||
|
@ -420,8 +364,7 @@ def add_mail_user(email, pw, privs, quota, env):
|
||||||
privs = privs.split("\n")
|
privs = privs.split("\n")
|
||||||
for p in privs:
|
for p in privs:
|
||||||
validation = validate_privilege(p)
|
validation = validate_privilege(p)
|
||||||
if validation:
|
if validation: return validation
|
||||||
return validation
|
|
||||||
|
|
||||||
if quota is None:
|
if quota is None:
|
||||||
quota = get_default_quota()
|
quota = get_default_quota()
|
||||||
|
@ -439,8 +382,7 @@ def add_mail_user(email, pw, privs, quota, env):
|
||||||
|
|
||||||
# add the user to the database
|
# add the user to the database
|
||||||
try:
|
try:
|
||||||
c.execute(
|
c.execute("INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
|
||||||
"INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
|
|
||||||
(email, pw, "\n".join(privs), quota))
|
(email, pw, "\n".join(privs), quota))
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
return ("User already exists.", 400)
|
return ("User already exists.", 400)
|
||||||
|
@ -453,7 +395,6 @@ def add_mail_user(email, pw, privs, quota, env):
|
||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, "mail user added")
|
return kick(env, "mail user added")
|
||||||
|
|
||||||
|
|
||||||
def set_mail_password(email, pw, env):
|
def set_mail_password(email, pw, env):
|
||||||
# validate that password is acceptable
|
# validate that password is acceptable
|
||||||
validate_password(pw)
|
validate_password(pw)
|
||||||
|
@ -469,19 +410,16 @@ def set_mail_password(email, pw, env):
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def hash_password(pw):
|
def hash_password(pw):
|
||||||
# Turn the plain password into a Dovecot-format hashed password, meaning
|
# Turn the plain password into a Dovecot-format hashed password, meaning
|
||||||
# something like "{SCHEME}hashedpassworddata".
|
# something like "{SCHEME}hashedpassworddata".
|
||||||
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
return utils.shell(
|
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||||
'check_output',
|
|
||||||
["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_mail_quota(email, env):
|
def get_mail_quota(email, env):
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("SELECT quota FROM users WHERE email=?", (email, ))
|
c.execute("SELECT quota FROM users WHERE email=?", (email,))
|
||||||
rows = c.fetchall()
|
rows = c.fetchall()
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
return ("That's not a user (%s)." % email, 400)
|
return ("That's not a user (%s)." % email, 400)
|
||||||
|
@ -504,7 +442,6 @@ def set_mail_quota(email, quota, env):
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def dovecot_quota_recalc(email):
|
def dovecot_quota_recalc(email):
|
||||||
# dovecot processes running for the user will not recognize the new quota setting
|
# dovecot processes running for the user will not recognize the new quota setting
|
||||||
# a reload is necessary to reread the quota setting, but it will also shut down
|
# a reload is necessary to reread the quota setting, but it will also shut down
|
||||||
|
@ -515,12 +452,10 @@ def dovecot_quota_recalc(email):
|
||||||
# force dovecot to recalculate the quota info for the user.
|
# force dovecot to recalculate the quota info for the user.
|
||||||
subprocess.call(["doveadm", "quota", "recalc", "-u", email])
|
subprocess.call(["doveadm", "quota", "recalc", "-u", email])
|
||||||
|
|
||||||
|
|
||||||
def get_default_quota(env):
|
def get_default_quota(env):
|
||||||
config = utils.load_settings(env)
|
config = utils.load_settings(env)
|
||||||
return config.get("default-quota", '0')
|
return config.get("default-quota", '0')
|
||||||
|
|
||||||
|
|
||||||
def validate_quota(quota):
|
def validate_quota(quota):
|
||||||
# validate quota
|
# validate quota
|
||||||
quota = quota.strip().upper()
|
quota = quota.strip().upper()
|
||||||
|
@ -528,31 +463,28 @@ def validate_quota(quota):
|
||||||
if quota == "":
|
if quota == "":
|
||||||
raise ValueError("No quota provided.")
|
raise ValueError("No quota provided.")
|
||||||
if re.search(r"[\s,.]", quota):
|
if re.search(r"[\s,.]", quota):
|
||||||
raise ValueError(
|
raise ValueError("Quotas cannot contain spaces, commas, or decimal points.")
|
||||||
"Quotas cannot contain spaces, commas, or decimal points.")
|
|
||||||
if not re.match(r'^[\d]+[GM]?$', quota):
|
if not re.match(r'^[\d]+[GM]?$', quota):
|
||||||
raise ValueError("Invalid quota.")
|
raise ValueError("Invalid quota.")
|
||||||
|
|
||||||
return quota
|
return quota
|
||||||
|
|
||||||
|
|
||||||
def get_mail_password(email, env):
|
def get_mail_password(email, env):
|
||||||
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
||||||
# password format, with a prefixed scheme.
|
# password format, with a prefixed scheme.
|
||||||
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
# update the database
|
# update the database
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT password FROM users WHERE email=?', (email, ))
|
c.execute('SELECT password FROM users WHERE email=?', (email,))
|
||||||
rows = c.fetchall()
|
rows = c.fetchall()
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
raise ValueError("That's not a user (%s)." % email)
|
raise ValueError("That's not a user (%s)." % email)
|
||||||
return rows[0][0]
|
return rows[0][0]
|
||||||
|
|
||||||
|
|
||||||
def remove_mail_user(email, env):
|
def remove_mail_user(email, env):
|
||||||
# remove
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM users WHERE email=?", (email, ))
|
c.execute("DELETE FROM users WHERE email=?", (email,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("That's not a user (%s)." % email, 400)
|
return ("That's not a user (%s)." % email, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
@ -560,39 +492,32 @@ def remove_mail_user(email, env):
|
||||||
# Update things in case any domains are removed.
|
# Update things in case any domains are removed.
|
||||||
return kick(env, "mail user removed")
|
return kick(env, "mail user removed")
|
||||||
|
|
||||||
|
|
||||||
def parse_privs(value):
|
def parse_privs(value):
|
||||||
return [p for p in value.split("\n") if p.strip() != ""]
|
return [p for p in value.split("\n") if p.strip() != ""]
|
||||||
|
|
||||||
|
|
||||||
def get_mail_user_privileges(email, env, empty_on_error=False):
|
def get_mail_user_privileges(email, env, empty_on_error=False):
|
||||||
# get privs
|
# get privs
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute('SELECT privileges FROM users WHERE email=?', (email, ))
|
c.execute('SELECT privileges FROM users WHERE email=?', (email,))
|
||||||
rows = c.fetchall()
|
rows = c.fetchall()
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
if empty_on_error:
|
if empty_on_error: return []
|
||||||
return []
|
|
||||||
return ("That's not a user (%s)." % email, 400)
|
return ("That's not a user (%s)." % email, 400)
|
||||||
return parse_privs(rows[0][0])
|
return parse_privs(rows[0][0])
|
||||||
|
|
||||||
|
|
||||||
def validate_privilege(priv):
|
def validate_privilege(priv):
|
||||||
if "\n" in priv or priv.strip() == "":
|
if "\n" in priv or priv.strip() == "":
|
||||||
return ("That's not a valid privilege (%s)." % priv, 400)
|
return ("That's not a valid privilege (%s)." % priv, 400)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def add_remove_mail_user_privilege(email, priv, action, env):
|
def add_remove_mail_user_privilege(email, priv, action, env):
|
||||||
# validate
|
# validate
|
||||||
validation = validate_privilege(priv)
|
validation = validate_privilege(priv)
|
||||||
if validation:
|
if validation: return validation
|
||||||
return validation
|
|
||||||
|
|
||||||
# get existing privs, but may fail
|
# get existing privs, but may fail
|
||||||
privs = get_mail_user_privileges(email, env)
|
privs = get_mail_user_privileges(email, env)
|
||||||
if isinstance(privs, tuple):
|
if isinstance(privs, tuple): return privs # error
|
||||||
return privs # error
|
|
||||||
|
|
||||||
# update privs set
|
# update privs set
|
||||||
if action == "add":
|
if action == "add":
|
||||||
|
@ -605,21 +530,14 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
||||||
|
|
||||||
# commit to database
|
# commit to database
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("UPDATE users SET privileges=? WHERE email=?",
|
c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email))
|
||||||
("\n".join(privs), email))
|
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("Something went wrong.", 400)
|
return ("Something went wrong.", 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True):
|
||||||
def add_mail_alias(address,
|
|
||||||
forwards_to,
|
|
||||||
permitted_senders,
|
|
||||||
env,
|
|
||||||
update_if_exists=False,
|
|
||||||
do_kick=True):
|
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
address = sanitize_idn_email_address(address)
|
address = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
|
@ -656,23 +574,17 @@ def add_mail_alias(address,
|
||||||
for line in forwards_to.split("\n"):
|
for line in forwards_to.split("\n"):
|
||||||
for email in line.split(","):
|
for email in line.split(","):
|
||||||
email = email.strip()
|
email = email.strip()
|
||||||
if email == "":
|
if email == "": continue
|
||||||
continue
|
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||||
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
|
||||||
# Strip any +tag from email alias and check privileges
|
# Strip any +tag from email alias and check privileges
|
||||||
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)", '', email)
|
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)",'',email)
|
||||||
if not validate_email(email):
|
if not validate_email(email):
|
||||||
return ("Invalid receiver email address (%s)." % email,
|
return ("Invalid receiver email address (%s)." % email, 400)
|
||||||
400)
|
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True):
|
||||||
if is_dcv_source and not is_dcv_address(
|
|
||||||
email) and "admin" not in get_mail_user_privileges(
|
|
||||||
privileged_email, env, empty_on_error=True):
|
|
||||||
# Make domain control validation hijacking a little harder to mess up by
|
# Make domain control validation hijacking a little harder to mess up by
|
||||||
# requiring aliases for email addresses typically used in DCV to forward
|
# requiring aliases for email addresses typically used in DCV to forward
|
||||||
# only to accounts that are administrators on this system.
|
# only to accounts that are administrators on this system.
|
||||||
return (
|
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400)
|
||||||
"This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.",
|
|
||||||
400)
|
|
||||||
validated_forwards_to.append(email)
|
validated_forwards_to.append(email)
|
||||||
|
|
||||||
# validate permitted_senders
|
# validate permitted_senders
|
||||||
|
@ -685,19 +597,14 @@ def add_mail_alias(address,
|
||||||
for line in permitted_senders.split("\n"):
|
for line in permitted_senders.split("\n"):
|
||||||
for login in line.split(","):
|
for login in line.split(","):
|
||||||
login = login.strip()
|
login = login.strip()
|
||||||
if login == "":
|
if login == "": continue
|
||||||
continue
|
|
||||||
if login not in valid_logins:
|
if login not in valid_logins:
|
||||||
return (
|
return ("Invalid permitted sender: %s is not a user on this system." % login, 400)
|
||||||
"Invalid permitted sender: %s is not a user on this system."
|
|
||||||
% login, 400)
|
|
||||||
validated_permitted_senders.append(login)
|
validated_permitted_senders.append(login)
|
||||||
|
|
||||||
# Make sure the alias has either a forwards_to or a permitted_sender.
|
# Make sure the alias has either a forwards_to or a permitted_sender.
|
||||||
if len(validated_forwards_to) + len(validated_permitted_senders) == 0:
|
if len(validated_forwards_to) + len(validated_permitted_senders) == 0:
|
||||||
return (
|
return ("The alias must either forward to an address or have a permitted sender.", 400)
|
||||||
"The alias must either forward to an address or have a permitted sender.",
|
|
||||||
400)
|
|
||||||
|
|
||||||
# save to db
|
# save to db
|
||||||
|
|
||||||
|
@ -710,17 +617,13 @@ def add_mail_alias(address,
|
||||||
|
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
try:
|
try:
|
||||||
c.execute(
|
c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders))
|
||||||
"INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)",
|
|
||||||
(address, forwards_to, permitted_senders))
|
|
||||||
return_status = "alias added"
|
return_status = "alias added"
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
if not update_if_exists:
|
if not update_if_exists:
|
||||||
return ("Alias already exists (%s)." % address, 400)
|
return ("Alias already exists (%s)." % address, 400)
|
||||||
else:
|
else:
|
||||||
c.execute(
|
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address))
|
||||||
"UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?",
|
|
||||||
(forwards_to, permitted_senders, address))
|
|
||||||
return_status = "alias updated"
|
return_status = "alias updated"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
@ -729,14 +632,13 @@ def add_mail_alias(address,
|
||||||
# Update things in case any new domains are added.
|
# Update things in case any new domains are added.
|
||||||
return kick(env, return_status)
|
return kick(env, return_status)
|
||||||
|
|
||||||
|
|
||||||
def remove_mail_alias(address, env, do_kick=True):
|
def remove_mail_alias(address, env, do_kick=True):
|
||||||
# convert Unicode domain to IDNA
|
# convert Unicode domain to IDNA
|
||||||
address = sanitize_idn_email_address(address)
|
address = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
# remove
|
# remove
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM aliases WHERE source=?", (address, ))
|
c.execute("DELETE FROM aliases WHERE source=?", (address,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("That's not an alias (%s)." % address, 400)
|
return ("That's not an alias (%s)." % address, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
@ -745,21 +647,16 @@ def remove_mail_alias(address, env, do_kick=True):
|
||||||
# Update things in case any domains are removed.
|
# Update things in case any domains are removed.
|
||||||
return kick(env, "alias removed")
|
return kick(env, "alias removed")
|
||||||
|
|
||||||
|
|
||||||
def add_auto_aliases(aliases, env):
|
def add_auto_aliases(aliases, env):
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM auto_aliases")
|
c.execute("DELETE FROM auto_aliases");
|
||||||
for source, destination in aliases.items():
|
for source, destination in aliases.items():
|
||||||
c.execute(
|
c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||||
"INSERT INTO auto_aliases (source, destination) VALUES (?, ?)",
|
|
||||||
(source, destination))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_system_administrator(env):
|
def get_system_administrator(env):
|
||||||
return "administrator@" + env['PRIMARY_HOSTNAME']
|
return "administrator@" + env['PRIMARY_HOSTNAME']
|
||||||
|
|
||||||
|
|
||||||
def get_required_aliases(env):
|
def get_required_aliases(env):
|
||||||
# These are the aliases that must exist.
|
# These are the aliases that must exist.
|
||||||
aliases = set()
|
aliases = set()
|
||||||
|
@ -772,11 +669,13 @@ def get_required_aliases(env):
|
||||||
|
|
||||||
# Get a list of domains we serve mail for, except ones for which the only
|
# Get a list of domains we serve mail for, except ones for which the only
|
||||||
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
||||||
real_mail_domains = get_mail_domains(
|
real_mail_domains = get_mail_domains(env,
|
||||||
env,
|
filter_aliases = lambda alias :
|
||||||
filter_aliases=lambda alias: not alias.startswith("postmaster@") and
|
not alias.startswith("postmaster@")
|
||||||
not alias.startswith("admin@") and not alias.startswith(
|
and not alias.startswith("admin@")
|
||||||
"abuse@") and not alias.startswith("@"))
|
and not alias.startswith("abuse@")
|
||||||
|
and not alias.startswith("@")
|
||||||
|
)
|
||||||
|
|
||||||
# Create postmaster@, admin@ and abuse@ for all domains we serve
|
# Create postmaster@, admin@ and abuse@ for all domains we serve
|
||||||
# mail on. postmaster@ is assumed to exist by our Postfix configuration.
|
# mail on. postmaster@ is assumed to exist by our Postfix configuration.
|
||||||
|
@ -790,7 +689,6 @@ def get_required_aliases(env):
|
||||||
|
|
||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
def add_noreply_address(env, address, do_kick=True):
|
def add_noreply_address(env, address, do_kick=True):
|
||||||
email = sanitize_idn_email_address(address)
|
email = sanitize_idn_email_address(address)
|
||||||
# validate email
|
# validate email
|
||||||
|
@ -804,30 +702,29 @@ def add_noreply_address(env, address, do_kick=True):
|
||||||
return ("This address is already an user.", 400)
|
return ("This address is already an user.", 400)
|
||||||
elif email in get_mail_aliases(env):
|
elif email in get_mail_aliases(env):
|
||||||
return ("This address is already an alias.", 400)
|
return ("This address is already an alias.", 400)
|
||||||
|
|
||||||
ret = ""
|
ret = ""
|
||||||
|
|
||||||
# Add the address
|
# Add the address
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
try:
|
try:
|
||||||
c.execute("INSERT INTO noreply (email) VALUES (?)", (email, ))
|
c.execute("INSERT INTO noreply (email) VALUES (?)", (email,))
|
||||||
if do_kick:
|
if do_kick:
|
||||||
ret = kick(env, "No-reply address (%s) added" % address)
|
ret = kick(env, "No-reply address (%s) added" % address)
|
||||||
else:
|
else:
|
||||||
ret = "No-reply address (%s) added" % address
|
ret = "No-reply address (%s) added" % address
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
return ("This noreply (%s) already exists." % address, 400)
|
return ("This noreply (%s) already exists." % address, 400)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def remove_noreply_address(env, address, do_kick=True):
|
def remove_noreply_address(env, address, do_kick=True):
|
||||||
email = sanitize_idn_email_address(address)
|
email = sanitize_idn_email_address(address)
|
||||||
|
|
||||||
# yeet yeet deleet
|
# yeet yeet deleet
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute("DELETE FROM noreply WHERE email=?", (email, ))
|
c.execute("DELETE FROM noreply WHERE email=?", (email,))
|
||||||
if c.rowcount != 1:
|
if c.rowcount != 1:
|
||||||
return ("That's not a noreply (%s)." % address, 400)
|
return ("That's not a noreply (%s)." % address, 400)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
@ -836,10 +733,8 @@ def remove_noreply_address(env, address, do_kick=True):
|
||||||
# Update things in case any domains are removed.
|
# Update things in case any domains are removed.
|
||||||
return kick(env, "No-reply address removed")
|
return kick(env, "No-reply address removed")
|
||||||
|
|
||||||
|
|
||||||
def get_required_noreply_addresses(env):
|
def get_required_noreply_addresses(env):
|
||||||
return {"noreply-daemon@" + env['PRIMARY_HOSTNAME']}
|
return { "noreply-daemon@" + env['PRIMARY_HOSTNAME'] }
|
||||||
|
|
||||||
|
|
||||||
def kick(env, mail_result=None):
|
def kick(env, mail_result=None):
|
||||||
results = []
|
results = []
|
||||||
|
@ -849,7 +744,7 @@ def kick(env, mail_result=None):
|
||||||
if mail_result is not None:
|
if mail_result is not None:
|
||||||
results.append(mail_result + "\n")
|
results.append(mail_result + "\n")
|
||||||
|
|
||||||
auto_aliases = {}
|
auto_aliases = { }
|
||||||
|
|
||||||
# Mape required aliases to the administrator alias (which should be created manually).
|
# Mape required aliases to the administrator alias (which should be created manually).
|
||||||
administrator = get_system_administrator(env)
|
administrator = get_system_administrator(env)
|
||||||
|
@ -857,20 +752,18 @@ def kick(env, mail_result=None):
|
||||||
required_noreply = get_required_noreply_addresses(env)
|
required_noreply = get_required_noreply_addresses(env)
|
||||||
existing_noreply = get_noreply_addresses(env)
|
existing_noreply = get_noreply_addresses(env)
|
||||||
for alias in required_aliases:
|
for alias in required_aliases:
|
||||||
if alias == administrator:
|
if alias == administrator: continue # don't make an alias from the administrator to itself --- this alias must be created manually
|
||||||
continue # don't make an alias from the administrator to itself --- this alias must be created manually
|
auto_aliases[alias] = administrator
|
||||||
auto_aliases[alias] = administrator
|
|
||||||
|
|
||||||
# Add domain maps from Unicode forms of IDNA domains to the ASCII forms stored in the alias table.
|
# Add domain maps from Unicode forms of IDNA domains to the ASCII forms stored in the alias table.
|
||||||
for domain in get_mail_domains(env):
|
for domain in get_mail_domains(env):
|
||||||
try:
|
try:
|
||||||
domain_unicode = idna.decode(domain.encode("ascii"))
|
domain_unicode = idna.decode(domain.encode("ascii"))
|
||||||
if domain == domain_unicode:
|
if domain == domain_unicode: continue # not an IDNA/Unicode domain
|
||||||
continue # not an IDNA/Unicode domain
|
|
||||||
auto_aliases["@" + domain_unicode] = "@" + domain
|
auto_aliases["@" + domain_unicode] = "@" + domain
|
||||||
except (ValueError, UnicodeError, idna.IDNAError):
|
except (ValueError, UnicodeError, idna.IDNAError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
add_auto_aliases(auto_aliases, env)
|
add_auto_aliases(auto_aliases, env)
|
||||||
for address in required_noreply - existing_noreply:
|
for address in required_noreply - existing_noreply:
|
||||||
add_noreply_address(env, address, do_kick=False)
|
add_noreply_address(env, address, do_kick=False)
|
||||||
|
@ -881,25 +774,22 @@ def kick(env, mail_result=None):
|
||||||
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
||||||
user, domain = address.split("@")
|
user, domain = address.split("@")
|
||||||
if user in ("postmaster", "admin", "abuse") \
|
if user in ("postmaster", "admin", "abuse") \
|
||||||
and address not in required_aliases \
|
and address not in required_aliases \
|
||||||
and forwards_to == get_system_administrator(env) \
|
and forwards_to == get_system_administrator(env) \
|
||||||
and not auto:
|
and not auto:
|
||||||
remove_mail_alias(address, env, do_kick=False)
|
remove_mail_alias(address, env, do_kick=False)
|
||||||
results.append(
|
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to))
|
||||||
"removed alias %s (was to %s; domain no longer used for email)\n"
|
|
||||||
% (address, forwards_to))
|
|
||||||
|
|
||||||
# Update DNS and nginx in case any domains are added/removed.
|
# Update DNS and nginx in case any domains are added/removed.
|
||||||
|
|
||||||
from dns_update import do_dns_update
|
from dns_update import do_dns_update
|
||||||
results.append(do_dns_update(env))
|
results.append( do_dns_update(env) )
|
||||||
|
|
||||||
from web_update import do_web_update
|
from web_update import do_web_update
|
||||||
results.append(do_web_update(env))
|
results.append( do_web_update(env) )
|
||||||
|
|
||||||
return "".join(s for s in results if s != "")
|
return "".join(s for s in results if s != "")
|
||||||
|
|
||||||
|
|
||||||
def validate_password(pw):
|
def validate_password(pw):
|
||||||
# validate password
|
# validate password
|
||||||
if pw.strip() == "":
|
if pw.strip() == "":
|
||||||
|
@ -907,7 +797,6 @@ def validate_password(pw):
|
||||||
if len(pw) < 8:
|
if len(pw) < 8:
|
||||||
raise ValueError("Passwords must be at least eight characters.")
|
raise ValueError("Passwords must be at least eight characters.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
||||||
|
|
|
@ -7,46 +7,33 @@ import qrcode
|
||||||
|
|
||||||
from mailconfig import open_database
|
from mailconfig import open_database
|
||||||
|
|
||||||
|
|
||||||
def get_user_id(email, c):
|
def get_user_id(email, c):
|
||||||
c.execute('SELECT id FROM users WHERE email=?', (email, ))
|
c.execute('SELECT id FROM users WHERE email=?', (email,))
|
||||||
r = c.fetchone()
|
r = c.fetchone()
|
||||||
if not r:
|
if not r: raise ValueError("User does not exist.")
|
||||||
raise ValueError("User does not exist.")
|
|
||||||
return r[0]
|
return r[0]
|
||||||
|
|
||||||
|
|
||||||
def get_mfa_state(email, env):
|
def get_mfa_state(email, env):
|
||||||
c = open_database(env)
|
c = open_database(env)
|
||||||
c.execute(
|
c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
'SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?',
|
return [
|
||||||
(get_user_id(email, c), ))
|
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
|
||||||
return [{
|
for r in c.fetchall()
|
||||||
"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):
|
def get_public_mfa_state(email, env):
|
||||||
mfa_state = get_mfa_state(email, env)
|
mfa_state = get_mfa_state(email, env)
|
||||||
return [{
|
return [
|
||||||
"id": s["id"],
|
{ "id": s["id"], "type": s["type"], "label": s["label"] }
|
||||||
"type": s["type"],
|
for s in mfa_state
|
||||||
"label": s["label"]
|
]
|
||||||
} for s in mfa_state]
|
|
||||||
|
|
||||||
|
|
||||||
def get_hash_mfa_state(email, env):
|
def get_hash_mfa_state(email, env):
|
||||||
mfa_state = get_mfa_state(email, env)
|
mfa_state = get_mfa_state(email, env)
|
||||||
return [{
|
return [
|
||||||
"id": s["id"],
|
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
|
||||||
"type": s["type"],
|
for s in mfa_state
|
||||||
"secret": s["secret"]
|
]
|
||||||
} for s in mfa_state]
|
|
||||||
|
|
||||||
|
|
||||||
def enable_mfa(email, type, secret, token, label, env):
|
def enable_mfa(email, type, secret, token, label, env):
|
||||||
if type == "totp":
|
if type == "totp":
|
||||||
|
@ -59,48 +46,41 @@ def enable_mfa(email, type, secret, token, label, env):
|
||||||
raise ValueError("Invalid MFA type.")
|
raise ValueError("Invalid MFA type.")
|
||||||
|
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute(
|
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
|
||||||
'INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)',
|
|
||||||
(get_user_id(email, c), type, secret, label))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def set_mru_token(email, mfa_id, token, env):
|
def set_mru_token(email, mfa_id, token, env):
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?',
|
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
|
||||||
(token, get_user_id(email, c), mfa_id))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def disable_mfa(email, mfa_id, env):
|
def disable_mfa(email, mfa_id, env):
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
if mfa_id is None:
|
if mfa_id is None:
|
||||||
# Disable all MFA for a user.
|
# Disable all MFA for a user.
|
||||||
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c), ))
|
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||||
else:
|
else:
|
||||||
# Disable a particular MFA mode for a user.
|
# Disable a particular MFA mode for a user.
|
||||||
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?',
|
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
|
||||||
(get_user_id(email, c), mfa_id))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return c.rowcount > 0
|
return c.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def validate_totp_secret(secret):
|
def validate_totp_secret(secret):
|
||||||
if type(secret) != str or secret.strip() == "":
|
if type(secret) != str or secret.strip() == "":
|
||||||
raise ValueError("No secret provided.")
|
raise ValueError("No secret provided.")
|
||||||
if len(secret) != 32:
|
if len(secret) != 32:
|
||||||
raise ValueError("Secret should be a 32 characters base32 string")
|
raise ValueError("Secret should be a 32 characters base32 string")
|
||||||
|
|
||||||
|
|
||||||
def provision_totp(email, env):
|
def provision_totp(email, env):
|
||||||
# Make a new secret.
|
# Make a new secret.
|
||||||
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||||
validate_totp_secret(secret) # sanity check
|
validate_totp_secret(secret) # sanity check
|
||||||
|
|
||||||
# Make a URI that we encode within a QR code.
|
# Make a URI that we encode within a QR code.
|
||||||
uri = pyotp.TOTP(secret).provisioning_uri(
|
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||||
name=email,
|
name=email,
|
||||||
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel")
|
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
|
||||||
|
)
|
||||||
|
|
||||||
# Generate a QR code as a base64-encode PNG image.
|
# Generate a QR code as a base64-encode PNG image.
|
||||||
qr = qrcode.make(uri)
|
qr = qrcode.make(uri)
|
||||||
|
@ -108,8 +88,11 @@ def provision_totp(email, env):
|
||||||
qr.save(byte_arr, format='PNG')
|
qr.save(byte_arr, format='PNG')
|
||||||
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
return {"type": "totp", "secret": secret, "qr_code_base64": png_b64}
|
return {
|
||||||
|
"type": "totp",
|
||||||
|
"secret": secret,
|
||||||
|
"qr_code_base64": png_b64
|
||||||
|
}
|
||||||
|
|
||||||
def validate_auth_mfa(email, request, env):
|
def validate_auth_mfa(email, request, env):
|
||||||
# Validates that a login request satisfies any MFA modes
|
# Validates that a login request satisfies any MFA modes
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
#!/usr/local/lib/mailinabox/env/bin/python
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
# Tools to manipulate PGP keys
|
# Tools to manipulate PGP keys
|
||||||
|
|
||||||
import gpg
|
import gpg, utils, datetime, shutil, tempfile
|
||||||
import utils
|
|
||||||
import datetime
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
env = utils.load_environment()
|
env = utils.load_environment()
|
||||||
|
|
||||||
|
@ -15,73 +11,50 @@ daemon_key_fpr = env['PGPKEY']
|
||||||
default_context = gpg.Context(armor=True, home_dir=gpghome)
|
default_context = gpg.Context(armor=True, home_dir=gpghome)
|
||||||
|
|
||||||
# Auxiliary function to process the key in order to be read more conveniently
|
# Auxiliary function to process the key in order to be read more conveniently
|
||||||
|
|
||||||
|
|
||||||
def key_representation(key):
|
def key_representation(key):
|
||||||
if key is None:
|
if key is None:
|
||||||
return None
|
return None
|
||||||
key_rep = {
|
key_rep = {
|
||||||
"master_fpr": key.fpr,
|
"master_fpr": key.fpr,
|
||||||
"revoked": key.revoked != 0,
|
"revoked": key.revoked != 0,
|
||||||
"ids": [],
|
"ids": [],
|
||||||
"ids_emails": {},
|
"ids_emails": {},
|
||||||
"subkeys": []
|
"subkeys": []
|
||||||
}
|
}
|
||||||
|
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
key_rep["ids"] = [id.uid for id in key.uids]
|
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 }) # No duplicate email addresses in this list
|
||||||
key_rep["ids_emails"] = list({id.email for id in key.uids})
|
key_rep["subkeys"] = [{
|
||||||
key_rep["subkeys"] = [{
|
"master": skey.fpr == key.fpr,
|
||||||
"master":
|
"sign": skey.can_sign == 1,
|
||||||
skey.fpr == key.fpr,
|
"cert": skey.can_certify == 1,
|
||||||
"sign":
|
"encr": skey.can_encrypt == 1,
|
||||||
skey.can_sign == 1,
|
"auth": skey.can_authenticate == 1,
|
||||||
"cert":
|
"fpr": skey.fpr,
|
||||||
skey.can_certify == 1,
|
"expires": skey.expires if skey.expires != 0 else None,
|
||||||
"encr":
|
"expires_date": datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat() if skey.expires != 0 else None,
|
||||||
skey.can_encrypt == 1,
|
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) - now).days if skey.expires != 0 else None,
|
||||||
"auth":
|
"expired": skey.expired == 1,
|
||||||
skey.can_authenticate == 1,
|
"algorithm": gpg.core.pubkey_algo_name(skey.pubkey_algo),
|
||||||
"fpr":
|
"bits": skey.length
|
||||||
skey.fpr,
|
} for skey in key.subkeys ]
|
||||||
"expires":
|
|
||||||
skey.expires if skey.expires != 0 else None,
|
|
||||||
"expires_date":
|
|
||||||
datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat()
|
|
||||||
if skey.expires != 0 else None,
|
|
||||||
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) -
|
|
||||||
now).days if skey.expires != 0 else None,
|
|
||||||
"expired":
|
|
||||||
skey.expired == 1,
|
|
||||||
"algorithm":
|
|
||||||
gpg.core.pubkey_algo_name(skey.pubkey_algo),
|
|
||||||
"bits":
|
|
||||||
skey.length
|
|
||||||
} for skey in key.subkeys]
|
|
||||||
|
|
||||||
return key_rep
|
|
||||||
|
|
||||||
|
return key_rep
|
||||||
|
|
||||||
# Tests an import as for whether we have any sort of private key material in our import
|
# Tests an import as for whether we have any sort of private key material in our import
|
||||||
|
|
||||||
|
|
||||||
def contains_private_keys(imports):
|
def contains_private_keys(imports):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
|
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
|
||||||
result = tmp.key_import(imports)
|
result = tmp.key_import(imports)
|
||||||
try:
|
try:
|
||||||
return result.secret_read != 0
|
return result.secret_read != 0
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise ValueError("Import is not a valid PGP key block!")
|
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
|
# 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):
|
||||||
|
|
||||||
def fork_context(f, context=default_context):
|
|
||||||
from os.path import isdir, isfile
|
from os.path import isdir, isfile
|
||||||
|
|
||||||
def dirs_files_only(current_dir, files):
|
def dirs_files_only(current_dir, files):
|
||||||
ignore = []
|
ignore = []
|
||||||
for f in files:
|
for f in files:
|
||||||
|
@ -92,18 +65,15 @@ def fork_context(f, context=default_context):
|
||||||
|
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
shutil.copytree(context.home_dir,
|
shutil.copytree(context.home_dir, f"{tmpdir}/gnupg", ignore=dirs_files_only)
|
||||||
f"{tmpdir}/gnupg",
|
kwargs["context"] = gpg.Context(armor=context.armor, 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()
|
kwargs["buffer"] = gpg.Data()
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
def get_key(fingerprint, context=default_context):
|
def get_key(fingerprint, context = default_context):
|
||||||
try:
|
try:
|
||||||
return context.get_key(fingerprint, secret=False)
|
return context.get_key(fingerprint, secret=False)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -111,68 +81,57 @@ def get_key(fingerprint, context=default_context):
|
||||||
except gpg.errors.GPGMEError:
|
except gpg.errors.GPGMEError:
|
||||||
return None
|
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_daemon_key(context=default_context):
|
def get_imported_keys(context = default_context):
|
||||||
if daemon_key_fpr is None or daemon_key_fpr == "":
|
# All the keys in the keyring, except for the daemon's key
|
||||||
return None
|
return list(
|
||||||
return context.get_key(daemon_key_fpr, secret=True)
|
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 get_imported_keys(context=default_context):
|
def export_key(fingerprint, context = default_context):
|
||||||
# All the keys in the keyring, except for the daemon's key
|
if get_key(fingerprint) is None:
|
||||||
return list(
|
return None
|
||||||
filter(lambda k: k.fpr != daemon_key_fpr,
|
return context.key_export(pattern=fingerprint) # Key does exist, export it!
|
||||||
context.keylist(secret=False)))
|
|
||||||
|
|
||||||
|
|
||||||
def import_key(key, context=default_context):
|
|
||||||
data = str.encode(key)
|
|
||||||
if contains_private_keys(data):
|
|
||||||
raise ValueError("Import cannot contain private keys!")
|
|
||||||
return context.key_import(data)
|
|
||||||
|
|
||||||
|
|
||||||
def export_key(fingerprint, context=default_context):
|
|
||||||
if get_key(fingerprint) is None:
|
|
||||||
return None
|
|
||||||
# Key does exist, export it!
|
|
||||||
return context.key_export(pattern=fingerprint)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_key(fingerprint, context=default_context):
|
|
||||||
key = get_key(fingerprint)
|
|
||||||
if fingerprint == daemon_key_fpr:
|
|
||||||
raise ValueError("You cannot delete the daemon's key!")
|
|
||||||
elif key is None:
|
|
||||||
return None
|
|
||||||
context.op_delete_ext(
|
|
||||||
key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
def delete_key(fingerprint, 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
|
# Key usage
|
||||||
|
|
||||||
# Uses the daemon key to sign the provided message. If 'detached' is True, only the signature will be returned
|
# 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)
|
||||||
def create_signature(data, detached=False, context=default_context):
|
return signed_data
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys, utils
|
||||||
import utils
|
# Check if we should renew the key
|
||||||
# Check if we should renew the key
|
|
||||||
|
|
||||||
daemon_key = get_daemon_key()
|
daemon_key = get_daemon_key()
|
||||||
|
|
||||||
exp = daemon_key.subkeys[0].expires
|
exp = daemon_key.subkeys[0].expires
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days
|
days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days
|
||||||
if days_left > 14:
|
if days_left > 14:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
utils.shell("check_output", ["management/pgp_renew.sh"])
|
utils.shell("check_output", ["management/pgp_renew.sh"])
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
#!/usr/local/lib/mailinabox/env/bin/python
|
#!/usr/local/lib/mailinabox/env/bin/python
|
||||||
# Utilities for installing and selecting SSL certificates.
|
# Utilities for installing and selecting SSL certificates.
|
||||||
|
|
||||||
import os
|
import os, os.path, re, shutil, subprocess, tempfile
|
||||||
import os.path
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from utils import shell, safe_domain_name, sort_domains
|
from utils import shell, safe_domain_name, sort_domains
|
||||||
import idna
|
import idna
|
||||||
|
|
||||||
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
||||||
|
|
||||||
|
|
||||||
def get_ssl_certificates(env):
|
def get_ssl_certificates(env):
|
||||||
# Scan all of the installed SSL certificates and map every domain
|
# Scan all of the installed SSL certificates and map every domain
|
||||||
# that the certificates are good for to the best certificate for
|
# that the certificates are good for to the best certificate for
|
||||||
|
@ -50,8 +44,8 @@ def get_ssl_certificates(env):
|
||||||
yield fn1
|
yield fn1
|
||||||
|
|
||||||
# Remember stuff.
|
# Remember stuff.
|
||||||
private_keys = {}
|
private_keys = { }
|
||||||
certificates = []
|
certificates = [ ]
|
||||||
|
|
||||||
# Scan each of the files to find private keys and certificates.
|
# Scan each of the files to find private keys and certificates.
|
||||||
# We must load all of the private keys first before processing
|
# We must load all of the private keys first before processing
|
||||||
|
@ -64,33 +58,36 @@ def get_ssl_certificates(env):
|
||||||
# Not a valid PEM format for a PEM type we care about.
|
# Not a valid PEM format for a PEM type we care about.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Remember where we got this object.
|
||||||
|
pem._filename = fn
|
||||||
|
|
||||||
# Is it a private key?
|
# Is it a private key?
|
||||||
if isinstance(pem, RSAPrivateKey):
|
if isinstance(pem, RSAPrivateKey):
|
||||||
private_keys[pem.public_key().public_numbers()] = { "filename": fn, "key": pem }
|
private_keys[pem.public_key().public_numbers()] = pem
|
||||||
|
|
||||||
# Is it a certificate?
|
# Is it a certificate?
|
||||||
if isinstance(pem, Certificate):
|
if isinstance(pem, Certificate):
|
||||||
certificates.append({ "filename": fn, "cert": pem })
|
certificates.append(pem)
|
||||||
|
|
||||||
# Process the certificates.
|
# Process the certificates.
|
||||||
domains = {}
|
domains = { }
|
||||||
for cert in certificates:
|
for cert in certificates:
|
||||||
# What domains is this certificate good for?
|
# What domains is this certificate good for?
|
||||||
cert_domains, primary_domain = get_certificate_domains(cert["cert"])
|
cert_domains, primary_domain = get_certificate_domains(cert)
|
||||||
cert["primary_domain"] = primary_domain
|
cert._primary_domain = primary_domain
|
||||||
|
|
||||||
# Is there a private key file for this certificate?
|
# Is there a private key file for this certificate?
|
||||||
private_key = private_keys.get(cert["cert"].public_key().public_numbers())
|
private_key = private_keys.get(cert.public_key().public_numbers())
|
||||||
if not private_key:
|
if not private_key:
|
||||||
continue
|
continue
|
||||||
cert["private_key"] = private_key
|
cert._private_key = private_key
|
||||||
|
|
||||||
# Add this cert to the list of certs usable for the domains.
|
# Add this cert to the list of certs usable for the domains.
|
||||||
for domain in cert_domains:
|
for domain in cert_domains:
|
||||||
# The primary hostname can only use a certificate mapped
|
# The primary hostname can only use a certificate mapped
|
||||||
# to the system private key.
|
# to the system private key.
|
||||||
if domain == env['PRIMARY_HOSTNAME']:
|
if domain == env['PRIMARY_HOSTNAME']:
|
||||||
if cert["private_key"]["filename"] != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
|
if cert._private_key._filename != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domains.setdefault(domain, []).append(cert)
|
domains.setdefault(domain, []).append(cert)
|
||||||
|
@ -98,70 +95,62 @@ def get_ssl_certificates(env):
|
||||||
# Sort the certificates to prefer good ones.
|
# Sort the certificates to prefer good ones.
|
||||||
import datetime
|
import datetime
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
ret = {}
|
ret = { }
|
||||||
for domain, cert_list in domains.items():
|
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)
|
#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 : (
|
cert_list.sort(key = lambda cert : (
|
||||||
# must be valid NOW
|
# must be valid NOW
|
||||||
cert["cert"].not_valid_before <= now <= cert["cert"].not_valid_after,
|
cert.not_valid_before <= now <= cert.not_valid_after,
|
||||||
|
|
||||||
# prefer one that is not self-signed
|
# prefer one that is not self-signed
|
||||||
cert["cert"].issuer != cert["cert"].subject,
|
cert.issuer != cert.subject,
|
||||||
|
|
||||||
###########################################################
|
###########################################################
|
||||||
# The above lines ensure that valid certificates are chosen
|
# The above lines ensure that valid certificates are chosen
|
||||||
# over invalid certificates. The lines below choose between
|
# over invalid certificates. The lines below choose between
|
||||||
# multiple valid certificates available for this domain.
|
# multiple valid certificates available for this domain.
|
||||||
###########################################################
|
###########################################################
|
||||||
|
|
||||||
# prefer one with the expiration furthest into the future so
|
# prefer one with the expiration furthest into the future so
|
||||||
# that we can easily rotate to new certs as we get them
|
# that we can easily rotate to new certs as we get them
|
||||||
cert["cert"].not_valid_after,
|
cert.not_valid_after,
|
||||||
|
|
||||||
###########################################################
|
###########################################################
|
||||||
# We always choose the certificate that is good for the
|
# We always choose the certificate that is good for the
|
||||||
# longest period of time. This is important for how we
|
# longest period of time. This is important for how we
|
||||||
# provision certificates for Let's Encrypt. To ensure that
|
# provision certificates for Let's Encrypt. To ensure that
|
||||||
# we don't re-provision every night, we have 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
|
# if we choose to provison a certificate that it will
|
||||||
# *actually* be used so the provisioning logic knows it
|
# *actually* be used so the provisioning logic knows it
|
||||||
# doesn't still need to provision a certificate for the
|
# doesn't still need to provision a certificate for the
|
||||||
# domain.
|
# domain.
|
||||||
###########################################################
|
###########################################################
|
||||||
|
|
||||||
# in case a certificate is installed in multiple paths,
|
# in case a certificate is installed in multiple paths,
|
||||||
# prefer the... lexicographically last one?
|
# prefer the... lexicographically last one?
|
||||||
cert["filename"],
|
cert._filename,
|
||||||
|
|
||||||
), reverse=True)
|
), reverse=True)
|
||||||
cert = cert_list.pop(0)
|
cert = cert_list.pop(0)
|
||||||
ret[domain] = {
|
ret[domain] = {
|
||||||
"private-key": cert["private_key"]["filename"],
|
"private-key": cert._private_key._filename,
|
||||||
"certificate": cert["filename"],
|
"certificate": cert._filename,
|
||||||
"primary-domain": cert["primary_domain"],
|
"primary-domain": cert._primary_domain,
|
||||||
"certificate_object": cert["cert"],
|
"certificate_object": cert,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False, use_main_cert=True):
|
||||||
def get_domain_ssl_files(domain,
|
|
||||||
ssl_certificates,
|
|
||||||
env,
|
|
||||||
allow_missing_cert=False,
|
|
||||||
use_main_cert=True):
|
|
||||||
if use_main_cert or not allow_missing_cert:
|
if use_main_cert or not allow_missing_cert:
|
||||||
# Get the system certificate info.
|
# Get the system certificate info.
|
||||||
ssl_private_key = os.path.join(
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
ssl_certificate = os.path.join(
|
|
||||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
|
||||||
system_certificate = {
|
system_certificate = {
|
||||||
"private-key": ssl_private_key,
|
"private-key": ssl_private_key,
|
||||||
"certificate": ssl_certificate,
|
"certificate": ssl_certificate,
|
||||||
"primary-domain": env['PRIMARY_HOSTNAME'],
|
"primary-domain": env['PRIMARY_HOSTNAME'],
|
||||||
"certificate_object":
|
"certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]),
|
||||||
load_pem(load_cert_chain(ssl_certificate)[0]),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if use_main_cert:
|
if use_main_cert:
|
||||||
|
@ -185,10 +174,7 @@ def get_domain_ssl_files(domain,
|
||||||
|
|
||||||
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
|
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
|
||||||
|
|
||||||
|
def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True):
|
||||||
def get_certificates_to_provision(env,
|
|
||||||
limit_domains=None,
|
|
||||||
show_valid_certs=True):
|
|
||||||
# Get a set of domain names that we can provision certificates for
|
# Get a set of domain names that we can provision certificates for
|
||||||
# using certbot. We start with domains that the box is serving web
|
# using certbot. We start with domains that the box is serving web
|
||||||
# for and subtract:
|
# for and subtract:
|
||||||
|
@ -206,7 +192,7 @@ def get_certificates_to_provision(env,
|
||||||
actual_web_domains = get_web_domains(env)
|
actual_web_domains = get_web_domains(env)
|
||||||
|
|
||||||
domains_to_provision = set()
|
domains_to_provision = set()
|
||||||
domains_cant_provision = {}
|
domains_cant_provision = { }
|
||||||
|
|
||||||
for domain in plausible_web_domains:
|
for domain in plausible_web_domains:
|
||||||
# Skip domains that the user doesn't want to provision now.
|
# Skip domains that the user doesn't want to provision now.
|
||||||
|
@ -215,8 +201,7 @@ def get_certificates_to_provision(env,
|
||||||
|
|
||||||
# Check that there isn't an explicit A/AAAA record.
|
# Check that there isn't an explicit A/AAAA record.
|
||||||
if domain not in actual_web_domains:
|
if domain not in actual_web_domains:
|
||||||
domains_cant_provision[
|
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)."
|
||||||
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.
|
# Check that the DNS resolves to here.
|
||||||
else:
|
else:
|
||||||
|
@ -226,53 +211,41 @@ def get_certificates_to_provision(env,
|
||||||
# make sure both IPv4 and IPv6 are correct because we don't know
|
# make sure both IPv4 and IPv6 are correct because we don't know
|
||||||
# how Let's Encrypt will connect.
|
# how Let's Encrypt will connect.
|
||||||
bad_dns = []
|
bad_dns = []
|
||||||
for rtype, value in [("A", env["PUBLIC_IP"]),
|
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
|
||||||
("AAAA", env.get("PUBLIC_IPV6"))]:
|
if not value: continue # IPv6 is not configured
|
||||||
if not value:
|
|
||||||
continue # IPv6 is not configured
|
|
||||||
response = query_dns(domain, rtype)
|
response = query_dns(domain, rtype)
|
||||||
if response != normalize_ip(value):
|
if response != normalize_ip(value):
|
||||||
bad_dns.append("%s (%s)" % (response, rtype))
|
bad_dns.append("%s (%s)" % (response, rtype))
|
||||||
|
|
||||||
if bad_dns:
|
if bad_dns:
|
||||||
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
|
domains_cant_provision[domain] = "The domain name does not resolve to this machine: " \
|
||||||
+ (", ".join(bad_dns)) \
|
+ (", ".join(bad_dns)) \
|
||||||
+ "."
|
+ "."
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# DNS is all good.
|
# DNS is all good.
|
||||||
|
|
||||||
# Check for a good existing cert.
|
# Check for a good existing cert.
|
||||||
existing_cert = get_domain_ssl_files(domain,
|
existing_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
|
||||||
existing_certs,
|
|
||||||
env,
|
|
||||||
use_main_cert=False,
|
|
||||||
allow_missing_cert=True)
|
|
||||||
if existing_cert:
|
if existing_cert:
|
||||||
existing_cert_check = check_certificate(
|
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],
|
||||||
domain,
|
|
||||||
existing_cert['certificate'],
|
|
||||||
existing_cert['private-key'],
|
|
||||||
warn_if_expiring_soon=14)
|
warn_if_expiring_soon=14)
|
||||||
if existing_cert_check[0] == "OK":
|
if existing_cert_check[0] == "OK":
|
||||||
if show_valid_certs:
|
if show_valid_certs:
|
||||||
domains_cant_provision[
|
domains_cant_provision[domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
|
||||||
domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
|
existing_cert_check[1],
|
||||||
existing_cert_check[1],
|
existing_cert['certificate'],
|
||||||
existing_cert['certificate'],
|
existing_cert['private-key'])
|
||||||
existing_cert['private-key'])
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domains_to_provision.add(domain)
|
domains_to_provision.add(domain)
|
||||||
|
|
||||||
return (domains_to_provision, domains_cant_provision)
|
return (domains_to_provision, domains_cant_provision)
|
||||||
|
|
||||||
|
|
||||||
def provision_certificates(env, limit_domains):
|
def provision_certificates(env, limit_domains):
|
||||||
# What domains should we provision certificates for? And what
|
# What domains should we provision certificates for? And what
|
||||||
# errors prevent provisioning for other domains.
|
# errors prevent provisioning for other domains.
|
||||||
domains, domains_cant_provision = get_certificates_to_provision(
|
domains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)
|
||||||
env, limit_domains=limit_domains)
|
|
||||||
|
|
||||||
# Build a list of what happened on each domain or domain-set.
|
# Build a list of what happened on each domain or domain-set.
|
||||||
ret = []
|
ret = []
|
||||||
|
@ -294,7 +267,7 @@ def provision_certificates(env, limit_domains):
|
||||||
# entry in each list (unless we overflow beyond 100) which ends up as the
|
# entry in each list (unless we overflow beyond 100) which ends up as the
|
||||||
# primary domain listed in each certificate.
|
# primary domain listed in each certificate.
|
||||||
from dns_update import get_dns_zones
|
from dns_update import get_dns_zones
|
||||||
certs = {}
|
certs = { }
|
||||||
for zone, zonefile in get_dns_zones(env):
|
for zone, zonefile in get_dns_zones(env):
|
||||||
certs[zone] = [[]]
|
certs[zone] = [[]]
|
||||||
for domain in sort_domains(domains, env):
|
for domain in sort_domains(domains, env):
|
||||||
|
@ -335,8 +308,7 @@ def provision_certificates(env, limit_domains):
|
||||||
try:
|
try:
|
||||||
# Create a CSR file for our master private key so that certbot
|
# Create a CSR file for our master private key so that certbot
|
||||||
# uses our private key.
|
# uses our private key.
|
||||||
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl',
|
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')
|
||||||
'ssl_private_key.pem')
|
|
||||||
with tempfile.NamedTemporaryFile() as csr_file:
|
with tempfile.NamedTemporaryFile() as csr_file:
|
||||||
# We could use openssl, but certbot requires
|
# We could use openssl, but certbot requires
|
||||||
# that the CN domain and SAN domains match
|
# that the CN domain and SAN domains match
|
||||||
|
@ -354,18 +326,12 @@ def provision_certificates(env, limit_domains):
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.x509.oid import NameOID
|
from cryptography.x509.oid import NameOID
|
||||||
builder = x509.CertificateSigningRequestBuilder()
|
builder = x509.CertificateSigningRequestBuilder()
|
||||||
builder = builder.subject_name(
|
builder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ]))
|
||||||
x509.Name([
|
builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
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(
|
builder = builder.add_extension(x509.SubjectAlternativeName(
|
||||||
[x509.DNSName(d) for d in domain_list]),
|
[x509.DNSName(d) for d in domain_list]
|
||||||
critical=False)
|
), critical=False)
|
||||||
request = builder.sign(load_pem(load_cert_chain(key_file)[0]),
|
request = builder.sign(load_pem(load_cert_chain(key_file)[0]), hashes.SHA256(), default_backend())
|
||||||
hashes.SHA256(), default_backend())
|
|
||||||
with open(csr_file.name, "wb") as f:
|
with open(csr_file.name, "wb") as f:
|
||||||
f.write(request.public_bytes(Encoding.PEM))
|
f.write(request.public_bytes(Encoding.PEM))
|
||||||
|
|
||||||
|
@ -374,40 +340,27 @@ def provision_certificates(env, limit_domains):
|
||||||
os.makedirs(webroot, exist_ok=True)
|
os.makedirs(webroot, exist_ok=True)
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
cert_file = os.path.join(d, 'cert_and_chain.pem')
|
cert_file = os.path.join(d, 'cert_and_chain.pem')
|
||||||
print("Provisioning TLS certificates for " +
|
print("Provisioning TLS certificates for " + ", ".join(domain_list) + ".")
|
||||||
", ".join(domain_list) + ".")
|
certbotret = subprocess.check_output([
|
||||||
certbotret = subprocess.check_output(
|
"certbot",
|
||||||
[
|
"certonly",
|
||||||
"certbot",
|
#"-v", # just enough to see ACME errors
|
||||||
"certonly",
|
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
|
||||||
# "-v", # just enough to see ACME errors
|
"--agree-tos", # Automatically agrees to Let's Encrypt TOS
|
||||||
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
|
"--register-unsafely-without-email", # The daemon takes care of renewals
|
||||||
"--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), # 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, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
|
||||||
"--csr",
|
"--cert-path", os.path.join(d, 'cert'), # we only use the full chain
|
||||||
csr_file.name,
|
"--chain-path", os.path.join(d, 'chain'), # we only use the full chain
|
||||||
# we only use the full chain
|
"--fullchain-path", cert_file,
|
||||||
"--cert-path",
|
|
||||||
os.path.join(d, 'cert'),
|
"--webroot", "--webroot-path", webroot,
|
||||||
# we only use the full chain
|
|
||||||
"--chain-path",
|
"--config-dir", account_path,
|
||||||
os.path.join(d, 'chain'),
|
#"--staging",
|
||||||
"--fullchain-path",
|
], stderr=subprocess.STDOUT).decode("utf8")
|
||||||
cert_file,
|
|
||||||
"--webroot",
|
|
||||||
"--webroot-path",
|
|
||||||
webroot,
|
|
||||||
"--config-dir",
|
|
||||||
account_path,
|
|
||||||
# "--staging",
|
|
||||||
],
|
|
||||||
stderr=subprocess.STDOUT).decode("utf8")
|
|
||||||
install_cert_copy_file(cert_file, env)
|
install_cert_copy_file(cert_file, env)
|
||||||
|
|
||||||
ret[-1]["log"].append(certbotret)
|
ret[-1]["log"].append(certbotret)
|
||||||
|
@ -425,7 +378,6 @@ def provision_certificates(env, limit_domains):
|
||||||
# Return what happened with each certificate request.
|
# Return what happened with each certificate request.
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def provision_certificates_cmdline():
|
def provision_certificates_cmdline():
|
||||||
import sys
|
import sys
|
||||||
from exclusiveprocess import Lock
|
from exclusiveprocess import Lock
|
||||||
|
@ -462,13 +414,12 @@ def provision_certificates_cmdline():
|
||||||
|
|
||||||
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
|
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
|
||||||
|
|
||||||
|
|
||||||
def create_csr(domain, ssl_key, country_code, env):
|
def create_csr(domain, ssl_key, country_code, env):
|
||||||
return shell("check_output", [
|
return shell("check_output", [
|
||||||
"openssl", "req", "-new", "-key", ssl_key, "-sha256", "-subj",
|
"openssl", "req", "-new",
|
||||||
"/C=%s/CN=%s" % (country_code, domain)
|
"-key", ssl_key,
|
||||||
])
|
"-sha256",
|
||||||
|
"-subj", "/C=%s/CN=%s" % (country_code, domain)])
|
||||||
|
|
||||||
def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
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.
|
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||||
|
@ -479,10 +430,8 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
# Do validation on the certificate before installing it.
|
# Do validation on the certificate before installing it.
|
||||||
ssl_private_key = os.path.join(
|
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)
|
||||||
cert_status, cert_status_details = check_certificate(
|
|
||||||
domain, fn, ssl_private_key)
|
|
||||||
if cert_status != "OK":
|
if cert_status != "OK":
|
||||||
if cert_status == "SELF-SIGNED":
|
if cert_status == "SELF-SIGNED":
|
||||||
cert_status = "This is a self-signed certificate. I can't install that."
|
cert_status = "This is a self-signed certificate. I can't install that."
|
||||||
|
@ -496,8 +445,7 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
||||||
|
|
||||||
# Run post-install steps.
|
# Run post-install steps.
|
||||||
ret = post_install_func(env)
|
ret = post_install_func(env)
|
||||||
if raw:
|
if raw: return ret
|
||||||
return ret
|
|
||||||
return "\n".join(ret)
|
return "\n".join(ret)
|
||||||
|
|
||||||
|
|
||||||
|
@ -509,15 +457,11 @@ def install_cert_copy_file(fn, env):
|
||||||
cert = load_pem(load_cert_chain(fn)[0])
|
cert = load_pem(load_cert_chain(fn)[0])
|
||||||
all_domains, cn = get_certificate_domains(cert)
|
all_domains, cn = get_certificate_domains(cert)
|
||||||
path = "%s-%s-%s.pem" % (
|
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), # 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
|
||||||
cert.not_valid_after.date().isoformat().replace("-",
|
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
||||||
""), # expiration date
|
)
|
||||||
hexlify(cert.fingerprint(
|
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
||||||
hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
|
||||||
)
|
|
||||||
ssl_certificate = os.path.join(
|
|
||||||
os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
|
||||||
|
|
||||||
# Install the certificate.
|
# Install the certificate.
|
||||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||||
|
@ -529,21 +473,16 @@ def post_install_func(env):
|
||||||
|
|
||||||
# Get the certificate to use for PRIMARY_HOSTNAME.
|
# Get the certificate to use for PRIMARY_HOSTNAME.
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'],
|
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'], ssl_certificates, env, use_main_cert=False)
|
||||||
ssl_certificates,
|
|
||||||
env,
|
|
||||||
use_main_cert=False)
|
|
||||||
if not cert:
|
if not cert:
|
||||||
# Ruh-row, we don't have any certificate usable
|
# Ruh-row, we don't have any certificate usable
|
||||||
# for the primary hostname.
|
# for the primary hostname.
|
||||||
ret.append("there is no valid certificate for " +
|
ret.append("there is no valid certificate for " + env['PRIMARY_HOSTNAME'])
|
||||||
env['PRIMARY_HOSTNAME'])
|
|
||||||
|
|
||||||
# Symlink the best cert for PRIMARY_HOSTNAME to the system
|
# Symlink the best cert for PRIMARY_HOSTNAME to the system
|
||||||
# certificate path, which is hard-coded for various purposes, and then
|
# certificate path, which is hard-coded for various purposes, and then
|
||||||
# restart postfix and dovecot.
|
# restart postfix and dovecot.
|
||||||
system_ssl_certificate = os.path.join(
|
system_ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
|
||||||
if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
|
if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
|
||||||
# Update symlink.
|
# Update symlink.
|
||||||
ret.append("updating primary certificate")
|
ret.append("updating primary certificate")
|
||||||
|
@ -562,20 +501,13 @@ def post_install_func(env):
|
||||||
|
|
||||||
# Update the web configuration so nginx picks up the new certificate file.
|
# Update the web configuration so nginx picks up the new certificate file.
|
||||||
from web_update import do_web_update
|
from web_update import do_web_update
|
||||||
ret.append(do_web_update(env))
|
ret.append( do_web_update(env) )
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
# VALIDATION OF CERTIFICATES
|
# VALIDATION OF CERTIFICATES
|
||||||
|
|
||||||
|
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=10, rounded_time=False, just_check_domain=False):
|
||||||
def check_certificate(domain,
|
|
||||||
ssl_certificate,
|
|
||||||
ssl_private_key,
|
|
||||||
warn_if_expiring_soon=10,
|
|
||||||
rounded_time=False,
|
|
||||||
just_check_domain=False):
|
|
||||||
# Check that the ssl_certificate & ssl_private_key files are good
|
# Check that the ssl_certificate & ssl_private_key files are good
|
||||||
# for the provided domain.
|
# for the provided domain.
|
||||||
|
|
||||||
|
@ -588,11 +520,9 @@ def check_certificate(domain,
|
||||||
try:
|
try:
|
||||||
ssl_cert_chain = load_cert_chain(ssl_certificate)
|
ssl_cert_chain = load_cert_chain(ssl_certificate)
|
||||||
cert = load_pem(ssl_cert_chain[0])
|
cert = load_pem(ssl_cert_chain[0])
|
||||||
if not isinstance(cert, Certificate):
|
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
|
||||||
raise ValueError("This is not a certificate file.")
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return ("There is a problem with the certificate file: %s" % str(e),
|
return ("There is a problem with the certificate file: %s" % str(e), None)
|
||||||
None)
|
|
||||||
|
|
||||||
# First check that the domain name is one of the names allowed by
|
# First check that the domain name is one of the names allowed by
|
||||||
# the certificate.
|
# the certificate.
|
||||||
|
@ -604,27 +534,21 @@ def check_certificate(domain,
|
||||||
# should work in normal cases).
|
# should work in normal cases).
|
||||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||||
if domain not in certificate_names and wildcard_domain not in certificate_names:
|
if domain not in certificate_names and wildcard_domain not in certificate_names:
|
||||||
return (
|
return ("The certificate is for the wrong domain name. It is for %s."
|
||||||
"The certificate is for the wrong domain name. It is for %s." %
|
% ", ".join(sorted(certificate_names)), None)
|
||||||
", ".join(sorted(certificate_names)), None)
|
|
||||||
|
|
||||||
# Second, check that the certificate matches the private key.
|
# Second, check that the certificate matches the private key.
|
||||||
if ssl_private_key is not None:
|
if ssl_private_key is not None:
|
||||||
try:
|
try:
|
||||||
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return ("The private key file %s is not a private key file: %s" %
|
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
||||||
(ssl_private_key, str(e)), None)
|
|
||||||
|
|
||||||
if not isinstance(priv_key, RSAPrivateKey):
|
if not isinstance(priv_key, RSAPrivateKey):
|
||||||
return ("The private key file %s is not a private key file." %
|
return ("The private key file %s is not a private key file." % ssl_private_key, None)
|
||||||
ssl_private_key, None)
|
|
||||||
|
|
||||||
if priv_key.public_key().public_numbers() != cert.public_key(
|
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers():
|
||||||
).public_numbers():
|
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None)
|
||||||
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
|
# 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".
|
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
|
||||||
|
@ -645,10 +569,8 @@ def check_certificate(domain,
|
||||||
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
|
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
|
||||||
import datetime
|
import datetime
|
||||||
now = datetime.datetime.utcnow()
|
now = datetime.datetime.utcnow()
|
||||||
if not (cert.not_valid_before <= now <= cert.not_valid_after):
|
if not(cert.not_valid_before <= now <= cert.not_valid_after):
|
||||||
return (
|
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)
|
||||||
"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
|
# 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
|
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||||
|
@ -657,17 +579,11 @@ def check_certificate(domain,
|
||||||
|
|
||||||
# The certificate chain has to be passed separately and is given via STDIN.
|
# 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.
|
# This command returns a non-zero exit status in most cases, so trap errors.
|
||||||
retcode, verifyoutput = shell(
|
retcode, verifyoutput = shell('check_output', [
|
||||||
'check_output',
|
"openssl",
|
||||||
[
|
"verify", "-verbose",
|
||||||
"openssl",
|
"-purpose", "sslserver", "-policy_check",]
|
||||||
"verify",
|
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
||||||
"-verbose",
|
|
||||||
"-purpose",
|
|
||||||
"sslserver",
|
|
||||||
"-policy_check",
|
|
||||||
] +
|
|
||||||
([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
|
||||||
+ [ssl_certificate],
|
+ [ssl_certificate],
|
||||||
input=b"\n\n".join(ssl_cert_chain[1:]),
|
input=b"\n\n".join(ssl_cert_chain[1:]),
|
||||||
trap=True)
|
trap=True)
|
||||||
|
@ -678,13 +594,10 @@ def check_certificate(domain,
|
||||||
|
|
||||||
elif retcode != 0:
|
elif retcode != 0:
|
||||||
if "unable to get local issuer certificate" in verifyoutput:
|
if "unable to get local issuer certificate" in verifyoutput:
|
||||||
return (
|
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
||||||
"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.
|
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||||
return ("There is a problem with the certificate.",
|
return ("There is a problem with the certificate.", verifyoutput.strip())
|
||||||
verifyoutput.strip())
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# `openssl verify` returned a zero exit status so the cert is currently
|
# `openssl verify` returned a zero exit status so the cert is currently
|
||||||
|
@ -692,15 +605,13 @@ def check_certificate(domain,
|
||||||
|
|
||||||
# But is it expiring soon?
|
# But is it expiring soon?
|
||||||
cert_expiration_date = cert.not_valid_after
|
cert_expiration_date = cert.not_valid_after
|
||||||
ndays = (cert_expiration_date - now).days
|
ndays = (cert_expiration_date-now).days
|
||||||
if not rounded_time or ndays <= 10:
|
if not rounded_time or ndays <= 10:
|
||||||
# Yikes better renew soon!
|
# Yikes better renew soon!
|
||||||
expiry_info = "The certificate expires in %d days on %s." % (
|
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
|
||||||
ndays, cert_expiration_date.date().isoformat())
|
|
||||||
else:
|
else:
|
||||||
# We'll renew it with Lets Encrypt.
|
# We'll renew it with Lets Encrypt.
|
||||||
expiry_info = "The certificate expires on %s." % cert_expiration_date.date(
|
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
|
||||||
).isoformat()
|
|
||||||
|
|
||||||
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
|
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
|
# Warn on day 10 to give 4 days for us to automatically renew the
|
||||||
|
@ -710,19 +621,17 @@ def check_certificate(domain,
|
||||||
# Return the special OK code.
|
# Return the special OK code.
|
||||||
return ("OK", expiry_info)
|
return ("OK", expiry_info)
|
||||||
|
|
||||||
|
|
||||||
def load_cert_chain(pemfile):
|
def load_cert_chain(pemfile):
|
||||||
# A certificate .pem file may contain a chain of certificates.
|
# A certificate .pem file may contain a chain of certificates.
|
||||||
# Load the file and split them apart.
|
# Load the file and split them apart.
|
||||||
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n]+(?:[A-Za-z0-9+/=]{1,64}[\r\n]+)+-+END (?:.+)-+[\r\n]+)"
|
re_pem = rb"(-+BEGIN (?:.+)-+[\r\n]+(?:[A-Za-z0-9+/=]{1,64}[\r\n]+)+-+END (?:.+)-+[\r\n]+)"
|
||||||
with open(pemfile, "rb") as f:
|
with open(pemfile, "rb") as f:
|
||||||
pem = f.read() + b"\n" # ensure trailing newline
|
pem = f.read() + b"\n" # ensure trailing newline
|
||||||
pemblocks = re.findall(re_pem, pem)
|
pemblocks = re.findall(re_pem, pem)
|
||||||
if len(pemblocks) == 0:
|
if len(pemblocks) == 0:
|
||||||
raise ValueError("File does not contain valid PEM data.")
|
raise ValueError("File does not contain valid PEM data.")
|
||||||
return pemblocks
|
return pemblocks
|
||||||
|
|
||||||
|
|
||||||
def load_pem(pem):
|
def load_pem(pem):
|
||||||
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
|
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
|
||||||
# using classes from the cryptography package.
|
# using classes from the cryptography package.
|
||||||
|
@ -734,14 +643,10 @@ def load_pem(pem):
|
||||||
raise ValueError("File is not a valid PEM-formatted file.")
|
raise ValueError("File is not a valid PEM-formatted file.")
|
||||||
pem_type = pem_type.group(1)
|
pem_type = pem_type.group(1)
|
||||||
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"):
|
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"):
|
||||||
return serialization.load_pem_private_key(pem,
|
return serialization.load_pem_private_key(pem, password=None, backend=default_backend())
|
||||||
password=None,
|
|
||||||
backend=default_backend())
|
|
||||||
if pem_type == b"CERTIFICATE":
|
if pem_type == b"CERTIFICATE":
|
||||||
return load_pem_x509_certificate(pem, default_backend())
|
return load_pem_x509_certificate(pem, default_backend())
|
||||||
raise ValueError("Unsupported PEM object type: " +
|
raise ValueError("Unsupported PEM object type: " + pem_type.decode("ascii", "replace"))
|
||||||
pem_type.decode("ascii", "replace"))
|
|
||||||
|
|
||||||
|
|
||||||
def get_certificate_domains(cert):
|
def get_certificate_domains(cert):
|
||||||
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
|
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
|
||||||
|
@ -770,8 +675,7 @@ def get_certificate_domains(cert):
|
||||||
return idna.encode(dns_name).decode('ascii')
|
return idna.encode(dns_name).decode('ascii')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sans = cert.extensions.get_extension_for_oid(
|
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
||||||
OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
|
||||||
for san in sans:
|
for san in sans:
|
||||||
names.add(idna_decode_dns_name(san))
|
names.add(idna_decode_dns_name(san))
|
||||||
except ExtensionNotFound:
|
except ExtensionNotFound:
|
||||||
|
@ -779,7 +683,6 @@ def get_certificate_domains(cert):
|
||||||
|
|
||||||
return names, cn
|
return names, cn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
if __name__ == "__main__":
|
|
||||||
# Provision certificates.
|
# Provision certificates.
|
||||||
provision_certificates_cmdline()
|
provision_certificates_cmdline()
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,149 +1,115 @@
|
||||||
<style>
|
<style>
|
||||||
#alias_table .alias-auto .actions>* {
|
#alias_table .actions > * { padding-right: 3px; }
|
||||||
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>
|
</style>
|
||||||
|
|
||||||
<h2>Aliases</h2>
|
<h2>Aliases</h2>
|
||||||
|
|
||||||
<h3>Add a mail alias</h3>
|
<h3>Add a mail alias</h3>
|
||||||
|
|
||||||
<p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail
|
<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>
|
||||||
user</a> or to any email address.</p>
|
|
||||||
|
|
||||||
<p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as
|
<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>
|
||||||
a permitted sender for the alias.</p>
|
|
||||||
|
|
||||||
<form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
<form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
|
||||||
|
<div class="form-group">
|
||||||
<div class="mb-3">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
<div id="alias_type_buttons" class="btn-group btn-group-xs">
|
||||||
<button type="button" class="btn btn-secondary" data-mode="regular">Regular</button>
|
<button type="button" class="btn btn-default" data-mode="regular">Regular</button>
|
||||||
<button type="button" class="btn btn-secondary" data-mode="catchall">Catch-All</button>
|
<button type="button" class="btn btn-default" data-mode="catchall">Catch-All</button>
|
||||||
<button type="button" class="btn btn-secondary" data-mode="domainalias">Domain Alias</button>
|
<button type="button" class="btn btn-default" data-mode="domainalias">Domain Alias</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
||||||
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
|
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
|
||||||
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to
|
<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>
|
||||||
another domain, preserving the part before the @-sign.</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<div class="col-lg-9 mb-3">
|
<label for="addaliasAddress" class="col-sm-1 control-label">Alias</label>
|
||||||
<div class="input-group">
|
<div class="col-sm-10">
|
||||||
<label for="addaliasAddress" class="input-group-text">Alias</label>
|
<input type="email" class="form-control" id="addaliasAddress">
|
||||||
<input type="email" class="form-control" id="addaliasAddress">
|
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">
|
||||||
</div>
|
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
|
||||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted ">
|
You may use international (non-ASCII) characters for the domain part of the email address only.
|
||||||
<span class="catchall domainalias">Enter just the part of an email address starting with the @-sign.</span>
|
</div>
|
||||||
You may use international (non-ASCII) characters for the domain part of the email address only.
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label for="addaliasForwardsTo" class="col-sm-1 control-label">Forwards To</label>
|
||||||
<div class="col-lg-9 mb-3">
|
<div class="col-sm-10">
|
||||||
<div class="form-floating">
|
<textarea class="form-control" rows="3" id="addaliasForwardsTo"></textarea>
|
||||||
<textarea class="form-control" style="min-height: 8em;" id="addaliasForwardsTo"></textarea>
|
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
|
||||||
<label for="addaliasForwardsTo">Forwards To</label>
|
<span class="domainalias text-muted">Enter just the part of an email address starting with the @-sign.</span>
|
||||||
</div>
|
<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 style="margin-top: 3px; padding-left: 3px; font-size: 90%">
|
</div>
|
||||||
<span class="domainalias text-muted">Enter just the part of an email address starting with the
|
</div>
|
||||||
@-sign.</span>
|
</div>
|
||||||
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded
|
<div class="form-group">
|
||||||
by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other
|
<label for="addaliasSenders" class="col-sm-1 control-label">Permitted Senders</label>
|
||||||
domains, create a mail user and then log into webmail for the user and create a filter rule to forward
|
<div class="col-sm-10">
|
||||||
mail.</span>
|
<div class="radio">
|
||||||
</div>
|
<label>
|
||||||
</div>
|
<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>.
|
||||||
<h4>Permitted Senders</h4>
|
</label>
|
||||||
|
</div>
|
||||||
<div class="col-lg-9 mb-3">
|
<div class="radio">
|
||||||
<div class="mb-3">
|
<label>
|
||||||
<div class="mb-3">
|
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
||||||
<div class="radio">
|
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>
|
</label>
|
||||||
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
</div>
|
||||||
checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
<div id="addaliasForwardsToDiv" style="margin-top: .5em; margin-left: 1.4em; display: none;">
|
||||||
Any mail user listed in the Forwards To box can send mail claiming to be from <span
|
<textarea class="form-control" rows="3" id="addaliasSenders" placeholder="one user per line or separated by commas"></textarea>
|
||||||
class="regularalias">the alias address</span><span class="catchall domainalias">any address
|
</div>
|
||||||
on the alias domain</span>.
|
</div>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
<div class="radio">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
<label>
|
<button id="add-alias-button" type="submit" class="btn btn-primary">Add Alias</button>
|
||||||
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
<button id="alias-cancel" class="btn btn-default hidden" onclick="aliases_reset_form(); return false;">Cancel</button>
|
||||||
id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
</div>
|
||||||
I’ll enter the mail users that can send mail claiming to be from <span
|
</div>
|
||||||
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>
|
</form>
|
||||||
|
|
||||||
<h3>Existing mail aliases</h3>
|
<h3>Existing mail aliases</h3>
|
||||||
<table id="alias_table" class="table">
|
<table id="alias_table" class="table" style="width: auto">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th></th>
|
||||||
<th></th>
|
<th>Alias<br></th>
|
||||||
<th>Alias<br></th>
|
<th>Forwards To</th>
|
||||||
<th>Forwards To</th>
|
<th>Permitted Senders</th>
|
||||||
<th>Permitted Senders</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
</tbody>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some
|
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some domains.</small></p>
|
||||||
domains.</small></p>
|
|
||||||
|
|
||||||
<div style="display: none">
|
<div style="display: none">
|
||||||
<table>
|
<table>
|
||||||
<tr id="alias-template">
|
<tr id="alias-template">
|
||||||
<td class='actions row justify-content-evenly' style="min-width: 4em; border: none;">
|
<td class='actions'>
|
||||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;"
|
<a href="#" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias">
|
||||||
class='edit' title="Edit Alias">
|
<span class="fas fa-pen"></span>
|
||||||
<span class="text-center text-primary fas fa-pen"></span>
|
</a>
|
||||||
</button>
|
<a href="#" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
||||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_remove(this); return false;"
|
<span class="fas fa-trash"></span>
|
||||||
class='remove' title="Remove Alias">
|
</a>
|
||||||
<span class="text-center text-danger fas fa-trash"></span>
|
</td>
|
||||||
</button>
|
<td class='address'> </td>
|
||||||
</td>
|
<td class='forwardsTo'> </td>
|
||||||
<td class='address'> </td>
|
<td class='senders'> </td>
|
||||||
<td class='forwardsTo'> </td>
|
</tr>
|
||||||
<td class='senders'> </td>
|
</table>
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Mail aliases API (advanced)</h3>
|
<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
|
<p>Use your box’s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p>
|
||||||
build.</p>
|
|
||||||
|
|
||||||
<p>Usage:</p>
|
<p>Usage:</p>
|
||||||
|
|
||||||
|
@ -156,35 +122,15 @@
|
||||||
<h4 style="margin-bottom: 0">Verbs</h4>
|
<h4 style="margin-bottom: 0">Verbs</h4>
|
||||||
|
|
||||||
<table class="table" style="margin-top: .5em">
|
<table class="table" style="margin-top: .5em">
|
||||||
<caption></caption>
|
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||||
<thead>
|
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||||
<th>Verb</th>
|
<tr><td>POST</td><td>/add</td> <td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.</td></tr>
|
||||||
<th>Action</th>
|
<tr><td>POST</td><td>/remove</td> <td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td></tr>
|
||||||
<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>
|
</table>
|
||||||
|
|
||||||
<h4>Examples:</h4>
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
<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>
|
||||||
line argument which you must fill in with your email address and password.</p>
|
|
||||||
|
|
||||||
<pre># Gives a JSON-encoded list of all mail aliases
|
<pre># Gives a JSON-encoded list of all mail aliases
|
||||||
curl -X GET https://{{hostname}}/admin/mail/aliases?format=json
|
curl -X GET https://{{hostname}}/admin/mail/aliases?format=json
|
||||||
|
@ -198,157 +144,159 @@ curl -X POST -d "address=new_alias@mydomail.com" https://{{hostname}}/admin/mail
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_aliases() {
|
function show_aliases() {
|
||||||
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#alias_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/mail/aliases",
|
"/mail/aliases",
|
||||||
"GET",
|
"GET",
|
||||||
{ format: 'json' },
|
{ format: 'json' },
|
||||||
function (r) {
|
function(r) {
|
||||||
$('#alias_table tbody').html("");
|
$('#alias_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var hdr = $("<tr><th colspan='4' class='bg-light'></th></tr>");
|
var hdr = $("<tr><th colspan='4' style='background-color: #EEE'></th></tr>");
|
||||||
hdr.find('th').text(r[i].domain);
|
hdr.find('th').text(r[i].domain);
|
||||||
$('#alias_table tbody').append(hdr);
|
$('#alias_table tbody').append(hdr);
|
||||||
|
|
||||||
for (var k = 0; k < r[i].aliases.length; k++) {
|
for (var k = 0; k < r[i].aliases.length; k++) {
|
||||||
var alias = r[i].aliases[k];
|
var alias = r[i].aliases[k];
|
||||||
|
|
||||||
var n = $("#alias-template").clone();
|
var n = $("#alias-template").clone();
|
||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
|
|
||||||
if (alias.auto) n.addClass('alias-auto');
|
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.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)
|
n.find('td.address').text(alias.address_display)
|
||||||
for (var j = 0; j < alias.forwards_to.length; j++)
|
for (var j = 0; j < alias.forwards_to.length; j++)
|
||||||
n.find('td.forwardsTo').append($("<div></div>").text(alias.forwards_to[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++)
|
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]))
|
n.find('td.senders').append($("<div></div>").text(alias.permitted_senders[j]))
|
||||||
$('#alias_table tbody').append(n);
|
$('#alias_table tbody').append(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$('#alias_type_buttons button').off('click').click(function () {
|
$(function() {
|
||||||
$('#alias_type_buttons button').removeClass('active');
|
$('#alias_type_buttons button').off('click').click(function() {
|
||||||
$(this).addClass('active');
|
$('#alias_type_buttons button').removeClass('active');
|
||||||
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
|
$(this).addClass('active');
|
||||||
if ($(this).attr('data-mode') == "regular") {
|
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
|
||||||
$('#addaliasAddress').attr('type', 'email');
|
if ($(this).attr('data-mode') == "regular") {
|
||||||
$('#addaliasAddress').attr('placeholder', 'you@example.com (incoming email address)');
|
$('#addaliasAddress').attr('type', 'email');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
$('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)');
|
||||||
$('#alias_mode_info').slideUp();
|
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||||
$('#addalias-form .regularalias').removeClass('hidden');
|
$('#alias_mode_info').slideUp();
|
||||||
} else if ($(this).attr('data-mode') == "catchall") {
|
$('#addalias-form .regularalias').removeClass('hidden');
|
||||||
$('#addaliasAddress').attr('type', 'text');
|
} else if ($(this).attr('data-mode') == "catchall") {
|
||||||
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas');
|
||||||
$('#addalias-form .catchall').removeClass('hidden');
|
$('#alias_mode_info').slideDown();
|
||||||
} else if ($(this).attr('data-mode') == "domainalias") {
|
$('#addalias-form .catchall').removeClass('hidden');
|
||||||
$('#addaliasAddress').attr('type', 'text');
|
} else if ($(this).attr('data-mode') == "domainalias") {
|
||||||
$('#addaliasAddress').attr('placeholder', '@example.com (incoming catch-all domain)');
|
$('#addaliasAddress').attr('type', 'text');
|
||||||
$('#addaliasForwardsTo').attr('placeholder', '@example.net (forward to other domain)');
|
$('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)');
|
||||||
$('#alias_mode_info').slideDown();
|
$('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)');
|
||||||
$('#addalias-form .domainalias').removeClass('hidden');
|
$('#alias_mode_info').slideDown();
|
||||||
}
|
$('#addalias-form .domainalias').removeClass('hidden');
|
||||||
})
|
}
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
})
|
||||||
}
|
$('#alias_type_buttons button[data-mode="regular"]').click(); // init
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var is_alias_add_update = false;
|
var is_alias_add_update = false;
|
||||||
function do_add_alias() {
|
function do_add_alias() {
|
||||||
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
|
||||||
var form_address = $("#addaliasAddress").val();
|
var form_address = $("#addaliasAddress").val();
|
||||||
var form_forwardsto = $("#addaliasForwardsTo").val();
|
var form_forwardsto = $("#addaliasForwardsTo").val();
|
||||||
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
|
var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : '');
|
||||||
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
|
if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) {
|
||||||
show_modal_error(title, "You did not enter any permitted senders.");
|
show_modal_error(title, "You did not enter any permitted senders.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/add",
|
"/mail/aliases/add",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
update_if_exists: is_alias_add_update ? '1' : '0',
|
update_if_exists: is_alias_add_update ? '1' : '0',
|
||||||
address: form_address,
|
address: form_address,
|
||||||
forwards_to: form_forwardsto,
|
forwards_to: form_forwardsto,
|
||||||
permitted_senders: form_senders
|
permitted_senders: form_senders
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error(title, $("<pre/>").text(r));
|
show_modal_error(title, $("<pre/>").text(r));
|
||||||
show_aliases()
|
show_aliases()
|
||||||
aliases_reset_form();
|
aliases_reset_form();
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error(title, r);
|
show_modal_error(title, r);
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_reset_form() {
|
function aliases_reset_form() {
|
||||||
$("#addaliasAddress").prop('disabled', false);
|
$("#addaliasAddress").prop('disabled', false);
|
||||||
$("#addaliasAddress").val('')
|
$("#addaliasAddress").val('')
|
||||||
$("#addaliasForwardsTo").val('')
|
$("#addaliasForwardsTo").val('')
|
||||||
$("#addaliasSenders").val('')
|
$("#addaliasSenders").val('')
|
||||||
$('#alias-cancel').addClass('hidden');
|
$('#alias-cancel').addClass('hidden');
|
||||||
$('#add-alias-button').text('Add Alias');
|
$('#add-alias-button').text('Add Alias');
|
||||||
is_alias_add_update = false;
|
is_alias_add_update = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_edit(elem) {
|
function aliases_edit(elem) {
|
||||||
var address = $(elem).parents('tr').attr('data-address');
|
var address = $(elem).parents('tr').attr('data-address');
|
||||||
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
var receiverdivs = $(elem).parents('tr').find('.forwardsTo div');
|
||||||
var senderdivs = $(elem).parents('tr').find('.senders div');
|
var senderdivs = $(elem).parents('tr').find('.senders div');
|
||||||
var forwardsTo = "";
|
var forwardsTo = "";
|
||||||
for (var i = 0; i < receiverdivs.length; i++)
|
for (var i = 0; i < receiverdivs.length; i++)
|
||||||
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
forwardsTo += $(receiverdivs[i]).text() + "\n";
|
||||||
var senders = "";
|
var senders = "";
|
||||||
for (var i = 0; i < senderdivs.length; i++)
|
for (var i = 0; i < senderdivs.length; i++)
|
||||||
senders += $(senderdivs[i]).text() + "\n";
|
senders += $(senderdivs[i]).text() + "\n";
|
||||||
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@')
|
||||||
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
$('#alias_type_buttons button[data-mode="domainalias"]').click();
|
||||||
else if (address.charAt(0) == '@')
|
else if (address.charAt(0) == '@')
|
||||||
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
$('#alias_type_buttons button[data-mode="catchall"]').click();
|
||||||
else
|
else
|
||||||
$('#alias_type_buttons button[data-mode="regular"]').click();
|
$('#alias_type_buttons button[data-mode="regular"]').click();
|
||||||
$('#alias-cancel').removeClass('hidden');
|
$('#alias-cancel').removeClass('hidden');
|
||||||
$("#addaliasAddress").prop('disabled', true);
|
$("#addaliasAddress").prop('disabled', true);
|
||||||
$("#addaliasAddress").val(address);
|
$("#addaliasAddress").val(address);
|
||||||
$("#addaliasForwardsTo").val(forwardsTo);
|
$("#addaliasForwardsTo").val(forwardsTo);
|
||||||
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
|
$('#addaliasForwardsToAdvanced').prop('checked', senders != "");
|
||||||
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
|
$('#addaliasForwardsToNotAdvanced').prop('checked', senders == "");
|
||||||
$("#addaliasSenders").val(senders);
|
$("#addaliasSenders").val(senders);
|
||||||
$('#add-alias-button').text('Update');
|
$('#add-alias-button').text('Update');
|
||||||
$('body').animate({ scrollTop: 0 })
|
$('body').animate({ scrollTop: 0 })
|
||||||
is_alias_add_update = true;
|
is_alias_add_update = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aliases_remove(elem) {
|
function aliases_remove(elem) {
|
||||||
var row_address = $(elem).parents('tr').attr('data-address');
|
var row_address = $(elem).parents('tr').attr('data-address');
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Remove Alias",
|
"Remove Alias",
|
||||||
"Remove " + row_address + "?",
|
"Remove " + row_address + "?",
|
||||||
"Remove",
|
"Remove",
|
||||||
function () {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/aliases/remove",
|
"/mail/aliases/remove",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
address: row_address
|
address: row_address
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error("Remove Alias", $("<pre/>").text(r));
|
show_modal_error("Remove Alias", $("<pre/>").text(r));
|
||||||
show_aliases();
|
show_aliases();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function scroll_top() {
|
function scroll_top() {
|
||||||
$('html, body').animate({
|
$('html, body').animate({
|
||||||
scrollTop: $("#panel_aliases").offset().top
|
scrollTop: $("#panel_aliases").offset().top
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<style>
|
<style>
|
||||||
#custom-dns-current td.long {
|
#custom-dns-current td.long {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Custom DNS</h2>
|
<h2>Custom DNS</h2>
|
||||||
|
@ -12,123 +12,114 @@
|
||||||
|
|
||||||
<h3>Set custom DNS records</h3>
|
<h3>Set custom DNS records</h3>
|
||||||
|
|
||||||
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for
|
<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>
|
||||||
external mail providers, or for various confirmation-of-ownership tests.</p>
|
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_set_custom_dns(); return false;">
|
<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="form-group">
|
||||||
<div class="input-group">
|
<label for="customdnsQname" class="col-sm-1 control-label">Name</label>
|
||||||
<label class="input-group-text">Name</label>
|
<div class="col-sm-10">
|
||||||
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
|
<table style="max-width: 400px">
|
||||||
<label class="input-group-text">.</label>
|
<tr><td>
|
||||||
<select id="customdnsZone" class="form-select"></select>
|
<input type="text" class="form-control" id="customdnsQname" placeholder="subdomain">
|
||||||
</div>
|
</td><td style="padding: 0 1em; font-weight: bold;">.</td><td>
|
||||||
<small>Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</small>
|
<select id="customdnsZone" class="form-control"> </select>
|
||||||
</div>
|
</td></tr></table>
|
||||||
<div class="col-lg-10 col-xl-7 mb-3">
|
<div class="text-info" style="margin-top: .5em">Leave the left field blank to set a record on the chosen domain name, or enter a subdomain.</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="customdnsType" class="input-group-text">Type</label>
|
</div>
|
||||||
<select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()">
|
<div class="form-group">
|
||||||
<option value="A"
|
<label for="customdnsType" class="col-sm-1 control-label">Type</label>
|
||||||
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.">
|
<div class="col-sm-10">
|
||||||
A (IPv4 address)</option>
|
<select id="customdnsType" class="form-control" style="max-width: 400px" onchange="show_customdns_rtype_hint()">
|
||||||
<option value="AAAA"
|
<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>
|
||||||
data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">
|
<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>
|
||||||
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="CAA"
|
<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>
|
||||||
data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild "letsencrypt.org")">
|
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||||
CAA (Certificate Authority Authorization)</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="CNAME"
|
<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>
|
||||||
data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">
|
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH fingerprint record)</option>
|
||||||
CNAME (DNS forwarding)</option>
|
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS subdomain delegation)</option>
|
||||||
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
</select>
|
||||||
<option value="MX"
|
</div>
|
||||||
data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">
|
</div>
|
||||||
MX (mail exchanger)</option>
|
<div class="form-group">
|
||||||
<option value="SRV"
|
<div class="col-sm-10">
|
||||||
data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">
|
<table>
|
||||||
SRV (service record)</option>
|
<tr style="width: 100%;">
|
||||||
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH
|
<td style="width: 75%;">
|
||||||
fingerprint record)</option>
|
<label for="customdnsValue" class="control-label">Value</label>
|
||||||
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS
|
<input type="text" class="form-control" id="customdnsValue" placeholder="">
|
||||||
subdomain delegation)</option>
|
</td>
|
||||||
</select>
|
<td style="width: 25%;">
|
||||||
</div>
|
<label for="customdnsTtl" class="col-sm-1 control-label">TTL</label>
|
||||||
</div>
|
<input type="number" class="form-control" style="margin-left: 5pt;" id="customdnsTtl" placeholder="default">
|
||||||
<div class="col-lg-10 col-xl-7 mb-3">
|
</td>
|
||||||
<div class="input-group">
|
</tr>
|
||||||
<label for="customdnsValue" class="input-group-text">Value</label>
|
</table>
|
||||||
<input type="text" class="form-control" id="customdnsValue" placeholder="">
|
<div id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></div>
|
||||||
<label for="customdnsTtl" class="input-group-text">TTL</label>
|
</div>
|
||||||
<input type="number" class="form-control" id="customdnsTtl" placeholder="default">
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
<small id="customdnsTypeHint" class="text-info" style="margin-top: .5em"></small>
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
</div>
|
<button type="submit" class="btn btn-primary">Set Record</button>
|
||||||
<button type="submit" class="btn btn-primary mb-3">Set Record</button>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="font-size: 90%;">
|
<div style="text-align: right; font-size: 90%; margin-top: 1em;">
|
||||||
<b>Sort by:</b>
|
sort by:
|
||||||
<a href="#"
|
<a href="#" onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">domain name</a>
|
||||||
onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain
|
|
|
||||||
name</a>
|
<a href="#" onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">created</a>
|
||||||
<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>
|
</div>
|
||||||
<table id="custom-dns-current" class="table col-12" style="display: none; margin-top: 0;">
|
<table id="custom-dns-current" class="table" style="width: auto; display: none; margin-top: 0;">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<th>Domain Name</th>
|
||||||
<th>Domain Name</th>
|
<th>Record Type</th>
|
||||||
<th>Record Type</th>
|
<th>Value</th>
|
||||||
<th>Value</th>
|
<th>TTL</th>
|
||||||
<th>TTL</th>
|
<th></th>
|
||||||
<th></th>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<tr><td colspan="5">Loading...</td></tr>
|
||||||
<tr>
|
</tbody>
|
||||||
<td colspan="5">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Using a secondary nameserver</h3>
|
<h3>Using a secondary nameserver</h3>
|
||||||
|
|
||||||
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#"
|
<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>
|
||||||
onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or
|
<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>
|
||||||
use the DNS server on this box but add a secondary (aka “slave”) nameserver.</p>
|
|
||||||
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name
|
|
||||||
registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver
|
|
||||||
service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
|
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||||
<div class="col-12 form-floating mb-3">
|
<div class="form-group">
|
||||||
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname"
|
<label for="secondarydnsHostname" class="col-sm-1 control-label">Hostname</label>
|
||||||
placeholder="ns1.example.com"></textarea>
|
<div class="col-sm-10">
|
||||||
<label for="secondarydnsHostname">Secondary Nameservers</label>
|
<input type="text" class="form-control" id="secondarydnsHostname" placeholder="ns1.cloudprovider.com">
|
||||||
<div>
|
</div>
|
||||||
<p class="small">
|
</div>
|
||||||
Multiple secondary servers can be separated with commas or spaces (i.e., <span
|
<div class="form-group">
|
||||||
class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span
|
</div>
|
||||||
class="font-monospace">xfr:10.0.0.0/8</span>.
|
</div>
|
||||||
</p>
|
<div class="form-group">
|
||||||
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
<div class="col-sm-offset-1 col-sm-11">
|
||||||
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the
|
<p class="small">
|
||||||
default/normal setup.
|
Multiple secondary servers can be separated with commas or spaces (i.e., <code>ns2.hostingcompany.com ns3.hostingcompany.com</code>).
|
||||||
</p>
|
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <code>xfr:10.20.30.40</code> or <code>xfr:10.0.0.0/8</code>.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
<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.
|
||||||
<button type="submit" class="btn btn-primary">Update</button>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
<h3>Custom DNS API</h3>
|
<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
|
<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>
|
||||||
dynamic DNS service.</p>
|
|
||||||
|
|
||||||
<p>Usage:</p>
|
<p>Usage:</p>
|
||||||
|
|
||||||
|
@ -139,83 +130,29 @@
|
||||||
<h4>Verbs</h4>
|
<h4>Verbs</h4>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<caption></caption>
|
<thead><th>Verb</th> <th>Usage</th></thead>
|
||||||
<thead>
|
<tr><td>GET</td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr>
|
||||||
<th>Verb</th>
|
<tr><td>PUT</td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>
|
||||||
<th>Usage</th>
|
<tr><td>POST</td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>
|
||||||
</thead>
|
<tr><td>DELETE</td> <td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>
|
||||||
<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>
|
</table>
|
||||||
|
|
||||||
<h4>Parameters</h4>
|
<h4>Parameters</h4>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<caption></caption>
|
<thead><th>Parameter</th> <th>Value</th></thead>
|
||||||
<thead>
|
<tr><td>email</td> <td>The email address of any administrative user here.</td></tr>
|
||||||
<th>Parameter</th>
|
<tr><td>password</td> <td>That user’s password.</td></tr>
|
||||||
<th>Value</th>
|
<tr><td>qname</td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
|
||||||
</thead>
|
<tr><td>rtype</td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name — don’t forget the final period), <code>MX</code>, <code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td></tr>
|
||||||
<tr>
|
<tr><td>value</td> <td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
|
||||||
<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>
|
</table>
|
||||||
|
|
||||||
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a
|
<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>
|
||||||
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>
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
<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>
|
||||||
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
|
<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
|
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||||
|
@ -237,138 +174,138 @@ curl -X DELETE -d "some text here" https://{{hostname}}/admin/dns/custom/foo.myd
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_custom_dns() {
|
function show_custom_dns() {
|
||||||
api(
|
api(
|
||||||
"/dns/secondary-nameserver",
|
"/dns/secondary-nameserver",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (data) {
|
function(data) {
|
||||||
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
$('#secondarydnsHostname').val(data.hostnames.join(' '));
|
||||||
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
$('#secondarydns-clear-instructions').toggle(data.hostnames.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/dns/zones",
|
"/dns/zones",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (data) {
|
function(data) {
|
||||||
$('#customdnsZone').text('');
|
$('#customdnsZone').text('');
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (var i = 0; i < data.length; i++) {
|
||||||
$('#customdnsZone').append($('<option/>').text(data[i]));
|
$('#customdnsZone').append($('<option/>').text(data[i]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
show_current_custom_dns();
|
show_current_custom_dns();
|
||||||
show_customdns_rtype_hint();
|
show_customdns_rtype_hint();
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_current_custom_dns() {
|
function show_current_custom_dns() {
|
||||||
api(
|
api(
|
||||||
"/dns/custom",
|
"/dns/custom",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (data) {
|
function(data) {
|
||||||
if (data.length > 0)
|
if (data.length > 0)
|
||||||
$('#custom-dns-current').fadeIn();
|
$('#custom-dns-current').fadeIn();
|
||||||
else
|
else
|
||||||
$('#custom-dns-current').fadeOut();
|
$('#custom-dns-current').fadeOut();
|
||||||
window.miab_custom_dns_data = data;
|
window.miab_custom_dns_data = data;
|
||||||
show_current_custom_dns_update_after_sort();
|
show_current_custom_dns_update_after_sort();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_current_custom_dns_update_after_sort() {
|
function show_current_custom_dns_update_after_sort() {
|
||||||
var data = window.miab_custom_dns_data;
|
var data = window.miab_custom_dns_data;
|
||||||
var sort_key = window.miab_custom_dns_data_sort_order || "qname";
|
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] });
|
data.sort(function(a, b) { return a["sort-order"][sort_key] - b["sort-order"][sort_key] });
|
||||||
|
|
||||||
var tbody = $('#custom-dns-current').find("tbody");
|
var tbody = $('#custom-dns-current').find("tbody");
|
||||||
tbody.text('');
|
tbody.text('');
|
||||||
var last_zone = null;
|
var last_zone = null;
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (var i = 0; i < data.length; i++) {
|
||||||
if (sort_key == "qname" && data[i].zone != last_zone) {
|
if (sort_key == "qname" && data[i].zone != last_zone) {
|
||||||
var r = $("<tr><th colspan=5 class='bg-light'></th></tr>");
|
var r = $("<tr><th colspan=5 style='background-color: #EEE'></th></tr>");
|
||||||
r.find("th").text(data[i].zone);
|
r.find("th").text(data[i].zone);
|
||||||
tbody.append(r);
|
tbody.append(r);
|
||||||
last_zone = data[i].zone;
|
last_zone = data[i].zone;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tr = $("<tr/>");
|
var tr = $("<tr/>");
|
||||||
tbody.append(tr);
|
tbody.append(tr);
|
||||||
tr.attr('data-qname', data[i].qname);
|
tr.attr('data-qname', data[i].qname);
|
||||||
tr.attr('data-rtype', data[i].rtype);
|
tr.attr('data-rtype', data[i].rtype);
|
||||||
tr.attr('data-value', data[i].value);
|
tr.attr('data-value', data[i].value);
|
||||||
tr.append($('<td class="long"/>').text(data[i].qname));
|
tr.append($('<td class="long"/>').text(data[i].qname));
|
||||||
tr.append($('<td/>').text(data[i].rtype));
|
tr.append($('<td/>').text(data[i].rtype));
|
||||||
tr.append($('<td class="long font-monospace" style="max-width: 40em"/>').text(data[i].value));
|
tr.append($('<td class="long" style="max-width: 40em"/>').text(data[i].value));
|
||||||
if (data[i].ttl) {
|
if (data[i].ttl) {
|
||||||
tr.append($('<td/>').text(data[i].ttl));
|
tr.append($('<td/>').text(data[i].ttl));
|
||||||
} else {
|
} else {
|
||||||
tr.append($('<td/>').html('<i class="">default</i>'));
|
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>'));
|
tr.append($('<td>[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function delete_custom_dns_record(elem) {
|
function delete_custom_dns_record(elem) {
|
||||||
var qname = $(elem).parents('tr').attr('data-qname');
|
var qname = $(elem).parents('tr').attr('data-qname');
|
||||||
var rtype = $(elem).parents('tr').attr('data-rtype');
|
var rtype = $(elem).parents('tr').attr('data-rtype');
|
||||||
var value = $(elem).parents('tr').attr('data-value');
|
var value = $(elem).parents('tr').attr('data-value');
|
||||||
do_set_custom_dns(qname, rtype, value, "DELETE");
|
do_set_custom_dns(qname, rtype, value, "DELETE");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_set_secondary_dns() {
|
function do_set_secondary_dns() {
|
||||||
api(
|
api(
|
||||||
"/dns/secondary-nameserver",
|
"/dns/secondary-nameserver",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
hostnames: $('#secondarydnsHostname').val()
|
hostnames: $('#secondarydnsHostname').val()
|
||||||
},
|
},
|
||||||
function (data) {
|
function(data) {
|
||||||
if (data == "") return; // nothing updated
|
if (data == "") return; // nothing updated
|
||||||
show_modal_error("Secondary DNS", $("<pre/>").text(data));
|
show_modal_error("Secondary DNS", $("<pre/>").text(data));
|
||||||
$('#secondarydns-clear-instructions').slideDown();
|
$('#secondarydns-clear-instructions').slideDown();
|
||||||
},
|
},
|
||||||
function (err) {
|
function(err) {
|
||||||
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
show_modal_error("Secondary DNS", $("<pre/>").text(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_set_custom_dns(qname, rtype, value, method) {
|
function do_set_custom_dns(qname, rtype, value, method) {
|
||||||
if (!qname) {
|
if (!qname) {
|
||||||
if ($('#customdnsQname').val() != '')
|
if ($('#customdnsQname').val() != '')
|
||||||
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
|
qname = $('#customdnsQname').val() + '.' + $('#customdnsZone').val();
|
||||||
else
|
else
|
||||||
qname = $('#customdnsZone').val();
|
qname = $('#customdnsZone').val();
|
||||||
rtype = $('#customdnsType').val();
|
rtype = $('#customdnsType').val();
|
||||||
value = {
|
value = {
|
||||||
value: $('#customdnsValue').val(),
|
value: $('#customdnsValue').val(),
|
||||||
ttl: $('#customdnsTtl').val()
|
ttl: $('#customdnsTtl').val()
|
||||||
};
|
};
|
||||||
method = 'POST';
|
method = 'POST';
|
||||||
} else {
|
} else {
|
||||||
value = {
|
value = {
|
||||||
value: value
|
value: value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/dns/custom/" + qname + "/" + rtype,
|
"/dns/custom/" + qname + "/" + rtype,
|
||||||
method,
|
method,
|
||||||
value,
|
value,
|
||||||
function (data) {
|
function(data) {
|
||||||
if (data == "") return; // nothing updated
|
if (data == "") return; // nothing updated
|
||||||
show_modal_error("Custom DNS", $("<pre/>").text(data));
|
show_modal_error("Custom DNS", $("<pre/>").text(data));
|
||||||
show_current_custom_dns();
|
show_current_custom_dns();
|
||||||
},
|
},
|
||||||
function (err) {
|
function(err) {
|
||||||
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
|
show_modal_error("Custom DNS (Error)", $("<pre/>").text(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_customdns_rtype_hint() {
|
function show_customdns_rtype_hint() {
|
||||||
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
|
$('#customdnsTypeHint').text($("#customdnsType").find('option:selected').attr('data-hint'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,66 +1,62 @@
|
||||||
<style>
|
<style>
|
||||||
#external_dns_settings .domain-header {
|
#external_dns_settings .heading td {
|
||||||
margin-top: 0.5em;
|
font-weight: bold;
|
||||||
margin-bottom: 0.5em;
|
font-size: 120%;
|
||||||
}
|
padding-top: 1.5em;
|
||||||
|
}
|
||||||
#external_dns_settings .values td {
|
#external_dns_settings .heading.first td {
|
||||||
border: 0;
|
border-top: none;
|
||||||
padding-top: .75em;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
}
|
||||||
}
|
#external_dns_settings .values td {
|
||||||
|
border: 0;
|
||||||
#external_dns_settings .value {
|
padding-top: .75em;
|
||||||
word-break: break-all;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
#external_dns_settings .value {
|
||||||
#external_dns_settings .explanation td {
|
word-break: break-all;
|
||||||
padding-top: .5em;
|
}
|
||||||
padding-bottom: .75em;
|
#external_dns_settings .explanation td {
|
||||||
font-style: italic;
|
border: 0;
|
||||||
color: #777;
|
padding-top: .5em;
|
||||||
}
|
padding-bottom: .75em;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 95%;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>External DNS</h2>
|
<h2>External DNS</h2>
|
||||||
|
|
||||||
<p class="text-warning">This is an advanced configuration page.</p>
|
<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
|
<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>
|
||||||
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
|
<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>
|
||||||
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">
|
<p class="alert" role="alert">
|
||||||
<span class="fas fa-info-circle"></span>
|
<span class="fas fa-info-circle"></span>
|
||||||
You may encounter zone file errors when attempting to create a TXT record with a long string.
|
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
|
<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.
|
||||||
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.
|
||||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to
|
|
||||||
validate your zone file.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Download zonefile</h3>
|
<h3>Download zonefile</h3>
|
||||||
<p>You can download your zonefiles here or use the table of records below.</p>
|
<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;">
|
<form class="form-inline" role="form" onsubmit="do_download_zonefile(); return false;">
|
||||||
<div class="col-md-10 col-xl-6 mb-3">
|
<div class="form-group">
|
||||||
<div class="input-group">
|
<div class="form-group">
|
||||||
<label for="downloadZonefile" class="input-group-text">Zone</label>
|
<label for="downloadZonefile" class="control-label sr-only">Zone</label>
|
||||||
<select id="downloadZonefile" class="form-select"></select>
|
<select id="downloadZonefile" class="form-control" style="width: auto"> </select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" style="margin-left: 20px" class="btn btn-primary">Download</button>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Download</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h3>Records</h3>
|
<h3>Records</h3>
|
||||||
|
|
||||||
<table id="external_dns_settings" class="table">
|
<table id="external_dns_settings" class="table">
|
||||||
<caption></caption>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>QName</th>
|
<th>QName</th>
|
||||||
|
@ -73,59 +69,59 @@
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_external_dns() {
|
function show_external_dns() {
|
||||||
api(
|
api(
|
||||||
"/dns/zones",
|
"/dns/zones",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (data) {
|
function(data) {
|
||||||
var zones = $('#downloadZonefile');
|
var zones = $('#downloadZonefile');
|
||||||
zones.text('');
|
zones.text('');
|
||||||
for (var j = 0; j < data.length; j++) {
|
for (var j = 0; j < data.length; j++) {
|
||||||
zones.append($('<option/>').text(data[j]));
|
zones.append($('<option/>').text(data[j]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
$('#external_dns_settings tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/dns/dump",
|
"/dns/dump",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (zones) {
|
function(zones) {
|
||||||
$('#external_dns_settings tbody').html("");
|
$('#external_dns_settings tbody').html("");
|
||||||
for (var j = 0; j < zones.length; j++) {
|
for (var j = 0; j < zones.length; j++) {
|
||||||
var h = $("<tr><td colspan='3' class='bg-light'><h4 class='domain-header'/></td></tr>");
|
var h = $("<tr class='heading'><td colspan='3'></td></tr>");
|
||||||
h.find("td h4").text(zones[j][0]);
|
h.find("td").text(zones[j][0]);
|
||||||
$('#external_dns_settings tbody').append(h);
|
$('#external_dns_settings tbody').append(h);
|
||||||
|
|
||||||
var r = zones[j][1];
|
var r = zones[j][1];
|
||||||
for (var i = 0; i < r.length; i++) {
|
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>");
|
var n = $("<tr class='values'><td class='qname'/><td class='rtype'/><td class='value'/></tr>");
|
||||||
n.find('.qname').text(r[i].qname);
|
n.find('.qname').text(r[i].qname);
|
||||||
n.find('.rtype').text(r[i].rtype);
|
n.find('.rtype').text(r[i].rtype);
|
||||||
n.find('.value').text(r[i].value);
|
n.find('.value').text(r[i].value);
|
||||||
$('#external_dns_settings tbody').append(n);
|
$('#external_dns_settings tbody').append(n);
|
||||||
|
|
||||||
var n = $("<tr class='explanation'><td colspan='3'/><small/></tr>");
|
var n = $("<tr class='explanation'><td colspan='3'/></tr>");
|
||||||
n.find('td small').text(r[i].explanation);
|
n.find('td').text(r[i].explanation);
|
||||||
$('#external_dns_settings tbody').append(n);
|
$('#external_dns_settings tbody').append(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_download_zonefile() {
|
function do_download_zonefile() {
|
||||||
var zone = $('#downloadZonefile').val();
|
var zone = $('#downloadZonefile').val();
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/dns/zonefile/" + zone,
|
"/dns/zonefile/"+ zone,
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{},
|
||||||
function (data) {
|
function(data) {
|
||||||
show_modal_error("Download Zonefile", $("<pre/>").text(data));
|
show_modal_error("Download Zonefile", $("<pre/>").text(data));
|
||||||
},
|
},
|
||||||
function (err) {
|
function(err) {
|
||||||
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
|
show_modal_error("Download Zonefile (Error)", $("<pre/>").text(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,201 +1,206 @@
|
||||||
<style>
|
<style>
|
||||||
.title {
|
.title {
|
||||||
margin: 0.5em;
|
margin: 1em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
margin: 1em;
|
margin: 2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 32em;
|
max-width: 32em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login #loginOtp {
|
.login #loginOtp {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loginForm.is-twofactor #loginOtp {
|
#loginForm.is-twofactor #loginOtp {
|
||||||
display: block
|
display: block
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h1 class="title">{{hostname}}</h1>
|
<h1 class="title">{{hostname}}</h1>
|
||||||
|
|
||||||
{% if no_users_exist or no_admins_exist %}
|
{% if no_users_exist or no_admins_exist %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-md-offset-2 col-md-8">
|
||||||
<hr>
|
{% if no_users_exist %}
|
||||||
{% if no_users_exist %}
|
<p class="text-danger">There are no users on this system! To make an administrative user,
|
||||||
<p class="text-danger">There are no users on this system! To make an administrative user,
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
<pre>cd mailinabox
|
||||||
<pre>cd mailinabox
|
|
||||||
sudo management/cli.py user add me@{{hostname}}
|
sudo management/cli.py user add me@{{hostname}}
|
||||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
<p class="text-danger">There are no administrative users on this system! To make an administrative user,
|
||||||
log into this machine using SSH (like when you first set it up) and run:</p>
|
log into this machine using SSH (like when you first set it up) and run:</p>
|
||||||
<pre>cd mailinabox
|
<pre>cd mailinabox
|
||||||
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||||
|
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||||
<div class="form-floating mb-3">
|
<div class="form-group">
|
||||||
<input type="email" class="form-control col-sm-12" id="loginEmail" placeholder="user@example.com">
|
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||||
<label for="loginEmail">Email</label>
|
<div class="col-sm-12">
|
||||||
</div>
|
<input name="email" type="email" class="form-control" id="loginEmail" placeholder="Email">
|
||||||
<div class="form-floating mb-3">
|
</div>
|
||||||
<input type="password" class="form-control col-sm-12" id="loginPassword" placeholder="password">
|
</div>
|
||||||
<label for="loginPassword">Password</label>
|
<div class="form-group">
|
||||||
</div>
|
<label for="inputPassword3" class="col-sm-3 control-label">Password</label>
|
||||||
<div class="form-floating mb-3" id="loginOtp">
|
<div class="col-sm-12">
|
||||||
<input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off">
|
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
|
||||||
<label for="loginOtpInput">TOTP Code</label>
|
</div>
|
||||||
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two
|
</div>
|
||||||
factor authentication app.</div>
|
<div class="form-group" id="loginOtp">
|
||||||
</div>
|
<label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
|
||||||
<div class="form-group mb-3">
|
<div class="col-sm-12">
|
||||||
<div class="ms-auto">
|
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code" autocomplete="off">
|
||||||
<input type="checkbox" class="form-check-input" id="loginRemember">
|
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
|
||||||
<label class="form-check-label" for="loginRemember">Remember Me</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
<div class="container-fluid">
|
<div class="checkbox">
|
||||||
<button type="submit" class="btn btn-primary col-12">Sign in</button>
|
<label>
|
||||||
</div>
|
<input name='remember' type="checkbox" id="loginRemember"> Remember me
|
||||||
</div>
|
</label>
|
||||||
</form>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-offset-3 col-sm-9">
|
||||||
|
<button type="submit" class="btn btn-default">Sign in</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function do_login() {
|
function do_login() {
|
||||||
if ($('#loginEmail').val() == "") {
|
if ($('#loginEmail').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email address.", function () {
|
show_modal_error("Login Failed", "Enter your email address.", function() {
|
||||||
$('#loginEmail').focus();
|
$('#loginEmail').focus();
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($('#loginPassword').val() == "") {
|
if ($('#loginPassword').val() == "") {
|
||||||
show_modal_error("Login Failed", "Enter your email password.", function () {
|
show_modal_error("Login Failed", "Enter your email password.", function() {
|
||||||
$('#loginPassword').focus();
|
$('#loginPassword').focus();
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange the email address & password for an API key.
|
// Exchange the email address & password for an API key.
|
||||||
api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
|
api_credentials = { username: $('#loginEmail').val(), session_key: $('#loginPassword').val() }
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/login",
|
"/login",
|
||||||
"POST",
|
"POST",
|
||||||
{},
|
{},
|
||||||
function (response) {
|
function(response) {
|
||||||
// This API call always succeeds. It returns a JSON object indicating
|
// This API call always succeeds. It returns a JSON object indicating
|
||||||
// whether the request was authenticated or not.
|
// whether the request was authenticated or not.
|
||||||
if (response.status != 'ok') {
|
if (response.status != 'ok') {
|
||||||
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
||||||
$('#loginForm').addClass('is-twofactor');
|
$('#loginForm').addClass('is-twofactor');
|
||||||
if (response.reason === "invalid-totp-token") {
|
if (response.reason === "invalid-totp-token") {
|
||||||
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
|
show_modal_error("Login Failed", "Incorrect two factor authentication token.");
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
$('#loginOtpInput').focus();
|
$('#loginOtpInput').focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$('#loginForm').removeClass('is-twofactor');
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Show why the login failed.
|
// Show why the login failed.
|
||||||
show_modal_error("Login Failed", response.reason)
|
show_modal_error("Login Failed", response.reason)
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Reset any saved credentials.
|
||||||
do_logout();
|
do_logout();
|
||||||
}
|
}
|
||||||
} else if (!("api_key" in response)) {
|
} else if (!("api_key" in response)) {
|
||||||
// Login succeeded but user might not be authorized!
|
// Login succeeded but user might not be authorized!
|
||||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Reset any saved credentials.
|
||||||
do_logout();
|
do_logout();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Login succeeded.
|
// Login succeeded.
|
||||||
|
|
||||||
// Save the new credentials.
|
// Save the new credentials.
|
||||||
api_credentials = {
|
api_credentials = { username: response.email,
|
||||||
username: response.email,
|
session_key: response.api_key,
|
||||||
session_key: response.api_key,
|
privileges: response.privileges };
|
||||||
privileges: response.privileges
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to wipe the username/password information.
|
// Try to wipe the username/password information.
|
||||||
$('#loginEmail').val('');
|
$('#loginEmail').val('');
|
||||||
$('#loginPassword').val('');
|
$('#loginPassword').val('');
|
||||||
$('#loginOtpInput').val('');
|
$('#loginOtpInput').val('');
|
||||||
$('#loginForm').removeClass('is-twofactor');
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
|
|
||||||
// Remember the credentials.
|
// Remember the credentials.
|
||||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||||
if ($('#loginRemember').val()) {
|
if ($('#loginRemember').val()) {
|
||||||
localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
localStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||||
sessionStorage.removeItem("miab-cp-credentials");
|
sessionStorage.removeItem("miab-cp-credentials");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("miab-cp-credentials");
|
localStorage.removeItem("miab-cp-credentials");
|
||||||
sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
sessionStorage.setItem("miab-cp-credentials", JSON.stringify(api_credentials));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle menus.
|
// Toggle menus.
|
||||||
show_hide_menus();
|
show_hide_menus();
|
||||||
|
|
||||||
// Open the next panel the user wants to go to. Do this after the XHR response
|
// Open the next panel the user wants to go to. Do this after the XHR response
|
||||||
// is over so that we don't start a new XHR request while this one is finishing,
|
// is over so that we don't start a new XHR request while this one is finishing,
|
||||||
// which confuses the loading indicator.
|
// which confuses the loading indicator.
|
||||||
setTimeout(function () { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300);
|
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'welcome' : switch_back_to_panel) }, 300);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
'x-auth-token': $('#loginOtpInput').val()
|
'x-auth-token': $('#loginOtpInput').val()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_login() {
|
function show_login() {
|
||||||
$('#loginForm').removeClass('is-twofactor');
|
$('#loginForm').removeClass('is-twofactor');
|
||||||
$('#loginOtpInput').val('');
|
$('#loginOtpInput').val('');
|
||||||
$('#loginEmail,#loginPassword').each(function () {
|
$('#loginEmail,#loginPassword').each(function() {
|
||||||
var input = $(this);
|
var input = $(this);
|
||||||
if (!$.trim(input.val())) {
|
if (!$.trim(input.val())) {
|
||||||
input.focus();
|
input.focus();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_hide_menus() {
|
function show_hide_menus() {
|
||||||
var is_logged_in = (api_credentials != null);
|
var is_logged_in = (api_credentials != null);
|
||||||
var privs = api_credentials ? api_credentials.privileges : [];
|
var privs = api_credentials ? api_credentials.privileges : [];
|
||||||
$('.if-logged-in').toggle(is_logged_in);
|
$('.if-logged-in').toggle(is_logged_in);
|
||||||
$('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);
|
$('.if-logged-in-admin, .if-logged-in-not-admin').toggle(false);
|
||||||
if (is_logged_in) {
|
if (is_logged_in) {
|
||||||
$('.if-logged-in-not-admin').toggle(true);
|
$('.if-logged-in-not-admin').toggle(true);
|
||||||
privs.forEach(function (priv) {
|
privs.forEach(function(priv) {
|
||||||
$('.if-logged-in-' + priv).toggle(true);
|
$('.if-logged-in-' + priv).toggle(true);
|
||||||
$('.if-logged-in-not-' + priv).toggle(false);
|
$('.if-logged-in-not-' + priv).toggle(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$('.if-not-logged-in').toggle(!is_logged_in);
|
$('.if-not-logged-in').toggle(!is_logged_in);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
<style>
|
<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style>
|
||||||
#panel_mail-guide table.table {
|
|
||||||
width: auto;
|
|
||||||
margin-left: .5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
||||||
|
@ -13,121 +8,66 @@
|
||||||
<h3>Webmail</h3>
|
<h3>Webmail</h3>
|
||||||
|
|
||||||
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
|
<p>Webmail lets you check your email from any web browser. Your webmail site is:</p>
|
||||||
<p style="margin-left: 2em"><strong><a
|
<p style="margin-left: 2em"><strong><a href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
|
||||||
href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
|
|
||||||
<p>Your username is your whole email address.</p>
|
<p>Your username is your whole email address.</p>
|
||||||
|
|
||||||
|
|
||||||
<h3>Mobile/desktop apps</h3>
|
<h3>Mobile/desktop apps</h3>
|
||||||
|
|
||||||
<h4>Automatic configuration</h4>
|
<h4>Automatic configuration</h4>
|
||||||
|
|
||||||
<p>iOS and OS X only: Open <a style="font-weight: bold"
|
<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>
|
||||||
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
|
<h4>Manual configuration</h4>
|
||||||
whole email address.</p>
|
|
||||||
|
|
||||||
<h4>Manual configuration</h4>
|
<p>Use the following settings when you set up your email on your phone, desktop, or other device:</p>
|
||||||
|
|
||||||
<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>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>
|
||||||
|
|
||||||
<table class="table">
|
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
||||||
<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"
|
<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>
|
||||||
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
|
<h4>Exchange/ActiveSync settings</h4>
|
||||||
SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same.
|
|
||||||
However, we recommend you use IMAP instead.</p>
|
|
||||||
|
|
||||||
<h4>Exchange/ActiveSync settings</h4>
|
<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>
|
||||||
|
|
||||||
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility
|
<table class="table">
|
||||||
list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an
|
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||||
Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as
|
<tr><th>Options</th> <td>Secure Connection</td></tr>
|
||||||
described above. If you encounter any problems, please use the manual settings above.</p>
|
</table>
|
||||||
|
|
||||||
<table class="table">
|
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
||||||
<tr>
|
|
||||||
<th>Server</th>
|
|
||||||
<td>{{hostname}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Options</th>
|
|
||||||
<td>Secure Connection</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this
|
|
||||||
method.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-sm-5">
|
<div class="col-sm-5">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h3>Other information about mail on your box</h3>
|
<h3>Other information about mail on your box</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<h4>Greylisting</h4>
|
<h4>Greylisting</h4>
|
||||||
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially
|
<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>
|
||||||
rejecting mail from people you haven’t received mail from before. Legitimate mail servers
|
|
||||||
will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this.
|
|
||||||
If you are waiting for an email from someone new, such as if you are registering on a new
|
|
||||||
website and are waiting for an email confirmation, please be aware there will be a minimum of 3
|
|
||||||
minutes delay, depending how soon the remote server attempts redelivery.</p>
|
|
||||||
|
|
||||||
<h4>+tag addresses</h4>
|
<h4>+tag addresses</h4>
|
||||||
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email
|
<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>
|
||||||
address is <code>you@yourdomain.com</code>, you’ll also automatically get mail sent to
|
|
||||||
<code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail
|
|
||||||
for your own filtering rules without having to create aliases in this control panel.</p>
|
|
||||||
|
|
||||||
<h4>Use only this box to send as you</h4>
|
<h4>Use only this box to send as you</h4>
|
||||||
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and
|
<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>
|
||||||
other fraudulent mail to claim to be you. Only this machine is authorized to send email on
|
</div>
|
||||||
behalf of your domain names. If you use any other service to send email as you, it will likely
|
</div>
|
||||||
get spam filtered by recipients.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,240 +1,242 @@
|
||||||
<style>
|
<style>
|
||||||
.twofactor #totp-setup,
|
.twofactor #totp-setup,
|
||||||
.twofactor #disable-2fa,
|
.twofactor #disable-2fa,
|
||||||
.twofactor #output-2fa {
|
.twofactor #output-2fa {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twofactor.loaded .loading-indicator {
|
.twofactor.loaded .loading-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twofactor.disabled #disable-2fa,
|
.twofactor.disabled #disable-2fa,
|
||||||
.twofactor.enabled #totp-setup {
|
.twofactor.enabled #totp-setup {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twofactor.disabled #totp-setup,
|
.twofactor.disabled #totp-setup,
|
||||||
.twofactor.enabled #disable-2fa {
|
.twofactor.enabled #disable-2fa {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twofactor #totp-setup-qr img {
|
.twofactor #totp-setup-qr img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 256px;
|
width: 256px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twofactor #output-2fa.visible {
|
.twofactor #output-2fa.visible {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Two-Factor Authentication</h2>
|
<h2>Two-Factor Authentication</h2>
|
||||||
|
|
||||||
<p>When two-factor authentication is enabled, you will be prompted to enter a six digit code from an
|
<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>
|
authenticator app (usually on your phone) when you log into this control panel.</p>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card">
|
||||||
<div class="card-header text-white bg-danger">
|
<div class="card-header text-white bg-danger">
|
||||||
Enabling two-factor authentication does not protect access to your email
|
Enabling two-factor authentication does not protect access to your email
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body bg-light">
|
<div class="card-body bg-light">
|
||||||
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most
|
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
|
||||||
websites allow you to
|
reset your password by checking your email, so anyone with access to your email can typically take over
|
||||||
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
|
||||||
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@,
|
||||||
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,
|
||||||
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>
|
||||||
and ensure every administrator account for this control panel does the same.</strong>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="twofactor">
|
<div class="twofactor">
|
||||||
<div class="loading-indicator">Loading...</div>
|
<div class="loading-indicator">Loading...</div>
|
||||||
|
|
||||||
<form id="totp-setup">
|
<form id="totp-setup">
|
||||||
<h3>Setup Instructions</h3>
|
<h3>Setup Instructions</h3>
|
||||||
|
|
||||||
<div class="row gx-5">
|
<div class="form-group">
|
||||||
<div class="col-12 col-lg-6">
|
<p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||||
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a
|
other two-factor authentication app</a> that supports TOTP.</p>
|
||||||
href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
</div>
|
||||||
other two-factor authentication app</a> that supports TOTP.</p>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into
|
<p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
|
||||||
the app:</p>
|
<div id="totp-setup-qr"></div>
|
||||||
<div id="totp-setup-qr">
|
</div>
|
||||||
<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="form-group">
|
||||||
<div class="mb-3">
|
<label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
|
||||||
<label for="otp-label" style="font-weight: normal"><b>3.</b> Optionally, give your device a label so
|
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
|
||||||
that you can remember what device you set it up on:</label>
|
</div>
|
||||||
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit
|
<label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</label>
|
||||||
code and enter it here:</label>
|
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||||
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<input type="hidden" id="totp-setup-secret" />
|
||||||
<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 class="form-group">
|
||||||
<div>
|
<p>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in
|
||||||
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span
|
again, now using your two-factor authentication app.</p>
|
||||||
class="font-monospace"></span></span>.</p>
|
<button id="totp-setup-submit" disabled type="submit" class="btn btn-primary">Enable Two-Factor Authentication</button>
|
||||||
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<small>You will have to log into the admin panel again after disabling two-factor authentication.</small>
|
|
||||||
</form>
|
<form id="disable-2fa">
|
||||||
|
<div class="form-group">
|
||||||
|
<p>Two-factor authentication is active for your account<span id="mfa-device-label"></span>.</p>
|
||||||
|
<p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="output-2fa" class="card bg-light">
|
||||||
|
<div class="card-body"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var el = {
|
var el = {
|
||||||
disableForm: document.getElementById('disable-2fa'),
|
disableForm: document.getElementById('disable-2fa'),
|
||||||
totpSetupForm: document.getElementById('totp-setup'),
|
output: document.getElementById('output-2fa'),
|
||||||
totpSetupToken: document.getElementById('totp-setup-token'),
|
totpSetupForm: document.getElementById('totp-setup'),
|
||||||
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
totpSetupToken: document.getElementById('totp-setup-token'),
|
||||||
totpSetupLabel: document.getElementById('totp-setup-label'),
|
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
||||||
totpQr: document.getElementById('totp-setup-qr'),
|
totpSetupLabel: document.getElementById('totp-setup-label'),
|
||||||
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
totpQr: document.getElementById('totp-setup-qr'),
|
||||||
wrapper: document.querySelector('.twofactor')
|
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
||||||
}
|
wrapper: document.querySelector('.twofactor')
|
||||||
|
}
|
||||||
|
|
||||||
function update_setup_disabled(evt) {
|
function update_setup_disabled(evt) {
|
||||||
var val = evt.target.value.trim();
|
var val = evt.target.value.trim();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof val !== 'string' ||
|
typeof val !== 'string' ||
|
||||||
typeof el.totpSetupSecret.value !== 'string' ||
|
typeof el.totpSetupSecret.value !== 'string' ||
|
||||||
val.length !== 6 ||
|
val.length !== 6 ||
|
||||||
el.totpSetupSecret.value.length !== 32 ||
|
el.totpSetupSecret.value.length !== 32 ||
|
||||||
!(/^\+?\d+$/.test(val))
|
!(/^\+?\d+$/.test(val))
|
||||||
) {
|
) {
|
||||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
} else {
|
} else {
|
||||||
el.totpSetupSubmit.removeAttribute('disabled');
|
el.totpSetupSubmit.removeAttribute('disabled');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_totp_setup(provisioned_totp) {
|
function render_totp_setup(provisioned_totp) {
|
||||||
$('#twofactor-qrimg').attr("src", "data:image/png;base64," + provisioned_totp.qr_code_base64);
|
var img = document.createElement('img');
|
||||||
|
img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64;
|
||||||
|
|
||||||
el.totpSetupSecret.val = provisioned_totp.secret;
|
var code = document.createElement('div');
|
||||||
|
code.innerHTML = `Secret: ${provisioned_totp.secret}`;
|
||||||
|
|
||||||
el.totpSetupToken.addEventListener('input', update_setup_disabled);
|
el.totpQr.appendChild(img);
|
||||||
el.totpSetupForm.addEventListener('submit', do_enable_totp);
|
el.totpQr.appendChild(code);
|
||||||
|
|
||||||
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
|
el.totpSetupToken.addEventListener('input', update_setup_disabled);
|
||||||
|
el.totpSetupForm.addEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
el.wrapper.classList.add('disabled');
|
el.totpSetupSecret.setAttribute('value', provisioned_totp.secret);
|
||||||
}
|
|
||||||
|
|
||||||
function render_disable(mfa) {
|
el.wrapper.classList.add('disabled');
|
||||||
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() {
|
function render_disable(mfa) {
|
||||||
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
el.disableForm.addEventListener('submit', do_disable);
|
||||||
|
el.wrapper.classList.add('enabled');
|
||||||
|
if (mfa.label)
|
||||||
|
$("#mfa-device-label").text(" on device '" + mfa.label + "'");
|
||||||
|
}
|
||||||
|
|
||||||
el.disableForm.removeEventListener('submit', do_disable);
|
function hide_error() {
|
||||||
|
el.output.querySelector('.card-body').innerHTML = '';
|
||||||
|
el.output.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
el.totpSetupForm.reset();
|
function render_error(msg) {
|
||||||
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
el.output.querySelector('.card-body').innerHTML = msg;
|
||||||
|
el.output.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
el.totpSetupSecret.setAttribute('value', '');
|
function reset_view() {
|
||||||
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
||||||
|
|
||||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
el.disableForm.removeEventListener('submit', do_disable);
|
||||||
|
|
||||||
$('#twofactor-qrimg').attr("src", "");
|
hide_error();
|
||||||
$('#totp-setup-secret').attr("value", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function show_mfa() {
|
el.totpSetupForm.reset();
|
||||||
reset_view();
|
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||||
|
|
||||||
api(
|
el.totpSetupSecret.setAttribute('value', '');
|
||||||
'/mfa/status',
|
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||||
'POST',
|
|
||||||
{},
|
|
||||||
function (res) {
|
|
||||||
el.wrapper.classList.add('loaded');
|
|
||||||
|
|
||||||
var has_mfa = false;
|
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||||
res.enabled_mfa.forEach(function (mfa) {
|
el.totpQr.innerHTML = '';
|
||||||
if (mfa.type == "totp") {
|
}
|
||||||
render_disable(mfa);
|
|
||||||
has_mfa = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!has_mfa)
|
function show_mfa() {
|
||||||
render_totp_setup(res.new_mfa.totp);
|
reset_view();
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function do_disable(evt) {
|
api(
|
||||||
evt.preventDefault();
|
'/mfa/status',
|
||||||
|
'POST',
|
||||||
|
{},
|
||||||
|
function(res) {
|
||||||
|
el.wrapper.classList.add('loaded');
|
||||||
|
|
||||||
api(
|
var has_mfa = false;
|
||||||
'/mfa/disable',
|
res.enabled_mfa.forEach(function(mfa) {
|
||||||
'POST',
|
if (mfa.type == "totp") {
|
||||||
{ type: 'totp' },
|
render_disable(mfa);
|
||||||
function () {
|
has_mfa = true;
|
||||||
do_logout();
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
if (!has_mfa)
|
||||||
}
|
render_totp_setup(res.new_mfa.totp);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function do_enable_totp(evt) {
|
function do_disable(evt) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
api(
|
api(
|
||||||
'/mfa/totp/enable',
|
'/mfa/disable',
|
||||||
'POST',
|
'POST',
|
||||||
{
|
{ type: 'totp' },
|
||||||
token: $(el.totpSetupToken).val(),
|
function() {
|
||||||
secret: $(el.totpSetupSecret).val(),
|
do_logout();
|
||||||
label: $(el.totpSetupLabel).val()
|
}
|
||||||
},
|
);
|
||||||
function (res) { do_logout(); },
|
|
||||||
function (res) { show_modal_error("Two-Factor Authentication Setup", res); }
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function do_enable_totp(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
hide_error();
|
||||||
|
|
||||||
|
api(
|
||||||
|
'/mfa/totp/enable',
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
token: $(el.totpSetupToken).val(),
|
||||||
|
secret: $(el.totpSetupSecret).val(),
|
||||||
|
label: $(el.totpSetupLabel).val()
|
||||||
|
},
|
||||||
|
function(res) { do_logout(); },
|
||||||
|
function(res) { render_error(res); }
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,15 +6,15 @@
|
||||||
<p>Opening munin in a new tab... You may need to allow pop-ups for this site.</p>
|
<p>Opening munin in a new tab... You may need to allow pop-ups for this site.</p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_munin() {
|
function show_munin() {
|
||||||
// Set the cookie.
|
// Set the cookie.
|
||||||
api(
|
api(
|
||||||
"/munin",
|
"/munin",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (r) {
|
function(r) {
|
||||||
// Redirect.
|
// Redirect.
|
||||||
window.open("/admin/munin/index.html", "_blank");
|
window.open("/admin/munin/index.html", "_blank");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,287 +1,278 @@
|
||||||
<style>
|
<style>
|
||||||
#pgp_keyring_config .icon {
|
#pgp_keyring_config .status-error {
|
||||||
min-width: 2em;
|
color: rgb(140, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline {
|
#pgp_keyring_config .status-warning {
|
||||||
display: inline;
|
color: rgb(170, 120, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pgp_keyring_config .status-ok {
|
||||||
|
color: rgb(0, 140, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pgp_keyring_config .status-none {
|
||||||
|
color: rgb(190, 190, 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pgp_keyring_config #uids {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>PGP Keyring Management</h2>
|
<h2>PGP Keyring Management</h2>
|
||||||
|
|
||||||
<template id="pgpkey-template">
|
<template id="pgpkey-template">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="row p-3">
|
<td>
|
||||||
<div class="col-lg-10 col-12">
|
<div id="trustlevel" style="font-size: 14pt;"><b>Trust Level:</b> Ultimate</div>
|
||||||
<b id="isrevoked" class="fs-4"></b>
|
<code id="uids">
|
||||||
<div id="uids" class="mb-3 font-monospace">
|
🤖 Power Mail-in-a-Box Management Daemon <administrator@mailinabox.lan>
|
||||||
<div id="uid-template">
|
</code>
|
||||||
<span class="icon fas"></span><span id="id" class="status-text"></span>
|
<h3 style="font-size: 12pt;">Subkeys</h3>
|
||||||
</div>
|
<table id="subkeys">
|
||||||
</div>
|
<tr id="subkey-template">
|
||||||
<b class="fs-5">Subkeys</b>
|
<td id="ismaster">🔑</td>
|
||||||
<div id="subkeys" class="align-middle col-12">
|
<td>
|
||||||
<div class="row" id="subkey-template">
|
<b>
|
||||||
<div style="width: 2em;" id="ismaster" data-bs-toggle="tooltip" title="Master Key"></div>
|
<a id="sign">S</a>
|
||||||
<div style="width: 5em;">
|
<a id="cert">C</a>
|
||||||
<b>
|
<a id="encr">E</a>
|
||||||
<span class="sym" id="sign">S</span>
|
<a id="auth">A</a>
|
||||||
<span class="sym" id="cert">C</span>
|
</b>
|
||||||
<span class="sym" id="encr">E</span>
|
</td>
|
||||||
<span class="sym" id="auth">A</span>
|
<td style="width: 120pt;">
|
||||||
</b>
|
<b id="algo">RSA, 3072 bit</b>
|
||||||
</div>
|
</td>
|
||||||
<div class="col-md-2 col-4" id="algo">
|
<td>
|
||||||
<b class="status-text"></b>
|
<pre id="fpr">1756 6B81 D8A4 24C7 0098 659E 6872 2633 F692 52C6</pre>
|
||||||
</div>
|
</td>
|
||||||
<div class="col" id="expiration">
|
<td id="expiration">
|
||||||
<span class="status-text"></span>
|
12/12/20 (119 days)
|
||||||
</div>
|
</td>
|
||||||
<div class="col-md-6 col-12" id="fpr">
|
</tr>
|
||||||
<span class="status-text font-monospace"></span>
|
</table>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td id="options" style="width: 140pt;">
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
<div id="options" class="col-lg-2 col-12">
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div id="pgp_keyring_config">
|
<div id="pgp_keyring_config">
|
||||||
<h3>Daemon's Private Key</h3>
|
<h3>Daemon's Private Key</h3>
|
||||||
<table id="privatekey" class="container">
|
<table id="privatekey" class="table container">
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Imported Public Keys</h3>
|
<h3>Imported Public Keys</h3>
|
||||||
<table id="pubkeys" class="container">
|
<table id="pubkeys" class="table container">
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>Import Key</h3>
|
<h3>Import Key</h3>
|
||||||
<p>
|
<p>
|
||||||
You can upload your <b>public</b> key/keychain here. Keys <b>must</b> be submitted in ASCII-armored format.
|
You can upload your <b>public</b> key/keychain here. Keys <b>must</b> be submitted in ASCII-armored format.
|
||||||
<br>
|
<br>
|
||||||
If you're using <code>gpg</code>, you can export your public key by following this example:
|
If you're using <code>gpg</code>, you can export your public key by following this example:
|
||||||
<pre>
|
<pre>
|
||||||
# Get all the keys in the ring
|
# Get all the keys in the ring
|
||||||
$ <b>gpg --list-keys</b>
|
<b>$ gpg --list-keys</b>
|
||||||
/home/you/.gnupg/pubring.kbx
|
/home/you/.gnupg/pubring.kbx
|
||||||
----------------------------
|
----------------------------
|
||||||
pub rsa4096 1970-01-01 [SC]
|
pub rsa4096 1970-01-01 [SC]
|
||||||
247C3553B4B36107BA0490C3CAFCCF3B4965761A
|
247C3553B4B36107BA0490C3CAFCCF3B4965761A
|
||||||
uid [ full ] Someone That I Used to Know <someone@example.com>
|
uid [ full ] Someone That I Used to Know <someone@example.com>
|
||||||
sub rsa2048 2020-01-01 [E] [expires: 2069-12-31]
|
sub rsa2048 2020-01-01 [E] [expires: 2021-01-01]
|
||||||
|
|
||||||
pub rsa4096 1970-01-01 [SC] [expires: 2069-12-31]
|
pub rsa4096 2020-05-24 [SC] [expires: 2021-02-12]
|
||||||
52661092E5CD9EEFD7796B19E85F540C9318B69F
|
52661092E5CD9EEFD7796B19E85F540C9318B69F
|
||||||
uid [ultimate] Me, Myself and I <me@example.net>
|
uid [ultimate] Me, Myself and I <me@mydomain.com>
|
||||||
sub rsa2048 2020-05-24 [E] [expires: 2069-12-31]
|
sub rsa2048 2020-05-24 [E] [expires: 2021-02-12]
|
||||||
|
|
||||||
# Let's export the key "Me, Myself and I"
|
# Say that we want to export our own key, this is - "Me, Myself and I". That's the fingerprint "52661..."
|
||||||
$ <b>gpg --export --armor 52661092E5CD9EEFD7796B19E85F540C9318B69F</b>
|
<b>$ gpg --export --armor 52661092E5CD9EEFD7796B19E85F540C9318B69F</b> # Replace with your key's fingerprint
|
||||||
<b>-----BEGIN PGP PUBLIC KEY BLOCK-----
|
<b>-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
copy and paste this block in the area below
|
copy and paste this block in the area below
|
||||||
-----END PGP PUBLIC KEY BLOCK-----</b>
|
-----END PGP PUBLIC KEY BLOCK-----</b>
|
||||||
</pre>
|
</pre>
|
||||||
</p>
|
</p>
|
||||||
<div class="form-floating col-12 col-xl-6 mb-3">
|
<p><textarea id="pgp_paste_key" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----
stuff here
-----END PGP PUBLIC KEY BLOCK-----"></textarea></p>
|
||||||
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
<button class="btn btn-primary" onclick="importkey()">Import Key</button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function pretty_fpr(fpr) {
|
function pretty_fpr(fpr) {
|
||||||
let pfpr = ""
|
let pfpr = ""
|
||||||
for (let n = 0; n < 2; ++n) {
|
for (let n = 0; n < 2; ++n) {
|
||||||
for (let i = 0; i < 5; ++i) {
|
for (let i = 0; i < 5; ++i) {
|
||||||
pfpr += `${fpr.substring(n * 20 + i * 4, n * 20 + (i + 1) * 4)} `
|
pfpr += `${fpr.substring(n * 20 + i * 4, n * 20 + (i + 1) * 4)} `
|
||||||
}
|
}
|
||||||
pfpr += " "
|
pfpr += " "
|
||||||
}
|
}
|
||||||
|
|
||||||
return pfpr.substring(0, pfpr.length - 2)
|
return pfpr.substring(0, pfpr.length - 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
function key_html(key, darken_bg, daemon) {
|
function key_html(key, darken_bg, daemon) {
|
||||||
let keyrep = $("#pgpkey-template").html()
|
let keyrep = $("#pgpkey-template").html()
|
||||||
console.log(keyrep)
|
keyrep = $(keyrep)
|
||||||
keyrep = $(keyrep)
|
keyrep.attr("id", key.master_fpr)
|
||||||
keyrep.attr("id", key.master_fpr)
|
|
||||||
|
|
||||||
// Main key config
|
// Main key config
|
||||||
if (darken_bg) {
|
if (darken_bg) {
|
||||||
keyrep.addClass("bg-light")
|
keyrep.addClass("bg-light")
|
||||||
}
|
}
|
||||||
|
|
||||||
let rev = keyrep.find("#isrevoked")
|
const tlevel = keyrep.find("#trustlevel")
|
||||||
|
if (key.revoked) {
|
||||||
|
tlevel.html("<b class='status-error'>This key was revoked by it's owner.</b>")
|
||||||
|
} else {
|
||||||
|
tlevel.html("<b>Key is not revoked.</b>")
|
||||||
|
}
|
||||||
|
|
||||||
if (key.revoked) {
|
let uidtxt = ""
|
||||||
rev.text("This key was revoked by it's owner.")
|
if (daemon) {
|
||||||
rev.addClass(".status-error")
|
key.ids.forEach(id => {
|
||||||
} else {
|
uidtxt += "🤖 " + id + "\n"
|
||||||
rev.text("Key is not revoked.")
|
});
|
||||||
}
|
} else {
|
||||||
|
key.ids.forEach(id => {
|
||||||
|
uidtxt += "🕵 " + id + "\n"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
keyrep.find("#uids").text(uidtxt.substring(0, uidtxt.length - 1))
|
||||||
|
|
||||||
let uids = keyrep.find("#uids")
|
// Subkeys
|
||||||
let uidtemplate = $(uids.html())
|
const keyflags = ["sign", "cert", "encr", "auth"]
|
||||||
uids.find("#uid-template").remove()
|
let subkeys = keyrep.find("#subkeys")
|
||||||
if (daemon) {
|
let subkeytemplate = subkeys.html()
|
||||||
uidtemplate.find(".icon").addClass("fa-robot")
|
keyrep.find("#subkey-template").remove()
|
||||||
} else {
|
key.subkeys.forEach(skey => {
|
||||||
uidtemplate.find(".icon").addClass("fa-user")
|
let skeyrep = $(subkeytemplate)
|
||||||
}
|
skeyrep.attr("id", `sub${skey.fpr}`)
|
||||||
|
|
||||||
|
// Master key?
|
||||||
|
if (skey.master) {
|
||||||
|
skeyrep.find("#ismaster").html("🔑")
|
||||||
|
} else {
|
||||||
|
skeyrep.find("#ismaster").html("")
|
||||||
|
}
|
||||||
|
|
||||||
key.ids.forEach(id => {
|
// Usage flags
|
||||||
let thisuid = uidtemplate.clone()
|
keyflags.forEach(flag => {
|
||||||
thisuid.attr("id", "")
|
if (!skey[flag]) {
|
||||||
thisuid.find("#id").text(id)
|
skeyrep.find(`#${flag}`).addClass("status-none")
|
||||||
thisuid.appendTo(uids)
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subkeys
|
// Algorithm and fingerprint
|
||||||
const keyflags = ["sign", "cert", "encr", "auth"]
|
skeyrep.find("#algo").html(`${skey.algorithm}, ${skey.bits} bits`)
|
||||||
let subkeys = keyrep.find("#subkeys")
|
skeyrep.find("#fpr").html(pretty_fpr(skey.fpr))
|
||||||
let subkeytemplate = subkeys.html()
|
let expiration = skeyrep.find("#expiration")
|
||||||
keyrep.find("#subkey-template").remove()
|
|
||||||
key.subkeys.forEach(skey => {
|
|
||||||
let skeyrep = $(subkeytemplate)
|
|
||||||
skeyrep.attr("id", `sub${skey.fpr}`)
|
|
||||||
|
|
||||||
let statusclass
|
// Expiration
|
||||||
let expiration = skeyrep.find("#expiration")
|
if (key.revoked) {
|
||||||
let exptxt = expiration.find(".status-text")
|
skeyrep.addClass("status-error")
|
||||||
|
expiration.html(`Revoked`)
|
||||||
|
} else if (skey.expired) {
|
||||||
|
skeyrep.addClass("status-error")
|
||||||
|
expiration.html(`${skey.expires_date} (expired)`)
|
||||||
|
} else if (skey.expires && skey.expires_days <= 14) {
|
||||||
|
skeyrep.addClass("status-warning")
|
||||||
|
expiration.html(`${skey.expires_date} (${skey.expires_days} days)`)
|
||||||
|
} else if (skey.expires) {
|
||||||
|
skeyrep.addClass("status-ok")
|
||||||
|
expiration.html(`${skey.expires_date} (${skey.expires_days} days)`)
|
||||||
|
} else {
|
||||||
|
skeyrep.addClass("status-ok")
|
||||||
|
expiration.html("Does not expire")
|
||||||
|
}
|
||||||
|
skeyrep.appendTo(subkeys)
|
||||||
|
});
|
||||||
|
|
||||||
if (key.revoked) {
|
// Options
|
||||||
statusclass = "status-error"
|
if (daemon) {
|
||||||
exptxt.text("Revoked")
|
keyrep.find("#options").html(`<button class="btn btn-primary btn-block" onclick="exportkey('${key.master_fpr}')">Export Public Key</button>`)
|
||||||
} else if (skey.expired) {
|
} else {
|
||||||
statusclass = "status-error"
|
keyrep.find("#options").html(`<button class="btn btn-secondary btn-block" onclick="exportkey('${key.master_fpr}')">Export Public Key</button><button class="btn btn-danger btn-block" onclick="rmkey('${key.master_fpr}')">Remove Key</button>`)
|
||||||
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)
|
return keyrep
|
||||||
|
}
|
||||||
|
|
||||||
// Master key?
|
function show_pgp_keyring() {
|
||||||
if (skey.master) {
|
$('#privatekey').html("<tr><td class='text-muted'>Loading...</td></tr>")
|
||||||
skeyrep.find("#ismaster").addClass("fas fa-key")
|
$('#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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Usage flags
|
function exportkey(fpr) {
|
||||||
keyflags.forEach(flag => {
|
api(
|
||||||
skeyrep.find(`#${flag}`).addClass(skey[flag] ? statusclass : "status-na")
|
`/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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Algorithm and fingerprint
|
function rmkey(fpr) {
|
||||||
let algo = skeyrep.find("#algo")
|
show_modal_confirm("Delete key", `Are you sure you wish to remove the key with the fingerprint ${pretty_fpr(fpr)}?`, "Yes, remove it", () => {
|
||||||
algo.addClass(statusclass)
|
api(
|
||||||
algo.find(".status-text").html(`${skey.algorithm}, ${skey.bits} bits`)
|
`/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>"
|
||||||
|
|
||||||
let fpr = skeyrep.find("#fpr")
|
show_modal_error("Delete key", wkd_info, show_pgp_keyring)
|
||||||
fpr.addClass(statusclass)
|
}
|
||||||
fpr.find(".status-text").html(pretty_fpr(skey.fpr))
|
},
|
||||||
|
function(r) {
|
||||||
|
show_modal_error("Key deletion error", r)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, ()=>{})
|
||||||
|
}
|
||||||
|
|
||||||
skeyrep.appendTo(subkeys)
|
function importkey() {
|
||||||
});
|
api(
|
||||||
|
"/system/pgp/import",
|
||||||
// Options
|
"POST",
|
||||||
if (daemon) {
|
{
|
||||||
keyrep.find("#options").html(`<button class="btn btn-primary col-12" onclick="exportkey('${key.master_fpr}')">Export Public Key</button>`)
|
key: $("#pgp_paste_key").val()
|
||||||
} 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>`)
|
function(r) {
|
||||||
}
|
show_modal_error("Import Results", `<ul>
|
||||||
|
|
||||||
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 read:</b> ${r.keys_read}</li>
|
||||||
<li><b>Keys added:</b> ${r.keys_added}</li>
|
<li><b>Keys added:</b> ${r.keys_added}</li>
|
||||||
<li><b>Keys not changed:</b> ${r.keys_unchanged}</li>
|
<li><b>Keys not changed:</b> ${r.keys_unchanged}</li>
|
||||||
|
@ -289,10 +280,10 @@ copy and paste this block in the area below
|
||||||
<li><b>Signatures added:</b> ${r.sigs_added}</li>
|
<li><b>Signatures added:</b> ${r.sigs_added}</li>
|
||||||
<li><b>Revocations added:</b> ${r.revs_added}</li>
|
<li><b>Revocations added:</b> ${r.revs_added}</li>
|
||||||
</ul>`, show_pgp_keyring)
|
</ul>`, show_pgp_keyring)
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error("Import Error", r)
|
show_modal_error("Import Error", r)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -15,40 +15,66 @@
|
||||||
<div id="smtp_relay_config">
|
<div id="smtp_relay_config">
|
||||||
<form class="form-horizontal" role="form" onsubmit="set_smtp_relay_config(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="set_smtp_relay_config(); return false;">
|
||||||
<h3>SMTP Relay Configuration</h3>
|
<h3>SMTP Relay Configuration</h3>
|
||||||
<div id="smtp-relays" class="col-12">
|
<div class="form-group">
|
||||||
|
<table id="smtp-relays" class="table" style="width: 100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width: 15%;">
|
||||||
|
<label for="use_relay" class="col-sm-1 control-label"
|
||||||
|
style="vertical-align: middle; white-space: nowrap;"><b>Use Relay?</b></label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="checkbox" id="use_relay" name="use_relay" value=false onclick="checkfields();">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<div class="input-group mb-3">
|
<tr>
|
||||||
<label for="use_relay" class="input-group-text"><b>Use Relay?</b></label>
|
<td style="width: 15%;">
|
||||||
<div class="input-group-text">
|
<label for="relay_host" class="col-sm-1 control-label"
|
||||||
<div class="form-switch">
|
style="vertical-align: middle; white-space: nowrap;">Hostname</label>
|
||||||
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false
|
</td>
|
||||||
onclick="checkfields();">
|
<td>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<input type="text" class="form-control" id="relay_host" placeholder="relay.example.net">
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0; font-weight: bold; vertical-align: middle; white-space: nowrap;">:</td>
|
||||||
|
<td style="width: 20%;">
|
||||||
|
<div>
|
||||||
|
<input type="number" class="form-control" id="relay_port" min="1" max="65535" step="1"
|
||||||
|
value="465">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<div class="input-group mb-3">
|
<tr>
|
||||||
<label class="input-group-text">Where to?</label>
|
<td style="width: 15%;">
|
||||||
<input type="text" class="form-control" id="relay_host" placeholder="relay.example.com">
|
<label for="relay_auth_user" class="col-sm-1 control-label"
|
||||||
<label class="input-group-text">:</label>
|
style="vertical-align: middle; white-space: nowrap;">Username</label>
|
||||||
<div style="width: 20%">
|
</td>
|
||||||
<input type="number" class="form-control" id="relay_port" placeholder="Port (465)">
|
<td>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<input type="text" class="form-control" id="relay_auth_user" placeholder="user">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<div class="input-group mb-3">
|
<tr>
|
||||||
<label class="input-group-text">Username</label>
|
<td style="width: 15%;">
|
||||||
<input type="text" class="form-control" id="relay_auth_user" placeholder="">
|
<label for="relay_auth_pass" class="col-sm-1 control-label"
|
||||||
</div>
|
style="vertical-align: middle; white-space: nowrap;">Password/Key</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<input type="password" class="form-control" id="relay_auth_pass" placeholder="password">
|
||||||
|
</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="input-group">
|
|
||||||
<label class="input-group-text">Password/Key</label>
|
|
||||||
<input type="password" class="form-control" id="relay_auth_pass" placeholder="">
|
|
||||||
</div>
|
|
||||||
<p class="small">If you've already set up a relay before on this box, you can leave this field blank if
|
|
||||||
you don't want to change it's password.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Authorized Servers</h3>
|
<h3>Authorized Servers</h3>
|
||||||
|
@ -72,33 +98,36 @@
|
||||||
placeholder="mail1.example.net mail2.example.net">
|
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
|
<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
|
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.
|
<code>spf:example.com</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>DKIM Configuration</h3>
|
<h3>DKIM Configuration</h3>
|
||||||
<p>DKIM allows receivers to verify that the email was sent by the relay you configured (this is, somebody you
|
<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>
|
trust). <b>Not doing so will have your email sent to spam.</b></p>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-8 col-12">
|
<div class="form-group">
|
||||||
<div class="input-group">
|
<table style="width: 100%">
|
||||||
<input type="text" class="form-control" id="relay_dkim_selector" placeholder="selector"></td>
|
<tr>
|
||||||
<label class="input-group-text" for="relay_dkim_selector">._domainkey.{{hostname}}</label>
|
<td style="width: 15%;"><input type="text" style="text-align: right;" class="form-control"
|
||||||
</div>
|
id="relay_dkim_selector" placeholder="selector"></td>
|
||||||
</div>
|
<td><b>._domainkey.{{hostname}}</b></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h4>Paste the DKIM key here:</h4>
|
<h4>Paste the DKIM key here:</h4>
|
||||||
<p><textarea id="relay_dkim_key" class="form-control" style="width: 100%; height: 8em"
|
<p><textarea id="relay_dkim_key" class="form-control" style="width: 100%; height: 8em"
|
||||||
placeholder="k=algo;p=K3y/C0N7ent5/dGhpcyBpcyBub3QgYSByZWFsIGtleSwgc28gYmV3YXJlIDrigb4"></textarea>
|
placeholder="k=algo;p=K3y/C0N7ent5/dGhpcyBpcyBub3QgYSByZWFsIGtleSwgc28gYmV3YXJlIDrigb4"></textarea>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="submit" class="btn btn-primary">Update</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>After configuration</h3>
|
<h3>After configuration</h3>
|
||||||
<p>By that time you should be good to go. If your relay provider provides their own custom DNS verification
|
<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>
|
methods, feel free to publish them on DNS.</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -145,13 +174,9 @@
|
||||||
relay_auth_pass.value = ""
|
relay_auth_pass.value = ""
|
||||||
relay_authorized_servers.value = ""
|
relay_authorized_servers.value = ""
|
||||||
|
|
||||||
if (data.spf_record) {
|
data.authorized_servers.forEach(element => {
|
||||||
relay_authorized_servers.value = data.spf_record
|
relay_authorized_servers.value += `${element} `
|
||||||
}else if (data.authorized_servers) {
|
});
|
||||||
data.authorized_servers.forEach(element => {
|
|
||||||
relay_authorized_servers.value += `${element} `
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.dkim_selector) {
|
if (data.dkim_selector) {
|
||||||
relay_dkim_sel.value = data.dkim_selector
|
relay_dkim_sel.value = data.dkim_selector
|
||||||
|
@ -164,25 +189,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_smtp_relay_config() {
|
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(
|
api(
|
||||||
"/system/smtp/relay",
|
"/system/smtp/relay",
|
||||||
"POST",
|
"POST",
|
||||||
relay_configuration,
|
{
|
||||||
|
enabled: use_relay.checked,
|
||||||
|
host: relay_host.value,
|
||||||
|
port: relay_port.value,
|
||||||
|
user: relay_auth_user.value,
|
||||||
|
key: relay_auth_pass.value,
|
||||||
|
authorized_servers: relay_authorized_servers.value,
|
||||||
|
dkim_selector: relay_dkim_sel.value,
|
||||||
|
dkim_rr: relay_dkim_key.value
|
||||||
|
},
|
||||||
() => {
|
() => {
|
||||||
show_modal_error("Done!", "The configuration has been updated and Postfix was restarted successfully. Please make sure everything is functioning as intended.", () => {
|
show_modal_error("Done!", "The configuration has been updated and Postfix was restarted successfully. Please make sure everything is functioning as intended.", () => {
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -1,307 +1,237 @@
|
||||||
<style>
|
<style>
|
||||||
.code-white>code {
|
|
||||||
color: #ffffff
|
|
||||||
}
|
|
||||||
|
|
||||||
#ssl_provision_result>div>div {
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ssl_domains .icon {
|
|
||||||
min-width: 1.6em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>TLS (SSL) Certificates</h2>
|
<h2>TLS (SSL) Certificates</h2>
|
||||||
|
|
||||||
<p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address
|
<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>
|
||||||
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
|
<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>
|
||||||
that this box is hosting a website for (see the list below).</p>
|
|
||||||
|
|
||||||
<div id="ssl_provision">
|
<div id="ssl_provision">
|
||||||
<h3>Provision certificates</h3>
|
<h3>Provision certificates</h3>
|
||||||
|
|
||||||
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
|
<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/"
|
<button onclick='return provision_tls_cert();' class='btn btn-primary' style="float: left; margin: 0 1.5em 1em 0;">Provision</button>
|
||||||
target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:</p>
|
<p><b>By provisioning the certificates, you’re agreeing to the <a href="https://acme-v01.api.letsencrypt.org/terms">Let’s Encrypt Subscriber Agreement</a>.</b></p>
|
||||||
<ul class="text-primary"></ul>
|
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:<br>
|
||||||
|
<span class="text-primary"></span></p>
|
||||||
<div 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>
|
||||||
|
|
||||||
<div id="ssl_provision_result" class="mt-4"></div>
|
<div class="clearfix"> </div>
|
||||||
|
|
||||||
|
<div id="ssl_provision_result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Certificate status</h3>
|
<h3>Certificate status</h3>
|
||||||
|
|
||||||
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed
|
<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>
|
||||||
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">
|
<table id="ssl_domains" class="table" style="margin-bottom: 2em; width: auto; display: none">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Domain</th>
|
||||||
<th>Domain</th>
|
<th>Certificate Status</th>
|
||||||
<th>Certificate Status</th>
|
<th>Actions</th>
|
||||||
<th></th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
</tbody>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
<h3 id="ssl_install_header">Install certificate</h3>
|
<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.
|
<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>
|
||||||
You can generate the needed CSR below.</p>
|
|
||||||
|
|
||||||
<div class="col-lg-10 col-xl-8">
|
<p>Which domain are you getting a certificate for?</p>
|
||||||
<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">
|
<p><select id="ssldomain" onchange="show_csr()" class="form-control" style="width: auto"></select></p>
|
||||||
<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>(A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides the one you choose above.)</p>
|
||||||
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
|
|
||||||
|
|
||||||
<div class="col-lg-10 col-xl-8 mb-3">
|
<p>What country are you in? This is required by some TLS certificate providers. You may leave this blank if you know your TLS certificate provider doesn't require it.</p>
|
||||||
<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><select id="sslcc" onchange="show_csr()" class="form-control" style="width: auto">
|
||||||
|
<option value="">(Select)</option>
|
||||||
|
{% for code, name in csr_country_codes %}
|
||||||
|
<option value="{{code}}">{{name}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select></p>
|
||||||
|
|
||||||
<p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an
|
<div id="csr_info" style="display: none">
|
||||||
intermediate chain. Paste each separately into the boxes below:</p>
|
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
|
||||||
|
|
||||||
<div class="row g-4">
|
<pre id="ssl_csr"></pre>
|
||||||
<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">
|
<p><small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small></p>
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="install_cert()">Install</button>
|
<p style="margin-bottom: .5em">TLS/SSL certificate:</p>
|
||||||
|
<p><textarea id="ssl_paste_cert" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
|
<p style="margin-bottom: .5em">TLS/SSL intermediate chain (if provided):</p>
|
||||||
|
<p><textarea id="ssl_paste_chain" class="form-control" style="max-width: 40em; height: 8em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea></p>
|
||||||
|
|
||||||
|
<p>After you paste in the information, click the install button.</p>
|
||||||
|
|
||||||
|
<button class="btn-primary" onclick="install_cert()">Install</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_tls(keep_provisioning_shown) {
|
function show_tls(keep_provisioning_shown) {
|
||||||
api(
|
api(
|
||||||
"/ssl/status",
|
"/ssl/status",
|
||||||
"GET",
|
"GET",
|
||||||
{
|
{
|
||||||
},
|
},
|
||||||
function (res) {
|
function(res) {
|
||||||
// provisioning status
|
// provisioning status
|
||||||
|
|
||||||
$("#ssl_provision_p ul").html("")
|
if (!keep_provisioning_shown)
|
||||||
if (!keep_provisioning_shown)
|
$('#ssl_provision').toggle(res.can_provision.length > 0)
|
||||||
$('#ssl_provision').toggle(res.can_provision.length > 0)
|
|
||||||
|
|
||||||
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
|
$('#ssl_provision_p').toggle(res.can_provision.length > 0);
|
||||||
if (res.can_provision.length > 0) {
|
if (res.can_provision.length > 0)
|
||||||
let hostlist = $("#ssl_provision_p ul")
|
$('#ssl_provision_p span').text(res.can_provision.join(", "));
|
||||||
res.can_provision.forEach(domain => {
|
|
||||||
$("#ssl_provision_p ul").append($("<li></li>").text(domain))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// certificate status
|
// certificate status
|
||||||
var domains = res.status;
|
var domains = res.status;
|
||||||
var tb = $('#ssl_domains tbody');
|
var tb = $('#ssl_domains tbody');
|
||||||
tb.text('');
|
tb.text('');
|
||||||
$('#ssldomain').html('<option value="">(select)</option>');
|
$('#ssldomain').html('<option value="">(select)</option>');
|
||||||
$('#ssl_domains').show();
|
$('#ssl_domains').show();
|
||||||
for (var i = 0; i < domains.length; i++) {
|
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>");
|
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
|
||||||
tb.append(row);
|
tb.append(row);
|
||||||
row.attr('data-domain', domains[i].domain);
|
row.attr('data-domain', domains[i].domain);
|
||||||
row.find('th').text(domains[i].domain);
|
row.find('.domain a').text(domains[i].domain);
|
||||||
if (domains[i].status == "not-applicable") {
|
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||||
row.find('.actions a').remove(); // no actions applicable
|
if (domains[i].status == "not-applicable") {
|
||||||
}
|
domains[i].status = "muted"; // text-muted css class
|
||||||
let statusclass
|
row.find('.actions a').remove(); // no actions applicable
|
||||||
let faclass
|
}
|
||||||
switch (domains[i].status) {
|
row.addClass("text-" + domains[i].status);
|
||||||
case "success":
|
row.find('.status').text(domains[i].text);
|
||||||
statusclass = "status-ok"
|
if (domains[i].status == "success") {
|
||||||
faclass = "fa-check"
|
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
||||||
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
|
} else {
|
||||||
break;
|
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
|
||||||
case "warning":
|
}
|
||||||
statusclass = "status-warning"
|
|
||||||
faclass = "fa-exclamation-triangle"
|
|
||||||
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
|
|
||||||
break;
|
|
||||||
case "danger":
|
|
||||||
statusclass = "status-error"
|
|
||||||
faclass = "fa-times"
|
|
||||||
row.find('.actions a').addClass('btn-secondary').text('Install Certificate');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
statusclass = "status-na"
|
|
||||||
row.find('.actions a').remove()
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let txt = row.find(".status")
|
|
||||||
txt.addClass(statusclass);
|
|
||||||
txt.find(".fas").addClass(faclass)
|
|
||||||
txt.find("span").text(domains[i].text);
|
|
||||||
|
|
||||||
$('#ssldomain').append($('<option>').text(domains[i].domain));
|
$('#ssldomain').append($('<option>').text(domains[i].domain));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ssl_install(elem) {
|
function ssl_install(elem) {
|
||||||
var domain = $(elem).parents('tr').attr('data-domain');
|
var domain = $(elem).parents('tr').attr('data-domain');
|
||||||
$('#ssldomain').val(domain);
|
$('#ssldomain').val(domain);
|
||||||
show_csr();
|
show_csr();
|
||||||
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
$('html, body').animate({ scrollTop: $('#ssl_install_header').offset().top - $('.navbar-fixed-top').height() - 20 })
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_csr() {
|
function show_csr() {
|
||||||
// Can't show a CSR until both inputs are entered.
|
// Can't show a CSR until both inputs are entered.
|
||||||
if ($('#ssldomain').val() == "") return;
|
if ($('#ssldomain').val() == "") return;
|
||||||
if ($('#sslcc').val() == "") return;
|
if ($('#sslcc').val() == "") return;
|
||||||
|
|
||||||
// Scroll to it and fetch.
|
// Scroll to it and fetch.
|
||||||
$('#csr_info').slideDown();
|
$('#csr_info').slideDown();
|
||||||
$('#ssl_csr').text('Loading...');
|
$('#ssl_csr').text('Loading...');
|
||||||
api(
|
api(
|
||||||
"/ssl/csr/" + $('#ssldomain').val(),
|
"/ssl/csr/" + $('#ssldomain').val(),
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
countrycode: $('#sslcc').val()
|
countrycode: $('#sslcc').val()
|
||||||
},
|
},
|
||||||
function (data) {
|
function(data) {
|
||||||
$('#ssl_csr').text(data);
|
$('#ssl_csr').text(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function install_cert() {
|
function install_cert() {
|
||||||
api(
|
api(
|
||||||
"/ssl/install",
|
"/ssl/install",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
domain: $('#ssldomain').val(),
|
domain: $('#ssldomain').val(),
|
||||||
cert: $('#ssl_paste_cert').val(),
|
cert: $('#ssl_paste_cert').val(),
|
||||||
chain: $('#ssl_paste_chain').val()
|
chain: $('#ssl_paste_chain').val()
|
||||||
},
|
},
|
||||||
function (status) {
|
function(status) {
|
||||||
if (/^OK($|\n)/.test(status)) {
|
if (/^OK($|\n)/.test(status)) {
|
||||||
console.log(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(); });
|
show_modal_error("TLS Certificate Installation", "Certificate has been installed. Check that you have no connection problems to the domain.", function() { show_ssl(); $('#csr_info').slideUp(); });
|
||||||
} else {
|
} else {
|
||||||
show_modal_error("TLS Certificate Installation", status);
|
show_modal_error("TLS Certificate Installation", status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function provision_tls_cert() {
|
function provision_tls_cert() {
|
||||||
// Automatically provision any certs.
|
// Automatically provision any certs.
|
||||||
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
|
$('#ssl_provision_p .btn').attr('disabled', '1'); // prevent double-clicks
|
||||||
api(
|
api(
|
||||||
"/ssl/provision",
|
"/ssl/provision",
|
||||||
"POST",
|
"POST",
|
||||||
{},
|
{ },
|
||||||
function (status) {
|
function(status) {
|
||||||
// Clear last attempt.
|
// Clear last attempt.
|
||||||
$('#ssl_provision_result').html("");
|
$('#ssl_provision_result').text("");
|
||||||
may_reenable_provision_button = true;
|
may_reenable_provision_button = true;
|
||||||
|
|
||||||
// Nothing was done. There might also be problem domains, but we've already displayed those.
|
// Nothing was done. There might also be problem domains, but we've already displayed those.
|
||||||
if (status.requests.length == 0) {
|
if (status.requests.length == 0) {
|
||||||
show_modal_error("TLS Certificate Provisioning", "There were no domain names to provision certificates for.");
|
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
|
// don't return - haven't re-enabled the provision button
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each provisioning API call returns zero or more "requests" which represent
|
// 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
|
// a request to Let's Encrypt for a single certificate. Normally there is just
|
||||||
// one request (for a single multi-domain certificate).
|
// one request (for a single multi-domain certificate).
|
||||||
let global_logs = []
|
for (var i = 0; i < status.requests.length; i++) {
|
||||||
for (var i = 0; i < status.requests.length; i++) {
|
var r = status.requests[i];
|
||||||
var r = status.requests[i];
|
|
||||||
|
|
||||||
if (r.result == "skipped") {
|
if (r.result == "skipped") {
|
||||||
// not interested --- this domain wasn't in the table
|
// not interested --- this domain wasn't in the table
|
||||||
// to begin with
|
// to begin with
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// plain log line
|
// create an HTML block to display the results of this request
|
||||||
if (typeof r === "string") {
|
var n = $("<div><h4/><p/></div>");
|
||||||
if (r !== "") {
|
$('#ssl_provision_result').append(n);
|
||||||
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>");
|
// plain log line
|
||||||
$('#ssl_provision_result').append(n);
|
if (typeof r === "string") {
|
||||||
|
n.find("p").text(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (status.requests.length > 0) {
|
// show a header only to disambiguate request blocks
|
||||||
n.find(".card-header").html(`Logs for ${r.domains.map((h) => { return `<code>${h}</code>` }).join(", ")}`);
|
if (status.requests.length > 0)
|
||||||
}
|
n.find("h4").text(r.domains.join(", "));
|
||||||
|
|
||||||
if (r.result == "error") {
|
if (r.result == "error") {
|
||||||
n.find(".card-header").addClass("bg-danger")
|
n.find("p").addClass("text-danger").text(r.message);
|
||||||
} 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
|
} else if (r.result == "installed") {
|
||||||
let logs = r.log.join("\n")
|
n.find("p").addClass("text-success").text("The TLS certificate was provisioned and installed.");
|
||||||
if (logs === "" || logs === null || logs === undefined) {
|
setTimeout("show_tls(true)", 1); // update main table of certificate statuses, call with arg keep_provisioning_shown true so that we don't clear what we just outputted
|
||||||
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) {
|
// display the detailed log info in case of problems
|
||||||
$('#ssl_provision_p .btn').removeAttr("disabled");
|
var trace = $("<div class='small text-muted' style='margin-top: 1.5em'>Log:</div>");
|
||||||
}
|
n.append(trace);
|
||||||
});
|
for (var j = 0; j < r.log.length; j++)
|
||||||
}
|
trace.append($("<div/>").text(r.log[j]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (may_reenable_provision_button)
|
||||||
|
$('#ssl_provision_p .btn').removeAttr("disabled");
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,91 +5,44 @@
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="row d-flex">
|
<div class="row">
|
||||||
<div class="col-lg-6">
|
<div class="col-sm-6">
|
||||||
<h4>In your browser</h4>
|
<h4>In your browser</h4>
|
||||||
|
|
||||||
<p>You can edit your contacts and calendar from your web browser.</p>
|
<p>You can edit your contacts and calendar from your web browser.</p>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<caption></caption>
|
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
||||||
<thead>
|
<tr><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||||
<tr>
|
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></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>
|
</table>
|
||||||
|
|
||||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>:
|
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
||||||
your
|
complete email address and your mail password.</p>
|
||||||
complete email address and your mail password.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-sm-6">
|
||||||
<h4>On your mobile device</h4>
|
<h4>On your mobile device</h4>
|
||||||
|
|
||||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using
|
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
|
||||||
Exchange/ActiveSync,
|
your contacts and calendar may already appear on your device.</p>
|
||||||
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>
|
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<caption></caption>
|
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||||
<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>
|
<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>
|
||||||
<th>For...</th>
|
<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>
|
||||||
<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>
|
</table>
|
||||||
|
|
||||||
<p>Use the following settings:</p>
|
<p>Use the following settings:</p>
|
||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tr>
|
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
|
||||||
<td>Account Type</td>
|
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
|
||||||
<td>CardDAV or CalDAV</td>
|
<tr><td>Use SSL</td> <td>Yes</td></tr>
|
||||||
</tr>
|
<tr><td>Username</td> <td>Your complete email address.</td></tr>
|
||||||
<tr>
|
<tr><td>Password</td> <td>Your mail password.</td></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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,416 +1,393 @@
|
||||||
<style>
|
<style>
|
||||||
#backup-status tr.full-backup td {
|
#backup-status th { text-align: center; }
|
||||||
font-weight: bold;
|
#backup-status tr.full-backup td { font-weight: bold; }
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<h2>Backup Status</h2>
|
<h2>Backup Status</h2>
|
||||||
|
|
||||||
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can
|
<p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can also store in on S3-compatible services like Amazon Web Services (AWS).</p>
|
||||||
also store it on S3-compatible services like Amazon Web Services (AWS).</p>
|
|
||||||
|
|
||||||
<h3>Configuration</h3>
|
<h3>Configuration</h3>
|
||||||
|
|
||||||
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
|
<form class="form-horizontal" role="form" onsubmit="set_custom_backup(); return false;">
|
||||||
<div class="col-lg-10 col-xl-8">
|
<div class="form-group">
|
||||||
<div class="input-group">
|
<label for="backup-target-type" class="col-sm-2 control-label">Backup to:</label>
|
||||||
<label class="input-group-text" for="backup-target-type">Backup to</label>
|
<div class="col-sm-3">
|
||||||
<select class="form-select" rows="1" id="backup-target-type" onchange="toggle_form()">
|
<select class="form-control" rows="1" id="backup-target-type" onchange="toggle_form()">
|
||||||
<option value="off">Nowhere (Disable Backups)</option>
|
<option value="off">Nowhere (Disable Backups)</option>
|
||||||
<option value="local">{{hostname}}</option>
|
<option value="local">{{hostname}}</option>
|
||||||
<option value="rsync">rsync</option>
|
<option value="rsync">rsync</option>
|
||||||
<option value="s3">S3 (Amazon or compatible) </option>
|
<option value="s3">S3 (Amazon or compatible) </option>
|
||||||
<option value="b2">Backblaze B2</option>
|
<option value="b2">Backblaze B2</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- LOCAL BACKUP -->
|
||||||
|
<div class="form-group backup-target-local">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>
|
||||||
|
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- RSYNC BACKUP -->
|
||||||
|
<div class="form-group backup-target-rsync">
|
||||||
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
|
|
||||||
<!-- LOCAL BACKUP -->
|
<p>Backups synced to a remote machine using rsync over SSH, with local
|
||||||
<div class="form-group backup-target-local">
|
copies in <tt class="backup-location"></tt>. These files are encrypted, so
|
||||||
<div class="col-lg-10 col-xl-8 mb-3">
|
they are safe to store anywhere.</p> <p>Separately copy the encryption
|
||||||
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP
|
password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||||
over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are
|
secure location. You will need this file to decrypt backup files.</p>
|
||||||
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">
|
|
||||||
|
|
||||||
<p>Backups synced to a remote machine using rsync over SSH, with local
|
</div>
|
||||||
copies in <tt class="backup-location"></tt>. These files are encrypted, so
|
</div>
|
||||||
they are safe to store anywhere.</p>
|
<div class="form-group backup-target-rsync">
|
||||||
<p>Separately copy the encryption
|
<table class="col-sm-8">
|
||||||
password from <tt class="backup-encpassword-file"></tt> to a safe and
|
<tr style="width: 100%;">
|
||||||
secure location. You will need this file to decrypt backup files.</p>
|
<td style="width: 75%;" class="col-sm-8">
|
||||||
|
<label for="backup-target-rsync-host" class="control-label">Hostname</label>
|
||||||
</div>
|
<input type="text" placeholder="hostname.local" class="form-control" rows="1" id="backup-target-rsync-host">
|
||||||
</div>
|
</td>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
<td style="width: 25%;" class="col-sm-8">
|
||||||
<div class="input-group">
|
<label for="backup-target-rsync-port" class="control-label">Port</label>
|
||||||
<label class="input-group-text">Where to?</label>
|
<input type="number" class="form-control" id="backup-target-rsync-port" placeholder="22">
|
||||||
<input type="text" class="form-control" id="backup-target-rsync-host" placeholder="hostname.local">
|
</td>
|
||||||
<label class="input-group-text">:</label>
|
</tr>
|
||||||
<div style="width: 20%">
|
</table>
|
||||||
<input type="number" class="form-control" id="backup-target-rsync-port" placeholder="Port (22)">
|
</div>
|
||||||
</div>
|
<div class="form-group backup-target-rsync">
|
||||||
</div>
|
<label for="backup-target-rsync-path" class="col-sm-2 control-label">Path</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" rows="1" id="backup-target-rsync-path">
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-rsync-path" class="input-group-text">Path</label>
|
</div>
|
||||||
<input type="text" placeholder="/backups/{{hostname}}" class="form-control" id="backup-target-rsync-path">
|
<div class="form-group backup-target-rsync">
|
||||||
</div>
|
<label for="backup-target-rsync-user" class="col-sm-2 control-label">Username</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
<input type="text" class="form-control" rows="1" id="backup-target-rsync-user">
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-rsync-path" class="input-group-text">Username</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="backup-target-rsync-user">
|
<div class="form-group backup-target-rsync">
|
||||||
</div>
|
<label for="ssh-pub-key" class="col-sm-2 control-label">Public SSH Key</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
<input type="text" class="form-control" rows="1" id="ssh-pub-key" readonly>
|
||||||
<label for="ssh-pub-key" class="col-sm-2 control-label">
|
<div class="small" style="margin-top: 2px">
|
||||||
<h4>Public SSH Key</h4>
|
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
|
||||||
</label>
|
of target user on the backup server specified above. That way you'll enable secure and
|
||||||
<textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea>
|
passwordless authentication from your mail-in-a-box server and your backup server.
|
||||||
<div class="small" style="margin-top: 2px">
|
</div>
|
||||||
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
|
</div>
|
||||||
of target user on the backup server specified above. That way you'll enable secure and
|
</div>
|
||||||
passwordless authentication from your mail-in-a-box server and your backup server.
|
<!-- S3 BACKUP -->
|
||||||
</div>
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
<!-- S3 BACKUP -->
|
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<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>
|
||||||
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
|
</div>
|
||||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
</div>
|
||||||
secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<label for="backup-target-s3-host-select" class="col-sm-2 control-label">S3 Region</label>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<select class="form-control" rows="1" id="backup-target-s3-host-select">
|
||||||
<label for="backup-target-s3-host-select" class="input-group-text">S3 Region</label>
|
{% for name, host in backup_s3_hosts %}
|
||||||
<select class="form-select" id="backup-target-s3-host-select">
|
<option value="{{host}}">{{name}}</option>
|
||||||
{% for name, host in backup_s3_hosts %}
|
{% endfor %}
|
||||||
<option value="{{host}}">{{name}}</option>
|
<option value="other">Other (non AWS)</option>
|
||||||
{% endfor %}
|
</select>
|
||||||
<option value="other">Other (non AWS)</option>
|
</div>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<label for="backup-target-s3-host" class="col-sm-2 control-label">S3 Host / Endpoint</label>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<input type="text" placeholder="Endpoint" class="form-control" rows="1" id="backup-target-s3-host">
|
||||||
<label for="backup-target-s3-host" class="input-group-text">S3 Host / Endpoint</label>
|
</div>
|
||||||
<input type="text" placeholder="Endpoint" class="form-control" id="backup-target-s3-host">
|
</div>
|
||||||
</div>
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<label for="backup-target-s3-path" class="col-sm-2 control-label">S3 Path</label>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<div class="col-sm-8">
|
||||||
<div class="input-group">
|
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" rows="1" id="backup-target-s3-path">
|
||||||
<label for="backup-target-s3-path" class="input-group-text">S3 Path</label>
|
</div>
|
||||||
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control"
|
</div>
|
||||||
id="backup-target-s3-path">
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<label for="backup-target-user" class="col-sm-2 control-label">S3 Access Key</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<input type="text" class="form-control" rows="1" id="backup-target-user">
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-user" class="input-group-text">S3 Access Key</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="backup-target-user">
|
<div class="form-group backup-target-s3">
|
||||||
</div>
|
<label for="backup-target-pass" class="col-sm-2 control-label">S3 Secret Access Key</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
<input type="text" class="form-control" rows="1" id="backup-target-pass">
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-pass" class="input-group-text">S3 Secret Access Key</label>
|
</div>
|
||||||
<input type="text" class="form-control" id="backup-target-pass">
|
<!-- Backblaze -->
|
||||||
</div>
|
<div class="form-group backup-target-b2">
|
||||||
</div>
|
<div class="col-sm-10 col-sm-offset-2">
|
||||||
<!-- Backblaze -->
|
<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>
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
<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>
|
||||||
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2
|
</div>
|
||||||
bucket. You must have a Backblaze account already.</p>
|
</div>
|
||||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
<div class="form-group backup-target-b2">
|
||||||
secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.
|
<label for="backup-target-b2-user" class="col-sm-2 control-label">B2 Application KeyID</label>
|
||||||
</p>
|
<div class="col-sm-8">
|
||||||
</div>
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-user">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-b2-user" class="input-group-text">B2 Application KeyID</label>
|
<div class="form-group backup-target-b2">
|
||||||
<input type="text" class="form-control" id="backup-target-b2-user">
|
<label for="backup-target-b2-pass" class="col-sm-2 control-label">B2 Application Key</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
</div>
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-pass">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-b2-pass" class="input-group-text">B2 Application Key</label>
|
<div class="form-group backup-target-b2">
|
||||||
<input type="text" class="form-control" id="backup-target-b2-pass">
|
<label for="backup-target-b2-bucket" class="col-sm-2 control-label">B2 Bucket</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
</div>
|
<input type="text" class="form-control" rows="1" id="backup-target-b2-bucket">
|
||||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
</div>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label for="backup-target-b2-bucket" class="input-group-text">B2 Bucket</label>
|
<!-- Common -->
|
||||||
<input type="text" class="form-control" id="backup-target-b2-bucket">
|
<div class="form-group backup-target-local backup-target-rsync backup-target-s3 backup-target-b2">
|
||||||
</div>
|
<label for="min-age" class="col-sm-2 control-label">Retention Days:</label>
|
||||||
</div>
|
<div class="col-sm-8">
|
||||||
<!-- Common -->
|
<input type="number" class="form-control" rows="1" id="min-age">
|
||||||
<div class="backup-target-local backup-target-rsync backup-target-s3 backup-target-b2">
|
<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>
|
||||||
<hr>
|
</div>
|
||||||
<div class="col-lg-10 col-xl-8">
|
</div>
|
||||||
<div class="input-group">
|
<div class="form-group">
|
||||||
<label class="input-group-text" for="backup-min-age">Retention Days</label>
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
<input type="number" class="form-control" id="backup-min-age">
|
<button id="set-s3-backup-button" type="submit" class="btn btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an
|
</div>
|
||||||
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>
|
</form>
|
||||||
|
|
||||||
<h3>Available backups</h3>
|
<h3>Available backups</h3>
|
||||||
|
|
||||||
<p>The backup location currently contains the backups listed below. The total size of the backups is currently <span
|
<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>
|
||||||
id="backup-total-size"></span>.</p>
|
|
||||||
|
|
||||||
<table id="backup-status" class="table align-middle col-12">
|
<table id="backup-status" class="table" style="width: auto">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<th colspan="2">When</th>
|
||||||
<th colspan="2">When</th>
|
<th>Type</th>
|
||||||
<th>Type</th>
|
<th>Size</th>
|
||||||
<th>Size</th>
|
<th>Deleted in...</th>
|
||||||
<th>Deleted in...</th>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
</tbody>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Hide these buttons until we're sure we can use them :) -->
|
<!-- 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" onclick="do_backup(true)" style="display: none;">Create Full Backup Now</button>
|
||||||
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)"
|
<button id="create-incremental-backup-button" class="btn btn-primary" onclick="do_backup(false)" style="display: none;">Create Incremental Backup Now</button>
|
||||||
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>
|
<script>
|
||||||
|
|
||||||
function toggle_form() {
|
function toggle_form() {
|
||||||
var target_type = $("#backup-target-type").val();
|
var target_type = $("#backup-target-type").val();
|
||||||
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||||
$(".backup-target-" + target_type).show();
|
$(".backup-target-" + target_type).show();
|
||||||
|
|
||||||
init_inputs(target_type);
|
init_inputs(target_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nice_size(bytes) {
|
function nice_size(bytes) {
|
||||||
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
var powers = ['bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
while (true) {
|
while (true) {
|
||||||
if (powers.length == 1) break;
|
if (powers.length == 1) break;
|
||||||
if (bytes < 1000) break;
|
if (bytes < 1000) break;
|
||||||
bytes /= 1024;
|
bytes /= 1024;
|
||||||
powers.shift();
|
powers.shift();
|
||||||
}
|
}
|
||||||
// round to have three significant figures but at most one decimal place
|
// round to have three significant figures but at most one decimal place
|
||||||
if (bytes >= 100)
|
if (bytes >= 100)
|
||||||
bytes = Math.round(bytes)
|
bytes = Math.round(bytes)
|
||||||
else
|
else
|
||||||
bytes = Math.round(bytes * 10) / 10;
|
bytes = Math.round(bytes*10)/10;
|
||||||
return bytes + " " + powers[0];
|
return bytes + " " + powers[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_system_backup() {
|
function show_system_backup() {
|
||||||
show_custom_backup()
|
show_custom_backup()
|
||||||
|
|
||||||
$('#backup-status tbody').html("<tr><td colspan='5' class='text-muted'>Loading...</td></tr>")
|
$('#backup-status tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/system/backup/status",
|
"/system/backup/status",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (r) {
|
function(r) {
|
||||||
if (r.error) {
|
if (r.error) {
|
||||||
show_modal_error("Backup Error", $("<pre/>").text(r.error));
|
show_modal_error("Backup Error", $("<pre/>").text(r.error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#backup-status tbody').html("");
|
$('#backup-status tbody').html("");
|
||||||
var total_disk_size = 0;
|
var total_disk_size = 0;
|
||||||
|
|
||||||
if (typeof r.backups == "undefined") {
|
if (typeof r.backups == "undefined") {
|
||||||
var tr = $('<tr><td colspan="5">Backups are turned off.</td></tr>');
|
var tr = $('<tr><td colspan="3">Backups are turned off.</td></tr>');
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
$('#create-full-backup-button').css("display", "none")
|
$('#create-full-backup-button').css("display","none")
|
||||||
$('#create-incremental-backup-button').css("display", "none")
|
$('#create-incremental-backup-button').css("display","none")
|
||||||
return;
|
return;
|
||||||
} else if (r.backups.length == 0) {
|
} else if (r.backups.length == 0) {
|
||||||
var tr = $('<tr><td colspan="5">No backups have been made yet.</td></tr>');
|
var tr = $('<tr><td colspan="3">No backups have been made yet.</td></tr>');
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backups ARE enabled.
|
// Backups ARE enabled.
|
||||||
$('#create-full-backup-button').css("display", "unset")
|
$('#create-full-backup-button').css("display","unset")
|
||||||
$('#create-incremental-backup-button').css("display", "unset")
|
$('#create-incremental-backup-button').css("display","unset")
|
||||||
for (var i = 0; i < r.backups.length; i++) {
|
for (var i = 0; i < r.backups.length; i++) {
|
||||||
var b = r.backups[i];
|
var b = r.backups[i];
|
||||||
var tr = $('<tr/>');
|
var tr = $('<tr/>');
|
||||||
if (b.full) tr.addClass("full-backup");
|
if (b.full) tr.addClass("full-backup");
|
||||||
tr.append($('<td/>').text(b.date_str));
|
tr.append( $('<td/>').text(b.date_str) );
|
||||||
tr.append($('<td/>').text(b.date_delta + " ago"));
|
tr.append( $('<td/>').text(b.date_delta + " ago") );
|
||||||
tr.append($('<td/>').text(b.full ? "full" : "increment"));
|
tr.append( $('<td/>').text(b.full ? "full" : "increment") );
|
||||||
tr.append($('<td/>').text(nice_size(b.size)));
|
tr.append( $('<td style="text-align: right"/>').text( nice_size(b.size)) );
|
||||||
if (b.deleted_in)
|
if (b.deleted_in)
|
||||||
tr.append($('<td/>').text(b.deleted_in));
|
tr.append( $('<td/>').text(b.deleted_in) );
|
||||||
else
|
else
|
||||||
tr.append($('<td class="text-muted">unknown</td>'));
|
tr.append( $('<td class="text-muted">unknown</td>') );
|
||||||
$('#backup-status tbody').append(tr);
|
$('#backup-status tbody').append(tr);
|
||||||
|
|
||||||
total_disk_size += b.size;
|
total_disk_size += b.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
total_disk_size += r.unmatched_file_size;
|
total_disk_size += r.unmatched_file_size;
|
||||||
$('#backup-total-size').text(nice_size(total_disk_size));
|
$('#backup-total-size').text(nice_size(total_disk_size));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_custom_backup() {
|
function show_custom_backup() {
|
||||||
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
$(".backup-target-local, .backup-target-rsync, .backup-target-s3, .backup-target-b2").hide();
|
||||||
api(
|
api(
|
||||||
"/system/backup/config",
|
"/system/backup/config",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{ },
|
||||||
function (r) {
|
function(r) {
|
||||||
$("#backup-target-user").val(r.target_user);
|
$("#backup-target-user").val(r.target_user);
|
||||||
$("#backup-target-pass").val(r.target_pass);
|
$("#backup-target-pass").val(r.target_pass);
|
||||||
$("#backup-min-age").val(r.min_age_in_days);
|
$("#min-age").val(r.min_age_in_days);
|
||||||
$(".backup-location").text(r.file_target_directory);
|
$(".backup-location").text(r.file_target_directory);
|
||||||
$(".backup-encpassword-file").text(r.enc_pw_file);
|
$(".backup-encpassword-file").text(r.enc_pw_file);
|
||||||
$("#ssh-pub-key").val(r.ssh_pub_key);
|
$("#ssh-pub-key").val(r.ssh_pub_key);
|
||||||
|
|
||||||
if (r.target == "file://" + r.file_target_directory) {
|
if (r.target == "file://" + r.file_target_directory) {
|
||||||
$("#backup-target-type").val("local");
|
$("#backup-target-type").val("local");
|
||||||
} else if (r.target == "off") {
|
} else if (r.target == "off") {
|
||||||
$("#backup-target-type").val("off");
|
$("#backup-target-type").val("off");
|
||||||
} else if (r.target.substring(0, 8) == "rsync://") {
|
} else if (r.target.substring(0, 8) == "rsync://") {
|
||||||
$("#backup-target-type").val("rsync");
|
$("#backup-target-type").val("rsync");
|
||||||
let uri = r.target.substring(8)
|
let uri = r.target.substring(8)
|
||||||
let i = uri.indexOf("/")
|
let i = uri.indexOf("/")
|
||||||
let path = [uri.slice(0, i), uri.slice(i + 1)];
|
let path = [uri.slice(0, i), uri.slice(i + 1)];
|
||||||
let host_parts = path.shift().split('@');
|
let host_parts = path.shift().split('@');
|
||||||
$("#backup-target-rsync-user").val(host_parts[0]);
|
$("#backup-target-rsync-user").val(host_parts[0]);
|
||||||
$("#backup-target-rsync-host").val(host_parts[1]);
|
$("#backup-target-rsync-host").val(host_parts[1]);
|
||||||
$("#backup-target-rsync-path").val(path[0]);
|
$("#backup-target-rsync-path").val(path[0]);
|
||||||
$("#backup-target-rsync-port").val(r.target_rsync_port)
|
$("#backup-target-rsync-port").val(r.target_rsync_port)
|
||||||
} else if (r.target.substring(0, 5) == "s3://") {
|
} else if (r.target.substring(0, 5) == "s3://") {
|
||||||
$("#backup-target-type").val("s3");
|
$("#backup-target-type").val("s3");
|
||||||
var hostpath = r.target.substring(5).split('/');
|
var hostpath = r.target.substring(5).split('/');
|
||||||
var host = hostpath.shift();
|
var host = hostpath.shift();
|
||||||
let s3_options = $("#backup-target-s3-host-select option").map(function() {return this.value}).get()
|
$("#backup-target-s3-host").val(host);
|
||||||
$("#backup-target-s3-host-select").val("other")
|
$("#backup-target-s3-path").val(hostpath.join('/'));
|
||||||
for (let h of s3_options) {
|
} else if (r.target.substring(0, 5) == "b2://") {
|
||||||
console.log(h)
|
$("#backup-target-type").val("b2");
|
||||||
if (h == host) {
|
var targetPath = r.target.substring(5);
|
||||||
$("#backup-target-s3-host-select").val(host)
|
var b2_application_keyid = targetPath.split(':')[0];
|
||||||
break
|
var b2_applicationkey = targetPath.split(':')[1].split('@')[0];
|
||||||
}
|
var b2_bucket = targetPath.split('@')[1];
|
||||||
}
|
$("#backup-target-b2-user").val(b2_application_keyid);
|
||||||
$("#backup-target-s3-host").val(host);
|
$("#backup-target-b2-pass").val(b2_applicationkey);
|
||||||
$("#backup-target-s3-path").val(hostpath.join('/'));
|
$("#backup-target-b2-bucket").val(b2_bucket);
|
||||||
} else if (r.target.substring(0, 5) == "b2://") {
|
}
|
||||||
$("#backup-target-type").val("b2");
|
toggle_form()
|
||||||
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() {
|
function set_custom_backup() {
|
||||||
var target_type = $("#backup-target-type").val();
|
var target_type = $("#backup-target-type").val();
|
||||||
var target_user = $("#backup-target-user").val();
|
var target_user = $("#backup-target-user").val();
|
||||||
var target_pass = $("#backup-target-pass").val();
|
var target_pass = $("#backup-target-pass").val();
|
||||||
let target_port = $("#backup-target-rsync-port").val();
|
let target_port = $("#backup-target-rsync-port").val();
|
||||||
|
|
||||||
var target;
|
var target;
|
||||||
if (target_type == "local" || target_type == "off")
|
if (target_type == "local" || target_type == "off")
|
||||||
target = target_type;
|
target = target_type;
|
||||||
else if (target_type == "s3")
|
else if (target_type == "s3")
|
||||||
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
target = "s3://" + $("#backup-target-s3-host").val() + "/" + $("#backup-target-s3-path").val();
|
||||||
else if (target_type == "rsync") {
|
else if (target_type == "rsync") {
|
||||||
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
target = "rsync://" + $("#backup-target-rsync-user").val() + "@" + $("#backup-target-rsync-host").val()
|
||||||
+ "/" + $("#backup-target-rsync-path").val();
|
+ "/" + $("#backup-target-rsync-path").val();
|
||||||
target_user = '';
|
target_user = '';
|
||||||
} else if (target_type == "b2") {
|
} else if (target_type == "b2") {
|
||||||
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
|
target = 'b2://' + $('#backup-target-b2-user').val() + ':' + $('#backup-target-b2-pass').val()
|
||||||
+ '@' + $('#backup-target-b2-bucket').val()
|
+ '@' + $('#backup-target-b2-bucket').val()
|
||||||
target_user = '';
|
target_user = '';
|
||||||
target_pass = '';
|
target_pass = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
var min_age = $("#backup-min-age").val();
|
var min_age = $("#min-age").val();
|
||||||
api(
|
api(
|
||||||
"/system/backup/config",
|
"/system/backup/config",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
target: target,
|
target: target,
|
||||||
target_user: target_user,
|
target_user: target_user,
|
||||||
target_pass: target_pass,
|
target_pass: target_pass,
|
||||||
target_rsync_port: target_port,
|
target_rsync_port: target_port,
|
||||||
min_age: min_age
|
min_age: min_age
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// use .text() --- it's a text response, not html
|
// 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
|
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// use .text() --- it's a text response, not html
|
// use .text() --- it's a text response, not html
|
||||||
show_modal_error("Backup configuration", $("<p/>").text(r));
|
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function init_inputs(target_type) {
|
function init_inputs(target_type) {
|
||||||
function set_host(host, overwrite_other) {
|
function set_host(host) {
|
||||||
if (host !== 'other') {
|
if(host !== 'other') {
|
||||||
$("#backup-target-s3-host").val(host);
|
$("#backup-target-s3-host").val(host);
|
||||||
} else if (overwrite_other) {
|
} else {
|
||||||
$("#backup-target-s3-host").val('');
|
$("#backup-target-s3-host").val('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (target_type == "s3") {
|
if (target_type == "s3") {
|
||||||
$('#backup-target-s3-host-select').off('change').on('change', function () {
|
$('#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());
|
||||||
});
|
});
|
||||||
set_host($('#backup-target-s3-host-select').val(), false);
|
set_host($('#backup-target-s3-host-select').val());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_backup(is_full) {
|
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."
|
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) {
|
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."
|
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", () => {
|
show_modal_confirm("Warning!", disclaimer, "Start Backup", () => {
|
||||||
api(
|
api(
|
||||||
"/system/backup/new",
|
"/system/backup/new",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
full: is_full
|
full: is_full
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// use .text() --- it's a text response, not html
|
// 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
|
show_modal_error("Backup configuration", $("<p/>").text(r), function() { if (r == "OK") show_system_backup(); }); // refresh after modal on success
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// use .text() --- it's a text response, not html
|
// use .text() --- it's a text response, not html
|
||||||
show_modal_error("Backup configuration", $("<p/>").text(r));
|
show_modal_error("Backup configuration", $("<p/>").text(r));
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,81 +1,42 @@
|
||||||
<h2>System Status Checks</h2>
|
<h2>System Status Checks</h2>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#system-checks .message {
|
#system-checks .heading td {
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#system-checks .icon {
|
|
||||||
min-width: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#system-checks .heading {
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 180%;
|
font-size: 120%;
|
||||||
padding-top: 1.75em;
|
padding-top: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#system-checks .heading.first {
|
#system-checks .heading.first td {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error .fas {
|
#system-checks .status-error td {
|
||||||
color: rgb(190, 0, 0);
|
color: rgb(140, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error.sym {
|
#system-checks .status-warning td {
|
||||||
color: rgb(190, 0, 0);
|
color: rgb(170, 120, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error .status-text {
|
#system-checks .status-ok td {
|
||||||
color: rgb(70, 0, 0);
|
color: rgb(0, 140, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warning .fas {
|
#system-checks .status-na td {
|
||||||
color: rgb(191, 150, 0);
|
color: rgb(108, 117, 125);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warning.sym {
|
#system-checks div.extra {
|
||||||
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;
|
display: none;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
|
max-width: 50em;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
#system-checks .showhide {
|
#system-checks .showhide {
|
||||||
display: block;
|
display: none;
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,24 +47,36 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
|
<div>
|
||||||
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
|
<div>
|
||||||
<div>No reboot is necessary.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="system-privacy-setting" style="display: none">
|
<div id="system-reboot-required" style="display: none; margin-bottom: 1em;">
|
||||||
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span>
|
<button type="button" class="btn btn-danger" onclick="confirm_reboot(); return false;">Reboot Box</button>
|
||||||
New-Version Check</a></div>
|
<div>No reboot is necessary.</div>
|
||||||
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of
|
</div>
|
||||||
Mail-in-a-Box.)</small></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="system-checks">
|
<div id="system-privacy-setting" style="display: none">
|
||||||
</div>
|
<div><a onclick="return enable_privacy(!current_privacy_setting)" href="#"><span>Enable/Disable</span>
|
||||||
|
New-Version Check</a></div>
|
||||||
|
<p style="line-height: 125%"><small>(When enabled, status checks phone-home to check for a new release of
|
||||||
|
Mail-in-a-Box.)</small></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> <!-- /col -->
|
||||||
|
<br>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<table id="system-checks" class="table">
|
||||||
|
<thead></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div> <!-- /col -->
|
||||||
|
</div> <!-- /row -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_system_status() {
|
function show_system_status() {
|
||||||
$('#system-checks').html("")
|
$('#system-checks tbody').html("<tr><td class='text-muted'>Loading...</td></tr>")
|
||||||
|
|
||||||
api(
|
api(
|
||||||
"/system/privacy",
|
"/system/privacy",
|
||||||
|
@ -131,38 +104,24 @@
|
||||||
"POST",
|
"POST",
|
||||||
{},
|
{},
|
||||||
function (r) {
|
function (r) {
|
||||||
for (let i = 0; i < r.length; i++) {
|
$('#system-checks tbody').html("");
|
||||||
let n = $("<div class='col-12'><div class='icon'></div><p class='message status-text' style='margin: 0'/>");
|
for (var i = 0; i < r.length; i++) {
|
||||||
|
var n = $("<tr><td class='status'/><td class='message'><p style='margin: 0'/><p class='showhide btn btn-light' href='#'/><div class='extra'></div></tr>");
|
||||||
if (i == 0) n.addClass('first')
|
if (i == 0) n.addClass('first')
|
||||||
|
if (r[i].type == "heading")
|
||||||
if (r[i].type == "heading") {
|
|
||||||
n.addClass(r[i].type)
|
n.addClass(r[i].type)
|
||||||
} else {
|
else
|
||||||
n.addClass("status-" + r[i].type)
|
n.addClass("status-" + r[i].type)
|
||||||
n.find(".icon").addClass("fas")
|
if (r[i].type == "ok") n.find('td.status').text("✔️")
|
||||||
}
|
if (r[i].type == "error") n.find('td.status').text("❌")
|
||||||
|
if (r[i].type == "warning") n.find('td.status').text("⚠️")
|
||||||
if (r[i].type == "ok") n.find(".icon").addClass("fa-check")
|
n.find('td.message p').text(r[i].text)
|
||||||
if (r[i].type == "error") n.find(".icon").addClass("fa-times")
|
$('#system-checks tbody').append(n);
|
||||||
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) {
|
if (r[i].extra.length > 0) {
|
||||||
let open = false
|
n.find('.showhide').show().text("Show More").click(function () {
|
||||||
n.append("<p class='showhide btn btn-light mt-3' href='#'>Show More</p><div class='extra ps-4 col-12'></div>")
|
$(this).hide();
|
||||||
n.find('.showhide').click(function () {
|
$(this).parent().find('.extra').fadeIn();
|
||||||
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;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -172,12 +131,7 @@
|
||||||
var m = $("<div/>").text(r[i].extra[j].text)
|
var m = $("<div/>").text(r[i].extra[j].text)
|
||||||
if (r[i].extra[j].monospace)
|
if (r[i].extra[j].monospace)
|
||||||
m.addClass("pre");
|
m.addClass("pre");
|
||||||
n.find('.extra').append(m);
|
n.find('> td.message > div').append(m);
|
||||||
}
|
|
||||||
|
|
||||||
$('#system-checks').append(n);
|
|
||||||
if (i < r.length - 1) {
|
|
||||||
$('#system-checks').append($("<hr>"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,145 +1,105 @@
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#user_table tr.account_inactive td.address {
|
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
||||||
color: #888;
|
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||||
text-decoration: line-through;
|
#user_table .account_inactive .if_active { display: none; }
|
||||||
}
|
#user_table .account_active .if_inactive { display: none; }
|
||||||
|
#user_table .account_active.if_inactive { display: none; }
|
||||||
#user_table .actions {
|
.row-center { text-align: center; }
|
||||||
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>
|
</style>
|
||||||
|
|
||||||
<h3>Add a mail user</h3>
|
<h3>Add a mail user</h3>
|
||||||
|
|
||||||
<p>Add an email address to this system. This will create a new login username/password.</p>
|
<p>Add an email address to this system. This will create a new login username/password.</p>
|
||||||
|
|
||||||
<form role="form" onsubmit="return do_add_user(); return false;">
|
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
|
||||||
<div class="mb-3">
|
<div class="form-group">
|
||||||
<div class="row g-3">
|
<label class="sr-only" for="adduserEmail">Email address</label>
|
||||||
<div class="col-12 col-lg-6">
|
<input type="email" class="form-control" id="adduserEmail" placeholder="Email Address">
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label class="input-group-text" for="adduserEmail">Email address</label>
|
<div class="form-group">
|
||||||
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail"
|
<label class="sr-only" for="adduserPassword">Password</label>
|
||||||
placeholder="me@example.com">
|
<input type="password" class="form-control" id="adduserPassword" placeholder="Password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
<div class="col-12 col-lg-6">
|
<select class="form-control" id="adduserPrivs">
|
||||||
<div class="input-group">
|
<option value="">Normal User</option>
|
||||||
<label class="input-group-text" for="adduserPassword">Password</label>
|
<option value="admin">Administrator</option>
|
||||||
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword"
|
</select>
|
||||||
placeholder="Password">
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
</div>
|
<label class="sr-only" for="adduserQuota">Quota</label>
|
||||||
</div>
|
<input type="text" class="form-control" id="adduserQuota" placeholder="Quota" style="width:5em;">
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add User</button>
|
||||||
<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>
|
</form>
|
||||||
|
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||||
|
<li>Passwords must be at least eight characters consisting of English letters and numbers only. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
|
||||||
|
<li>Use <a href="#" 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>
|
||||||
|
|
||||||
<h3>Existing mail users</h3>
|
<h3>Existing mail users</h3>
|
||||||
<table id="user_table" class="table col-12">
|
<table id="user_table" class="table" style="width: auto">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th width="35%">Email Address</th>
|
||||||
<th width="35%">Email Address</th>
|
<th class="row-center">Messages</th>
|
||||||
<th class="">Messages</th>
|
<th class="row-center">Size</th>
|
||||||
<th class="">Size</th>
|
<th class="row-center">Used</th>
|
||||||
<th class="">Used</th>
|
<th class="row-center">Quota</th>
|
||||||
<th class="">Quota</th>
|
<th>Actions</th>
|
||||||
<th>Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
</tbody>
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="display: none">
|
<div style="display: none">
|
||||||
<table>
|
<table>
|
||||||
<tr id="user-template">
|
<tr id="user-template">
|
||||||
<td class='address'>
|
<td class='address'>
|
||||||
</td>
|
</td>
|
||||||
<td class="box-count"></td>
|
<td class="box-count row-center"></td>
|
||||||
<td class="box-size"></td>
|
<td class="box-size row-center"></td>
|
||||||
<td class="percent"></td>
|
<td class="percent row-center"></td>
|
||||||
<td class="quota">
|
<td class="quota row-center">
|
||||||
</td>
|
</td>
|
||||||
<td class='actions'>
|
<td class='actions'>
|
||||||
<span class='privs'>
|
<span class='privs'>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="if_active">
|
<span class="if_active">
|
||||||
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
|
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
|
||||||
set quota
|
set quota
|
||||||
</a>
|
</a>
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="if_active">
|
<span class="if_active">
|
||||||
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
|
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
|
||||||
set password
|
set password
|
||||||
</a>
|
</a>
|
||||||
|
|
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class='add-privs'>
|
<span class='add-privs'>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
<a href="#" onclick="users_remove(this); return false;" class='if_active' title="Archive Account">
|
||||||
archive account
|
archive account
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="user-extra-template" class="if_inactive">
|
<tr id="user-extra-template" class="if_inactive">
|
||||||
<td colspan="3" style="border: 0; padding-top: 0">
|
<td colspan="3" style="border: 0; padding-top: 0">
|
||||||
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this
|
<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>
|
||||||
email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Mail user API (advanced)</h3>
|
<h3>Mail user API (advanced)</h3>
|
||||||
|
@ -157,57 +117,35 @@
|
||||||
<h4 style="margin-bottom: 0">Verbs</h4>
|
<h4 style="margin-bottom: 0">Verbs</h4>
|
||||||
|
|
||||||
<table class="table" style="margin-top: .5em">
|
<table class="table" style="margin-top: .5em">
|
||||||
<caption></caption>
|
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||||
<thead>
|
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||||
<th>Verb</th>
|
<tr>
|
||||||
<th>Action</th>
|
<td>POST</td>
|
||||||
<th></th>
|
<td>/add</td>
|
||||||
</thead>
|
<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>GET</b< /td>
|
<tr>
|
||||||
<td><i>(none)</i></td>
|
<td>POST</td>
|
||||||
<td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded
|
<td>/remove</td>
|
||||||
results.</td>
|
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
|
||||||
<td><b>POST</b< /td>
|
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
||||||
<td class="font-monospace">/add</td>
|
<tr>
|
||||||
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional
|
<td>GET</td>
|
||||||
parameters: <code>privilege=admin</code> and <code>quota</code></td>
|
<td>/quota</td>
|
||||||
</tr>
|
<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>
|
<tr>
|
||||||
<td class="font-monospace">/remove</td>
|
<td>POST</td>
|
||||||
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
|
<td>/quota</td>
|
||||||
</tr>
|
<td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</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>
|
</table>
|
||||||
|
|
||||||
<h4>Examples:</h4>
|
<h4>Examples:</h4>
|
||||||
|
|
||||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
<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>
|
||||||
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
|
<pre># Gives a JSON-encoded list of all mail users
|
||||||
curl -X GET https://{{hostname}}/admin/mail/users?format=json
|
curl -X GET https://{{hostname}}/admin/mail/users?format=json
|
||||||
|
@ -226,228 +164,228 @@ curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/us
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_users() {
|
function show_users() {
|
||||||
api(
|
api(
|
||||||
"/system/default-quota",
|
"/system/default-quota",
|
||||||
"GET",
|
"GET",
|
||||||
{},
|
{},
|
||||||
function (r) {
|
function(r) {
|
||||||
$('#adduserQuota').val(r['default-quota']);
|
$('#adduserQuota').val(r['default-quota']);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$('#user_table tbody').html("<tr><td colspan='6' class='text-muted'>Loading...</td></tr>")
|
$('#user_table tbody').html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
||||||
api(
|
api(
|
||||||
"/mail/users",
|
"/mail/users",
|
||||||
"GET",
|
"GET",
|
||||||
{ format: 'json' },
|
{ format: 'json' },
|
||||||
function (r) {
|
function(r) {
|
||||||
$('#user_table tbody').html("");
|
$('#user_table tbody').html("");
|
||||||
for (var i = 0; i < r.length; i++) {
|
for (var i = 0; i < r.length; i++) {
|
||||||
var hdr = $("<tr><th colspan='6' class='bg-light'></th></tr>");
|
var hdr = $("<tr><th colspan='6' style='background-color: #EEE'></th></tr>");
|
||||||
hdr.find('th').text(r[i].domain);
|
hdr.find('th').text(r[i].domain);
|
||||||
$('#user_table tbody').append(hdr);
|
$('#user_table tbody').append(hdr);
|
||||||
|
|
||||||
for (var k = 0; k < r[i].users.length; k++) {
|
for (var k = 0; k < r[i].users.length; k++) {
|
||||||
var user = r[i].users[k];
|
var user = r[i].users[k];
|
||||||
|
|
||||||
var n = $("#user-template").clone();
|
var n = $("#user-template").clone();
|
||||||
var n2 = $("#user-extra-template").clone();
|
var n2 = $("#user-extra-template").clone();
|
||||||
n.attr('id', '');
|
n.attr('id', '');
|
||||||
n2.attr('id', '');
|
n2.attr('id', '');
|
||||||
$('#user_table tbody').append(n);
|
$('#user_table tbody').append(n);
|
||||||
$('#user_table tbody').append(n2);
|
$('#user_table tbody').append(n2);
|
||||||
|
|
||||||
n.addClass("account_" + user.status);
|
n.addClass("account_" + user.status);
|
||||||
n2.addClass("account_" + user.status);
|
n2.addClass("account_" + user.status);
|
||||||
|
|
||||||
n.attr('data-email', user.email);
|
n.attr('data-email', user.email);
|
||||||
n.attr('data-quota', user.quota);
|
n.attr('data-quota', user.quota);
|
||||||
n.find('.address').text(user.email);
|
n.find('.address').text(user.email);
|
||||||
n.find('.box-count').text((user.box_count).toLocaleString('en'));
|
n.find('.box-count').text((user.box_count).toLocaleString('en'));
|
||||||
if (user.box_count == '?') {
|
if (user.box_count == '?') {
|
||||||
n.find('.box-count').attr('title', 'Message count is unkown')
|
n.find('.box-count').attr('title', 'Message count is unkown')
|
||||||
}
|
}
|
||||||
n.find('.box-size').text(user.box_size);
|
n.find('.box-size').text(user.box_size);
|
||||||
if (user.box_size == '?') {
|
if (user.box_size == '?') {
|
||||||
n.find('.box-size').attr('title', 'Mailbox size is unkown')
|
n.find('.box-size').attr('title', 'Mailbox size is unkown')
|
||||||
}
|
}
|
||||||
n.find('.percent').text(user.percent);
|
n.find('.percent').text(user.percent);
|
||||||
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
|
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
|
||||||
n2.find('.restore_info tt').text(user.mailbox);
|
n2.find('.restore_info tt').text(user.mailbox);
|
||||||
|
|
||||||
if (user.status == 'inactive') continue;
|
if (user.status == 'inactive') continue;
|
||||||
|
|
||||||
var add_privs = ["admin"];
|
var add_privs = ["admin"];
|
||||||
|
|
||||||
for (var j = 0; j < user.privileges.length; j++) {
|
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>");
|
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]);
|
p.find('span.name').text(user.privileges[j]);
|
||||||
n.find('.privs').append(p);
|
n.find('.privs').append(p);
|
||||||
if (add_privs.indexOf(user.privileges[j]) >= 0)
|
if (add_privs.indexOf(user.privileges[j]) >= 0)
|
||||||
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
|
add_privs.splice(add_privs.indexOf(user.privileges[j]), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var j = 0; j < add_privs.length; j++) {
|
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>");
|
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]);
|
p.find('span.name').text(add_privs[j]);
|
||||||
n.find('.add-privs').append(p);
|
n.find('.add-privs').append(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function do_add_user() {
|
function do_add_user() {
|
||||||
var email = $("#adduserEmail").val();
|
var email = $("#adduserEmail").val();
|
||||||
var pw = $("#adduserPassword").val();
|
var pw = $("#adduserPassword").val();
|
||||||
var privs = $("#adduserPrivs").val();
|
var privs = $("#adduserPrivs").val();
|
||||||
var quota = $("#adduserQuota").val();
|
var quota = $("#adduserQuota").val();
|
||||||
api(
|
api(
|
||||||
"/mail/users/add",
|
"/mail/users/add",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
password: pw,
|
password: pw,
|
||||||
privileges: privs,
|
privileges: privs,
|
||||||
quota: quota
|
quota: quota
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error("Add User", $("<pre/>").text(r));
|
show_modal_error("Add User", $("<pre/>").text(r));
|
||||||
show_users()
|
show_users()
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error("Add User", r);
|
show_modal_error("Add User", r);
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function users_set_password(elem) {
|
function users_set_password(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
|
|
||||||
var yourpw = "";
|
var yourpw = "";
|
||||||
if (api_credentials != null && email == api_credentials.username)
|
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>";
|
yourpw = "<p class='text-danger'>If you change your own password, you will be logged out of this control panel and will need to log in again.</p>";
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Set Password",
|
"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>"),
|
$("<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",
|
"Set Password",
|
||||||
function () {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/users/password",
|
"/mail/users/password",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
password: $('#users_set_password_pw').val()
|
password: $('#users_set_password_pw').val()
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error("Set Password", $("<pre/>").text(r));
|
show_modal_error("Set Password", $("<pre/>").text(r));
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error("Set Password", r);
|
show_modal_error("Set Password", r);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function users_set_quota(elem) {
|
function users_set_quota(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
var quota = $(elem).parents('tr').attr('data-quota');
|
var quota = $(elem).parents('tr').attr('data-quota');
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Set Quota",
|
"Set Quota",
|
||||||
$("<p>Set quota for <b>" + email + "</b>?</p>" +
|
$("<p>Set quota for <b>" + email + "</b>?</p>" +
|
||||||
"<p>" +
|
"<p>" +
|
||||||
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
|
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
|
||||||
"<input type='text' id='users_set_quota' value='" + quota + "'></p>" +
|
"<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>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>"),
|
"<p><small>For unlimited storage enter 0 (zero)</small></p>"),
|
||||||
"Set Quota",
|
"Set Quota",
|
||||||
function () {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/users/quota",
|
"/mail/users/quota",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
quota: $('#users_set_quota').val()
|
quota: $('#users_set_quota').val()
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_users();
|
show_users();
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error("Set Quota", r);
|
show_modal_error("Set Quota", r);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function users_remove(elem) {
|
function users_remove(elem) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
|
|
||||||
// can't remove yourself
|
// can't remove yourself
|
||||||
if (api_credentials != null && email == api_credentials.username) {
|
if (api_credentials != null && email == api_credentials.username) {
|
||||||
show_modal_error("Archive User", "You cannot archive your own account.");
|
show_modal_error("Archive User", "You cannot archive your own account.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Archive User",
|
"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>"),
|
$("<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",
|
"Archive",
|
||||||
function () {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/users/remove",
|
"/mail/users/remove",
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
email: email
|
email: email
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
// Responses are multiple lines of pre-formatted text.
|
// Responses are multiple lines of pre-formatted text.
|
||||||
show_modal_error("Remove User", $("<pre/>").text(r));
|
show_modal_error("Remove User", $("<pre/>").text(r));
|
||||||
show_users();
|
show_users();
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_modal_error("Remove User", r);
|
show_modal_error("Remove User", r);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mod_priv(elem, add_remove) {
|
function mod_priv(elem, add_remove) {
|
||||||
var email = $(elem).parents('tr').attr('data-email');
|
var email = $(elem).parents('tr').attr('data-email');
|
||||||
var priv = $(elem).parents('td').find('.name').text();
|
var priv = $(elem).parents('td').find('.name').text();
|
||||||
|
|
||||||
// can't remove your own admin access
|
// can't remove your own admin access
|
||||||
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
|
if (priv == "admin" && add_remove == "remove" && api_credentials != null && email == api_credentials.username) {
|
||||||
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
show_modal_error("Modify Privileges", "You cannot remove the admin privilege from yourself.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
var add_remove1 = add_remove.charAt(0).toUpperCase() + add_remove.substring(1);
|
||||||
show_modal_confirm(
|
show_modal_confirm(
|
||||||
"Modify Privileges",
|
"Modify Privileges",
|
||||||
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
|
$("<p>Are you sure you want to " + add_remove + " the " + priv + " privilege for <b>" + email + "</b>?</p>"),
|
||||||
add_remove1,
|
add_remove1,
|
||||||
function () {
|
function() {
|
||||||
api(
|
api(
|
||||||
"/mail/users/privileges/" + add_remove,
|
"/mail/users/privileges/" + add_remove,
|
||||||
"POST",
|
"POST",
|
||||||
{
|
{
|
||||||
email: email,
|
email: email,
|
||||||
privilege: priv
|
privilege: priv
|
||||||
},
|
},
|
||||||
function (r) {
|
function(r) {
|
||||||
show_users();
|
show_users();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function generate_random_password() {
|
function generate_random_password() {
|
||||||
var pw = "";
|
var pw = "";
|
||||||
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
|
||||||
for (var i = 0; i < 12; i++)
|
for (var i = 0; i < 12; i++)
|
||||||
pw += charset.charAt(Math.floor(Math.random() * charset.length));
|
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>");
|
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></p>");
|
||||||
return false; // cancel click
|
return false; // cancel click
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,101 +3,87 @@
|
||||||
|
|
||||||
<h2>Static Web Hosting</h2>
|
<h2>Static Web Hosting</h2>
|
||||||
|
|
||||||
<p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at
|
<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>
|
||||||
all domain names that you set up an email user or alias for.</p>
|
|
||||||
|
|
||||||
<h3>Uploading web files</h3>
|
<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
|
<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>
|
||||||
help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status"
|
<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>
|
||||||
onclick="return show_panel(this);">Status Checks</a> page.</li>
|
|
||||||
|
|
||||||
<li>On your personal computer, install an SSH file transfer program such as <a
|
<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>
|
||||||
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
|
<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>
|
||||||
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.
|
<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>
|
||||||
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>The websites set up on this machine are listed in the table below with where to put the files for each website.</li>
|
||||||
</li>
|
|
||||||
|
|
||||||
<table id="web_domains_existing" class="table col-12" style="margin-bottom: 1em;">
|
<table id="web_domains_existing" class="table" style="margin-bottom: 1em; width: auto;">
|
||||||
<caption></caption>
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Site</th>
|
||||||
<th>Site</th>
|
<th>Directory for Files</th>
|
||||||
<th>Directory for Files</th>
|
<th/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or
|
<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>
|
||||||
<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>
|
</ol>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function show_web() {
|
function show_web() {
|
||||||
api(
|
api(
|
||||||
"/web/domains",
|
"/web/domains",
|
||||||
"GET",
|
"GET",
|
||||||
{
|
{
|
||||||
},
|
},
|
||||||
function (domains) {
|
function(domains) {
|
||||||
var tb = $('#web_domains_existing tbody');
|
var tb = $('#web_domains_existing tbody');
|
||||||
tb.text('');
|
tb.text('');
|
||||||
for (var i = 0; i < domains.length; i++) {
|
for (var i = 0; i < domains.length; i++) {
|
||||||
if (!domains[i].static_enabled) continue;
|
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>");
|
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);
|
tb.append(row);
|
||||||
row.attr('data-domain', domains[i].domain);
|
row.attr('data-domain', domains[i].domain);
|
||||||
row.attr('data-custom-web-root', domains[i].custom_root);
|
row.attr('data-custom-web-root', domains[i].custom_root);
|
||||||
row.find('.domain a').text('https://' + domains[i].domain);
|
row.find('.domain a').text('https://' + domains[i].domain);
|
||||||
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
row.find('.domain a').attr('href', 'https://' + domains[i].domain);
|
||||||
row.find('.directory tt').text(domains[i].root);
|
row.find('.directory tt').text(domains[i].root);
|
||||||
if (domains[i].root != domains[i].custom_root)
|
if (domains[i].root != domains[i].custom_root)
|
||||||
row.find('.change-root').removeClass('hidden');
|
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) {
|
function do_web_update() {
|
||||||
var domain = $(elem).parents('tr').attr('data-domain');
|
api(
|
||||||
var root = $(elem).parents('tr').attr('data-custom-web-root');
|
"/web/update",
|
||||||
show_modal_confirm(
|
"POST",
|
||||||
'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(data) {
|
||||||
function () { do_web_update(); });
|
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>
|
</script>
|
||||||
|
|
|
@ -1,3 +1,16 @@
|
||||||
|
<style>
|
||||||
|
.title {
|
||||||
|
margin: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<h1 class="title">{{hostname}}</h1>
|
<h1 class="title">{{hostname}}</h1>
|
||||||
|
|
||||||
<p class="subtitle">Welcome to your Power Mail-in-a-Box control panel.</p>
|
<p class="subtitle">Welcome to your Mail-in-a-Box control panel.</p>
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,30 @@
|
||||||
<style>
|
<style>
|
||||||
#wkd_table #uids {
|
#wkd_table .status-error {
|
||||||
white-space: pre-line;
|
color: rgb(140, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#wkd_table .domain-header {
|
#wkd_table .status-warning {
|
||||||
margin-top: 0.5em;
|
color: rgb(170, 120, 0);
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#wkd_table .status-ok {
|
||||||
|
color: rgb(0, 140, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#wkd_table .status-none {
|
||||||
|
color: rgb(190, 190, 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
#wkd_table #uids {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template id="wkd-entry-template">
|
<template id="wkd-entry-template">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
|
<td style="vertical-align: middle; white-space: nowrap;" id="email">email@example.com</td>
|
||||||
<td id="key-dropdown" class="dropdown show" style="width: 100%;">
|
<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 class="btn bg-light" style="width: 100%;" data-toggle="dropdown" id="current-key">
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" style="width: 100%;" id="menu">
|
<div class="dropdown-menu" style="width: 100%;" id="menu">
|
||||||
<button class="btn bg-light" style="width:100%;" id="key-none">
|
<button class="btn bg-light" style="width:100%;" id="key-none">
|
||||||
|
@ -22,21 +33,19 @@
|
||||||
<a>Do not serve a key in WKD.</a>
|
<a>Do not serve a key in WKD.</a>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn bg-light" style="width:100%" id="key-sample">
|
<button class="btn bg-light" style="width:100%" id="key-sample">
|
||||||
<div class="row">
|
<div>
|
||||||
<div class="col-6" id="fingerprint">
|
<div style="float: left; padding-right: 20pt;" id="fingerprint">
|
||||||
<b class="font-monospace status-text" id="fpr"></b><span id="subkeys"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div style="float: right; padding-left: 20pt">
|
||||||
<b>
|
<b><a id="sign">S</a>
|
||||||
<span class="sym" id="sign">S</span>
|
<a id="cert">C</a>
|
||||||
<span class="sym" id="cert">C</span>
|
<a id="encr">E</a>
|
||||||
<span class="sym" id="encr">E</span>
|
<a id="auth">A</a></b>,
|
||||||
<span class="sym" id="auth">A</span>
|
<a id="expire"></a>
|
||||||
</b>,
|
</div>
|
||||||
<span id="expire"><span class="status-text"></span></span>
|
<br>
|
||||||
|
<div id="key-emails">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div id="key-emails">
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,8 +68,7 @@
|
||||||
|
|
||||||
<p>WKD (<b>W</b>eb <b>K</b>ey <b>D</b>irectory) is an
|
<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>
|
<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.
|
that allows users to authoratively publish their public PGP keys on the web, via HTTPS.</p>
|
||||||
</p>
|
|
||||||
<p>Unlike other solutions (like public keyservers), WKD has the advantage that the owner
|
<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
|
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>
|
is more certainity that the key actually belongs to it's owner.</p>
|
||||||
|
@ -71,8 +79,7 @@
|
||||||
If you have a separate server to host WKD, you can still use it instead of this box on a per-domain basis.
|
If you have a separate server to host WKD, you can still use it instead of this box on a per-domain basis.
|
||||||
<br>
|
<br>
|
||||||
This box uses the Advanced Method to serve the keys. For example, to host your <code>@some.example.com</code> keys,
|
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
|
you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's records.
|
||||||
records.
|
|
||||||
<br><br>
|
<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
|
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
|
Direct method somewhere else, you can always set the records to an unreachable destination
|
||||||
|
@ -87,22 +94,11 @@
|
||||||
You can add keys on the PGP Keyring Management menu.
|
You can add keys on the PGP Keyring Management menu.
|
||||||
</a>
|
</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%">
|
<table id="wkd_table" class="table" style="width: 100%">
|
||||||
<caption></caption>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email Address</th>
|
<th>Email Address</th>
|
||||||
<th></th>
|
<th>Key</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="wkd_tbody">
|
<tbody id="wkd_tbody">
|
||||||
|
@ -113,8 +109,6 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let dirty_config = {}
|
let dirty_config = {}
|
||||||
let toggleable_lines = []
|
|
||||||
let show_toggleable_lines = document.getElementById("wkd-show-all-entries").checked
|
|
||||||
function pretty_long_id(fpr) {
|
function pretty_long_id(fpr) {
|
||||||
let plid = ""
|
let plid = ""
|
||||||
for (let i = 6; i < 10; ++i) {
|
for (let i = 6; i < 10; ++i) {
|
||||||
|
@ -151,10 +145,8 @@
|
||||||
let keyinfo = keys[key]
|
let keyinfo = keys[key]
|
||||||
let skey = keyinfo.subkeys[0] // Master subkey
|
let skey = keyinfo.subkeys[0] // Master subkey
|
||||||
|
|
||||||
let fpr = option.find("#fingerprint")
|
option.find("#fingerprint").html(`<b>${pretty_long_id(key)}</b> (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`)
|
||||||
fpr.find("#fpr").text(pretty_long_id(key))
|
option.click(()=>{
|
||||||
fpr.find("#subkeys").text(` (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`)
|
|
||||||
option.click(() => {
|
|
||||||
$("#wkd_submit").attr("disabled", false)
|
$("#wkd_submit").attr("disabled", false)
|
||||||
dirty_config[email] = key
|
dirty_config[email] = key
|
||||||
menurep.find("#current-key").html(option.html())
|
menurep.find("#current-key").html(option.html())
|
||||||
|
@ -162,48 +154,46 @@
|
||||||
option.appendTo(menurep.find("#menu"))
|
option.appendTo(menurep.find("#menu"))
|
||||||
|
|
||||||
let classType = ""
|
let classType = ""
|
||||||
let expire = option.find("#expire")
|
let exp = option.find("#expire")
|
||||||
let exp = expire.find(".status-text")
|
|
||||||
|
|
||||||
// Purpose and expiration
|
// Purpose and expiration
|
||||||
// For evaluation we're only going to use the master subkey
|
// For evaluation we're only going to use the master subkey
|
||||||
if (keyinfo.revoked) {
|
if (keyinfo.revoked) {
|
||||||
classType = "status-error"
|
classType = "status-error"
|
||||||
exp.text(`revoked`)
|
exp.html(`revoked`)
|
||||||
} else if (skey.expired) {
|
} else if (skey.expired) {
|
||||||
classType = "status-error"
|
classType = "status-error"
|
||||||
exp.text(`expired on ${skey.expires_date}`)
|
exp.html(`expired on ${skey.expires_date}`)
|
||||||
} else if (skey.expires && skey.expires_days <= 14) {
|
} else if (skey.expires && skey.expires_days <= 14) {
|
||||||
classType = "status-warning"
|
classType = "status-warning"
|
||||||
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
exp.html(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
||||||
} else if (skey.expires) {
|
} else if (skey.expires) {
|
||||||
classType = "status-ok"
|
classType = "status-ok"
|
||||||
exp.text(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
exp.html(`expires in ${skey.expires_days} days (${skey.expires_date})`)
|
||||||
} else {
|
} else {
|
||||||
classType = "status-ok"
|
classType = "status-ok"
|
||||||
exp.text("does not expire")
|
exp.html("does not expire")
|
||||||
}
|
}
|
||||||
expire.addClass(classType)
|
exp.addClass(classType)
|
||||||
fpr.addClass(classType)
|
|
||||||
|
|
||||||
let s = option.find("#sign")
|
let s = option.find("#sign")
|
||||||
let c = option.find("#cert")
|
let c = option.find("#cert")
|
||||||
let e = option.find("#encr")
|
let e = option.find("#encr")
|
||||||
let a = option.find("#auth")
|
let a = option.find("#auth")
|
||||||
|
|
||||||
s.addClass(skey.sign ? classType : "status-na")
|
s.addClass(skey.sign ? classType : "status-none")
|
||||||
c.addClass(skey.cert ? classType : "status-na")
|
c.addClass(skey.cert ? classType : "status-none")
|
||||||
e.addClass(skey.encr ? classType : "status-na")
|
e.addClass(skey.encr ? classType : "status-none")
|
||||||
a.addClass(skey.auth ? classType : "status-na")
|
a.addClass(skey.auth ? classType : "status-none")
|
||||||
|
|
||||||
let txt = ""
|
let txt = ""
|
||||||
let n_emails = keyinfo.ids_emails.length
|
let n_emails = keyinfo.ids_emails.length
|
||||||
if (n_emails == 1) {
|
if (n_emails == 1) {
|
||||||
txt = `<b class="font-monospace">${email}</b>`
|
txt = `<code><b>${email}</b></code>`
|
||||||
} else if (email.length >= 70) {
|
} else if (email.length >= 70) {
|
||||||
// Absurd situation, but can happen!
|
// Absurd situation, but can happen!
|
||||||
// We'll only
|
// We'll only
|
||||||
txt = `<b class="font-monospace">${email}</b> and ${n_emails - 1} other${n_emails == 2 ? "" : "s"}`
|
txt = `<code><b>${email}</b></code> and ${n_emails - 1} other${n_emails == 2 ? "" : "s"}`
|
||||||
} else {
|
} else {
|
||||||
let buf = 70 - email.length
|
let buf = 70 - email.length
|
||||||
let others = n_emails - 1
|
let others = n_emails - 1
|
||||||
|
@ -215,7 +205,7 @@
|
||||||
return x.length == y.length ? x.localeCompare(y) : x.length > y.length ? -1 : 1
|
return x.length == y.length ? x.localeCompare(y) : x.length > y.length ? -1 : 1
|
||||||
})
|
})
|
||||||
|
|
||||||
txt = `<b class="font-monospace">${email}</b>`
|
txt = `<code><b>${email}</b></code>`
|
||||||
|
|
||||||
// From the remaining emails, pick the longest ones within the buffer constraints
|
// From the remaining emails, pick the longest ones within the buffer constraints
|
||||||
// If none, append the "and x others text"
|
// If none, append the "and x others text"
|
||||||
|
@ -223,7 +213,7 @@
|
||||||
if (e.length <= buf) {
|
if (e.length <= buf) {
|
||||||
others--
|
others--
|
||||||
buf -= e.length
|
buf -= e.length
|
||||||
txt += `, <span class="font-monospace">${e}</span>`
|
txt += `, <code>${e}</code>`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -236,7 +226,7 @@
|
||||||
template.remove()
|
template.remove()
|
||||||
|
|
||||||
let nokeyopt = menurep.find("#key-none")
|
let nokeyopt = menurep.find("#key-none")
|
||||||
nokeyopt.click(() => {
|
nokeyopt.click(()=>{
|
||||||
$("#wkd_submit").attr("disabled", false)
|
$("#wkd_submit").attr("disabled", false)
|
||||||
dirty_config[email] = null
|
dirty_config[email] = null
|
||||||
menurep.find("#current-key").html(nokeyopt.html())
|
menurep.find("#current-key").html(nokeyopt.html())
|
||||||
|
@ -249,23 +239,14 @@
|
||||||
function generate_wkd_dropdowns(wkd_info) {
|
function generate_wkd_dropdowns(wkd_info) {
|
||||||
$("#wkd_tbody").html("")
|
$("#wkd_tbody").html("")
|
||||||
Object.keys(wkd_info.wkd).forEach(domain => {
|
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")
|
$(`<tr><td colspan="3"><h4>${domain}</h4></td></tr>`).appendTo("#wkd_tbody")
|
||||||
Object.keys(wkd_info.wkd[domain]).forEach(email => {
|
Object.keys(wkd_info.wkd[domain]).forEach(email => {
|
||||||
let menu = generate_wkd_menu(email, wkd_info.wkd[domain][email], wkd_info.keys)
|
generate_wkd_menu(email, wkd_info.wkd[domain][email], wkd_info.keys).appendTo("#wkd_tbody")
|
||||||
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() {
|
function show_wkd() {
|
||||||
toggleable_lines = []
|
$("#wkd_tbody").html("<tr><td class='text-muted'>Loading...</td></tr>")
|
||||||
$("#wkd_tbody").html("<tr><td colspan='2' class='text-muted'>Loading...</td></tr>")
|
|
||||||
api(
|
api(
|
||||||
"/system/pgp/wkd",
|
"/system/pgp/wkd",
|
||||||
"GET",
|
"GET",
|
||||||
|
@ -288,19 +269,11 @@
|
||||||
"POST",
|
"POST",
|
||||||
dirty_config,
|
dirty_config,
|
||||||
(r) => {
|
(r) => {
|
||||||
show_modal_error("WKD Management", $("<p/>").text(r), () => { if (r == "OK") show_wkd() })
|
show_modal_error("WKD Management", $("<p/>").text(r), () => {if (r == "OK") show_wkd()})
|
||||||
},
|
},
|
||||||
(r) => {
|
(r) => {
|
||||||
show_modal_error("WKD Management", $("<p/>").text(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>
|
</script>
|
||||||
|
|
|
@ -6,229 +6,207 @@ import os.path
|
||||||
|
|
||||||
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
|
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
|
||||||
|
|
||||||
|
|
||||||
def load_environment():
|
def load_environment():
|
||||||
# Load settings from /etc/mailinabox.conf.
|
# Load settings from /etc/mailinabox.conf.
|
||||||
return load_env_vars_from_file("/etc/mailinabox.conf")
|
return load_env_vars_from_file("/etc/mailinabox.conf")
|
||||||
|
|
||||||
|
|
||||||
def load_env_vars_from_file(fn):
|
def load_env_vars_from_file(fn):
|
||||||
# Load settings from a KEY=VALUE file.
|
# Load settings from a KEY=VALUE file.
|
||||||
import collections
|
import collections
|
||||||
env = collections.OrderedDict()
|
env = collections.OrderedDict()
|
||||||
for line in open(fn):
|
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
|
||||||
env.setdefault(*line.strip().split("=", 1))
|
return env
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def save_environment(env):
|
def save_environment(env):
|
||||||
with open("/etc/mailinabox.conf", "w") as f:
|
with open("/etc/mailinabox.conf", "w") as f:
|
||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
f.write("%s=%s\n" % (k, v))
|
f.write("%s=%s\n" % (k, v))
|
||||||
|
|
||||||
|
|
||||||
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
||||||
|
|
||||||
|
|
||||||
def write_settings(config, env):
|
def write_settings(config, env):
|
||||||
import rtyaml
|
import rtyaml
|
||||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||||
with open(fn, "w") as f:
|
with open(fn, "w") as f:
|
||||||
f.write(rtyaml.dump(config))
|
f.write(rtyaml.dump(config))
|
||||||
|
|
||||||
|
|
||||||
def load_settings(env):
|
def load_settings(env):
|
||||||
import rtyaml
|
import rtyaml
|
||||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||||
try:
|
try:
|
||||||
config = rtyaml.load(open(fn, "r"))
|
config = rtyaml.load(open(fn, "r"))
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict): raise ValueError() # caught below
|
||||||
raise ValueError() # caught below
|
return config
|
||||||
return config
|
except:
|
||||||
except:
|
return { }
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# UTILITIES
|
# UTILITIES
|
||||||
|
|
||||||
|
|
||||||
def safe_domain_name(name):
|
def safe_domain_name(name):
|
||||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
return urllib.parse.quote(name, safe='')
|
return urllib.parse.quote(name, safe='')
|
||||||
|
|
||||||
|
|
||||||
def sort_domains(domain_names, env):
|
def sort_domains(domain_names, env):
|
||||||
# Put domain names in a nice sorted order.
|
# Put domain names in a nice sorted order.
|
||||||
|
|
||||||
# The nice order will group domain names by DNS zone, i.e. the top-most
|
# 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
|
# 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
|
# 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
|
# from shortest to longest since zones are always shorter than their
|
||||||
# subdomains.
|
# subdomains.
|
||||||
zones = {}
|
zones = { }
|
||||||
for domain in sorted(domain_names, key=lambda d: len(d)):
|
for domain in sorted(domain_names, key=lambda d : len(d)):
|
||||||
for z in zones.values():
|
for z in zones.values():
|
||||||
if domain.endswith("." + z):
|
if domain.endswith("." + z):
|
||||||
# We found a parent domain already in the list.
|
# We found a parent domain already in the list.
|
||||||
zones[domain] = z
|
zones[domain] = z
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# 'break' did not occur: there is no parent domain, so it is its
|
# 'break' did not occur: there is no parent domain, so it is its
|
||||||
# own zone.
|
# own zone.
|
||||||
zones[domain] = domain
|
zones[domain] = domain
|
||||||
|
|
||||||
# Sort the zones.
|
# Sort the zones.
|
||||||
zone_domains = sorted(
|
zone_domains = sorted(zones.values(),
|
||||||
zones.values(),
|
key = lambda d : (
|
||||||
key=lambda d: (
|
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
||||||
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
|
||||||
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].
|
|
||||||
endswith("." + d)),
|
|
||||||
|
|
||||||
# Then just dumb lexicographically.
|
# Then just dumb lexicographically.
|
||||||
d,
|
d,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Now sort the domain names that fall within each zone.
|
# Now sort the domain names that fall within each zone.
|
||||||
domain_names = sorted(
|
domain_names = sorted(domain_names,
|
||||||
domain_names,
|
key = lambda d : (
|
||||||
key=lambda d: (
|
# First by zone.
|
||||||
# First by zone.
|
zone_domains.index(zones[d]),
|
||||||
zone_domains.index(zones[d]),
|
|
||||||
|
|
||||||
# PRIMARY_HOSTNAME is always first within the zone that contains it.
|
# PRIMARY_HOSTNAME is always first within the zone that contains it.
|
||||||
d != env['PRIMARY_HOSTNAME'],
|
d != env['PRIMARY_HOSTNAME'],
|
||||||
|
|
||||||
# Followed by any of its subdomains.
|
# Followed by any of its subdomains.
|
||||||
not d.endswith("." + env['PRIMARY_HOSTNAME']),
|
not d.endswith("." + env['PRIMARY_HOSTNAME']),
|
||||||
|
|
||||||
# Then in right-to-left lexicographic order of the .-separated parts of the name.
|
# Then in right-to-left lexicographic order of the .-separated parts of the name.
|
||||||
list(reversed(d.split("."))),
|
list(reversed(d.split("."))),
|
||||||
))
|
))
|
||||||
|
|
||||||
return domain_names
|
|
||||||
|
|
||||||
|
return domain_names
|
||||||
|
|
||||||
def sort_email_addresses(email_addresses, env):
|
def sort_email_addresses(email_addresses, env):
|
||||||
email_addresses = set(email_addresses)
|
email_addresses = set(email_addresses)
|
||||||
domains = set(
|
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||||
email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
ret = []
|
||||||
ret = []
|
for domain in sort_domains(domains, env):
|
||||||
for domain in sort_domains(domains, env):
|
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain))
|
||||||
domain_emails = set(email for email in email_addresses
|
ret.extend(sorted(domain_emails))
|
||||||
if email.endswith("@" + domain))
|
email_addresses -= domain_emails
|
||||||
ret.extend(sorted(domain_emails))
|
ret.extend(sorted(email_addresses)) # whatever is left
|
||||||
email_addresses -= domain_emails
|
return ret
|
||||||
ret.extend(sorted(email_addresses)) # whatever is left
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
|
||||||
|
# A safe way to execute processes.
|
||||||
|
# Some processes like apt-get require being given a sane PATH.
|
||||||
|
import subprocess
|
||||||
|
|
||||||
def shell(method,
|
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
|
||||||
cmd_args,
|
kwargs = {
|
||||||
env={},
|
'env': env,
|
||||||
capture_stderr=False,
|
'stderr': None if not capture_stderr else subprocess.STDOUT,
|
||||||
return_bytes=False,
|
}
|
||||||
trap=False,
|
if method == "check_output" and input is not None:
|
||||||
input=None):
|
kwargs['input'] = input
|
||||||
# A safe way to execute processes.
|
|
||||||
# Some processes like apt-get require being given a sane PATH.
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
env.update({"PATH": "/sbin:/bin:/usr/sbin:/usr/bin"})
|
|
||||||
kwargs = {
|
|
||||||
'env': env,
|
|
||||||
'stderr': None if not capture_stderr else subprocess.STDOUT,
|
|
||||||
}
|
|
||||||
if method == "check_output" and input is not None:
|
|
||||||
kwargs['input'] = input
|
|
||||||
|
|
||||||
if not trap:
|
|
||||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
|
||||||
code = 0
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
ret = e.output
|
|
||||||
code = e.returncode
|
|
||||||
if not return_bytes and isinstance(ret, bytes):
|
|
||||||
ret = ret.decode("utf8")
|
|
||||||
if not trap:
|
|
||||||
return ret
|
|
||||||
else:
|
|
||||||
return code, ret
|
|
||||||
|
|
||||||
|
if not trap:
|
||||||
|
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ret = getattr(subprocess, method)(cmd_args, **kwargs)
|
||||||
|
code = 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
ret = e.output
|
||||||
|
code = e.returncode
|
||||||
|
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
|
||||||
|
if not trap:
|
||||||
|
return ret
|
||||||
|
else:
|
||||||
|
return code, ret
|
||||||
|
|
||||||
def create_syslog_handler():
|
def create_syslog_handler():
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||||
handler.setLevel(logging.WARNING)
|
handler.setLevel(logging.WARNING)
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def du(path):
|
def du(path):
|
||||||
# Computes the size of all files in the path, like the `du` command.
|
# Computes the size of all files in the path, like the `du` command.
|
||||||
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
||||||
# soft and hard links.
|
# soft and hard links.
|
||||||
total_size = 0
|
total_size = 0
|
||||||
seen = set()
|
seen = set()
|
||||||
for dirpath, dirnames, filenames in os.walk(path):
|
for dirpath, dirnames, filenames in os.walk(path):
|
||||||
for f in filenames:
|
for f in filenames:
|
||||||
fp = os.path.join(dirpath, f)
|
fp = os.path.join(dirpath, f)
|
||||||
try:
|
try:
|
||||||
stat = os.lstat(fp)
|
stat = os.lstat(fp)
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if stat.st_ino in seen:
|
if stat.st_ino in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(stat.st_ino)
|
seen.add(stat.st_ino)
|
||||||
total_size += stat.st_size
|
total_size += stat.st_size
|
||||||
return total_size
|
return total_size
|
||||||
|
|
||||||
|
|
||||||
def wait_for_service(port, public, env, timeout):
|
def wait_for_service(port, public, env, timeout):
|
||||||
# Block until a service on a given port (bound privately or publicly)
|
# Block until a service on a given port (bound privately or publicly)
|
||||||
# is taking connections, with a maximum timeout.
|
# is taking connections, with a maximum timeout.
|
||||||
import socket
|
import socket, time
|
||||||
import time
|
start = time.perf_counter()
|
||||||
start = time.perf_counter()
|
while True:
|
||||||
while True:
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s.settimeout(timeout/3)
|
||||||
s.settimeout(timeout / 3)
|
try:
|
||||||
try:
|
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
|
||||||
s.connect(("127.0.0.1" if not public else env['PUBLIC_IP'], port))
|
return True
|
||||||
return True
|
except OSError:
|
||||||
except OSError:
|
if time.perf_counter() > start+timeout:
|
||||||
if time.perf_counter() > start + timeout:
|
return False
|
||||||
return False
|
time.sleep(min(timeout/4, 1))
|
||||||
time.sleep(min(timeout / 4, 1))
|
|
||||||
|
def fix_boto():
|
||||||
|
# Google Compute Engine instances install some Python-2-only boto plugins that
|
||||||
|
# conflict with boto running under Python 3. Disable boto's default configuration
|
||||||
|
# file prior to importing boto so that GCE's plugin is not loaded:
|
||||||
|
import os
|
||||||
|
os.environ["BOTO_CONFIG"] = "/etc/boto3.cfg"
|
||||||
|
|
||||||
def get_php_version():
|
def get_php_version():
|
||||||
# Gets the version of PHP installed in the system.
|
# Gets the version of PHP installed in the system.
|
||||||
return shell("check_output", ["/usr/bin/php", "-v"])[4:7]
|
return shell("check_output", ["/usr/bin/php", "-v"])[4:7]
|
||||||
|
|
||||||
|
os_codes = {None, "Debian10", "Ubuntu2004"}
|
||||||
|
|
||||||
def get_os_code():
|
def get_os_code():
|
||||||
# Massive mess incoming
|
# Massive mess incoming
|
||||||
dist = shell("check_output", ["/usr/bin/lsb_release", "-is"]).strip()
|
dist = shell("check_output", ["/usr/bin/lsb_release", "-is"]).strip()
|
||||||
version = shell("check_output", ["/usr/bin/lsb_release", "-rs"]).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"
|
||||||
|
|
||||||
if dist == "Debian":
|
return None
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
from web_update import get_web_domains
|
from web_update import get_web_domains
|
||||||
env = load_environment()
|
env = load_environment()
|
||||||
domains = get_web_domains(env)
|
domains = get_web_domains(env)
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
print(domain)
|
print(domain)
|
||||||
|
|
|
@ -2,20 +2,14 @@
|
||||||
# domains for which a mail account has been set up.
|
# domains for which a mail account has been set up.
|
||||||
########################################################################
|
########################################################################
|
||||||
|
|
||||||
import os.path
|
import os.path, re, rtyaml
|
||||||
import re
|
|
||||||
import rtyaml
|
|
||||||
|
|
||||||
from mailconfig import get_mail_domains
|
from mailconfig import get_mail_domains
|
||||||
from dns_update import get_custom_dns_config, get_dns_zones
|
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 ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
|
||||||
from utils import shell, safe_domain_name, sort_domains, get_php_version
|
from utils import shell, safe_domain_name, sort_domains, get_php_version
|
||||||
|
|
||||||
|
def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_dns_elsewhere=True):
|
||||||
def get_web_domains(env,
|
|
||||||
include_www_redirects=True,
|
|
||||||
include_auto=True,
|
|
||||||
exclude_dns_elsewhere=True):
|
|
||||||
# What domains should we serve HTTP(S) for?
|
# What domains should we serve HTTP(S) for?
|
||||||
domains = set()
|
domains = set()
|
||||||
|
|
||||||
|
@ -34,20 +28,14 @@ def get_web_domains(env,
|
||||||
# Add Autoconfiguration domains for domains that there are user accounts at:
|
# Add Autoconfiguration domains for domains that there are user accounts at:
|
||||||
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||||
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
|
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
|
||||||
domains |= set(
|
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
'autoconfig.' + maildomain
|
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||||
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.
|
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
|
||||||
domains |= set('mta-sts.' + maildomain
|
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
|
||||||
for maildomain in get_mail_domains(env))
|
|
||||||
|
|
||||||
# 'openpgpkey.' for WKD support
|
# 'openpgpkey.' for WKD support
|
||||||
domains |= set('openpgpkey.' + maildomain
|
domains |= set('openpgpkey.' + maildomain for maildomain in get_mail_domains(env))
|
||||||
for maildomain in get_mail_domains(env))
|
|
||||||
|
|
||||||
if exclude_dns_elsewhere:
|
if exclude_dns_elsewhere:
|
||||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||||
|
@ -64,41 +52,34 @@ def get_web_domains(env,
|
||||||
|
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
def get_domains_with_a_records(env):
|
def get_domains_with_a_records(env):
|
||||||
domains = set()
|
domains = set()
|
||||||
dns = get_custom_dns_config(env)
|
dns = get_custom_dns_config(env)
|
||||||
for domain, rtype, value, ttl in dns:
|
for domain, rtype, value, ttl in dns:
|
||||||
if rtype == "CNAME" or (rtype in ("A", "AAAA")
|
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])):
|
||||||
and value not in ("local", env['PUBLIC_IP'])):
|
|
||||||
domains.add(domain)
|
domains.add(domain)
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
def get_web_domains_with_root_overrides(env):
|
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 '/',
|
# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
||||||
# which means static hosting is not happening.
|
# which means static hosting is not happening.
|
||||||
root_overrides = {}
|
root_overrides = { }
|
||||||
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
nginx_conf_custom_fn = os.path.join(env["STORAGE_ROOT"], "www/custom.yaml")
|
||||||
if os.path.exists(nginx_conf_custom_fn):
|
if os.path.exists(nginx_conf_custom_fn):
|
||||||
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
||||||
for domain, settings in custom_settings.items():
|
for domain, settings in custom_settings.items():
|
||||||
for type, value in [('redirect', settings.get('redirects',
|
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
|
||||||
{}).get('/')),
|
('proxy', settings.get('proxies', {}).get('/'))]:
|
||||||
('proxy', settings.get('proxies',
|
|
||||||
{}).get('/'))]:
|
|
||||||
if value:
|
if value:
|
||||||
root_overrides[domain] = (type, value)
|
root_overrides[domain] = (type, value)
|
||||||
return root_overrides
|
return root_overrides
|
||||||
|
|
||||||
|
|
||||||
DOMAIN_EXTERNAL = -1
|
DOMAIN_EXTERNAL = -1
|
||||||
DOMAIN_PRIMARY = 1
|
DOMAIN_PRIMARY = 1
|
||||||
DOMAIN_WWW = 2
|
DOMAIN_WWW = 2
|
||||||
DOMAIN_REDIRECT = 4
|
DOMAIN_REDIRECT = 4
|
||||||
DOMAIN_WKD = 8
|
DOMAIN_WKD = 8
|
||||||
|
|
||||||
|
|
||||||
def get_web_domain_flags(env):
|
def get_web_domain_flags(env):
|
||||||
flags = dict()
|
flags = dict()
|
||||||
zones = get_dns_zones(env)
|
zones = get_dns_zones(env)
|
||||||
|
@ -127,24 +108,19 @@ def get_web_domain_flags(env):
|
||||||
# Last check for websites hosted elsewhere
|
# Last check for websites hosted elsewhere
|
||||||
for d in flags.keys():
|
for d in flags.keys():
|
||||||
if d in external:
|
if d in external:
|
||||||
# -1 = All bits set to 1, assuming twos-complement
|
flags[d] = DOMAIN_EXTERNAL # -1 = All bits set to 1, assuming twos-complement
|
||||||
flags[d] = DOMAIN_EXTERNAL
|
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
|
|
||||||
def do_web_update(env):
|
def do_web_update(env):
|
||||||
# Pre-load what SSL certificates we will use for each domain.
|
# Pre-load what SSL certificates we will use for each domain.
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
# Build an nginx configuration file.
|
# Build an nginx configuration file.
|
||||||
nginx_conf = open(
|
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||||
os.path.join(os.path.dirname(__file__),
|
|
||||||
"../conf/nginx-top.conf")).read()
|
|
||||||
nginx_conf = re.sub("{{phpver}}", get_php_version(), nginx_conf)
|
nginx_conf = re.sub("{{phpver}}", get_php_version(), nginx_conf)
|
||||||
|
|
||||||
# Add upstream additions
|
# Add upstream additions
|
||||||
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www",
|
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www", ".upstream.conf")
|
||||||
".upstream.conf")
|
|
||||||
if not os.path.exists(nginx_upstream_include):
|
if not os.path.exists(nginx_upstream_include):
|
||||||
with open(nginx_upstream_include, "a+") as f:
|
with open(nginx_upstream_include, "a+") as f:
|
||||||
f.writelines([
|
f.writelines([
|
||||||
|
@ -157,29 +133,20 @@ def do_web_update(env):
|
||||||
nginx_conf += "\ninclude %s;\n" % (nginx_upstream_include)
|
nginx_conf += "\ninclude %s;\n" % (nginx_upstream_include)
|
||||||
|
|
||||||
# Load the templates.
|
# Load the templates.
|
||||||
template0 = open(
|
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||||
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()
|
||||||
template1 = open(
|
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
||||||
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"
|
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||||
template4 = open(
|
template4 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-openpgpkey.conf")).read()
|
||||||
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.
|
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||||
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'],
|
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)
|
||||||
[template0, template1, template2],
|
|
||||||
ssl_certificates, env)
|
|
||||||
|
|
||||||
# Add configuration all other web domains.
|
# Add configuration all other web domains.
|
||||||
pairs = list(get_web_domain_flags(env).items())
|
pairs = list(get_web_domain_flags(env).items())
|
||||||
|
|
||||||
# Sort the domains in some way to keep ordering consistency. Keep domains and subdomains together.
|
# 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])
|
pairs.sort(reverse = False, key = lambda x: x[0][::-1])
|
||||||
for domain, flags in pairs:
|
for domain, flags in pairs:
|
||||||
if flags & DOMAIN_PRIMARY == DOMAIN_PRIMARY or flags == DOMAIN_EXTERNAL:
|
if flags & DOMAIN_PRIMARY == DOMAIN_PRIMARY or flags == DOMAIN_EXTERNAL:
|
||||||
# PRIMARY_HOSTNAME is handled above.
|
# PRIMARY_HOSTNAME is handled above.
|
||||||
|
@ -187,20 +154,14 @@ def do_web_update(env):
|
||||||
if flags & DOMAIN_WWW == 0:
|
if flags & DOMAIN_WWW == 0:
|
||||||
# This is a regular domain.
|
# This is a regular domain.
|
||||||
if flags & DOMAIN_WKD == DOMAIN_WKD:
|
if flags & DOMAIN_WKD == DOMAIN_WKD:
|
||||||
nginx_conf += make_domain_config(
|
nginx_conf += make_domain_config(domain, [template0, template1, template4], ssl_certificates, env)
|
||||||
domain, [template0, template1, template4],
|
|
||||||
ssl_certificates, env)
|
|
||||||
elif flags & DOMAIN_REDIRECT == 0:
|
elif flags & DOMAIN_REDIRECT == 0:
|
||||||
nginx_conf += make_domain_config(domain,
|
nginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env)
|
||||||
[template0, template1],
|
|
||||||
ssl_certificates, env)
|
|
||||||
else:
|
else:
|
||||||
nginx_conf += make_domain_config(domain, [template0],
|
nginx_conf += make_domain_config(domain, [template0], ssl_certificates, env)
|
||||||
ssl_certificates, env)
|
|
||||||
else:
|
else:
|
||||||
# Add default 'www.' redirect.
|
# Add default 'www.' redirect.
|
||||||
nginx_conf += make_domain_config(domain, [template0, template3],
|
nginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env)
|
||||||
ssl_certificates, env)
|
|
||||||
|
|
||||||
# Did the file change? If not, don't bother writing & restarting nginx.
|
# Did the file change? If not, don't bother writing & restarting nginx.
|
||||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||||
|
@ -221,7 +182,6 @@ def do_web_update(env):
|
||||||
|
|
||||||
return "web updated\n"
|
return "web updated\n"
|
||||||
|
|
||||||
|
|
||||||
def make_domain_config(domain, templates, ssl_certificates, env):
|
def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
# GET SOME VARIABLES
|
# GET SOME VARIABLES
|
||||||
|
|
||||||
|
@ -246,9 +206,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
finally:
|
finally:
|
||||||
f.close()
|
f.close()
|
||||||
return sha1.hexdigest()
|
return sha1.hexdigest()
|
||||||
|
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||||
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(
|
|
||||||
tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
|
||||||
|
|
||||||
# Add in any user customizations in YAML format.
|
# Add in any user customizations in YAML format.
|
||||||
hsts = "yes"
|
hsts = "yes"
|
||||||
|
@ -293,8 +251,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
nginx_conf_extra += "\n\t\talias %s;" % alias
|
nginx_conf_extra += "\n\t\talias %s;" % alias
|
||||||
nginx_conf_extra += "\n\t}\n"
|
nginx_conf_extra += "\n\t}\n"
|
||||||
for path, url in yaml.get("redirects", {}).items():
|
for path, url in yaml.get("redirects", {}).items():
|
||||||
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path,
|
nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path, url)
|
||||||
url)
|
|
||||||
|
|
||||||
# override the HSTS directive type
|
# override the HSTS directive type
|
||||||
hsts = yaml.get("hsts", hsts)
|
hsts = yaml.get("hsts", hsts)
|
||||||
|
@ -306,9 +263,7 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n"
|
nginx_conf_extra += "\tadd_header Strict-Transport-Security \"max-age=15768000; includeSubDomains; preload\" always;\n"
|
||||||
|
|
||||||
# Add in any user customizations in the includes/ folder.
|
# Add in any user customizations in the includes/ folder.
|
||||||
nginx_conf_custom_include = os.path.join(
|
nginx_conf_custom_include = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf")
|
||||||
env["STORAGE_ROOT"], "www",
|
|
||||||
safe_domain_name(domain) + ".conf")
|
|
||||||
if not os.path.exists(nginx_conf_custom_include):
|
if not os.path.exists(nginx_conf_custom_include):
|
||||||
with open(nginx_conf_custom_include, "a+") as f:
|
with open(nginx_conf_custom_include, "a+") as f:
|
||||||
f.writelines([
|
f.writelines([
|
||||||
|
@ -325,75 +280,57 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
||||||
# of the previous template.
|
# of the previous template.
|
||||||
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
|
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
|
||||||
for t in templates + [nginx_conf_extra]:
|
for t in templates + [nginx_conf_extra]:
|
||||||
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t,
|
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
|
||||||
nginx_conf)
|
|
||||||
|
|
||||||
# Replace substitution strings in the template & return.
|
# Replace substitution strings in the template & return.
|
||||||
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
||||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
||||||
nginx_conf = nginx_conf.replace("$ROOT", root)
|
nginx_conf = nginx_conf.replace("$ROOT", root)
|
||||||
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
|
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
|
||||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE",
|
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"])
|
||||||
tls_cert["certificate"])
|
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
|
||||||
nginx_conf = nginx_conf.replace(
|
|
||||||
"$REDIRECT_DOMAIN",
|
|
||||||
re.sub(r"^www\.", "",
|
|
||||||
domain)) # for default www redirects to parent domain
|
|
||||||
|
|
||||||
return nginx_conf
|
return nginx_conf
|
||||||
|
|
||||||
|
|
||||||
def get_web_root(domain, env, test_exists=True):
|
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.
|
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
||||||
for test_domain in (domain, 'default'):
|
for test_domain in (domain, 'default'):
|
||||||
root = os.path.join(env["STORAGE_ROOT"], "www",
|
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
||||||
safe_domain_name(test_domain))
|
if os.path.exists(root) or not test_exists: break
|
||||||
if os.path.exists(root) or not test_exists:
|
|
||||||
break
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def is_default_web_root(domain, env):
|
def is_default_web_root(domain, env):
|
||||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain))
|
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain))
|
||||||
return not os.path.exists(root)
|
return not os.path.exists(root)
|
||||||
|
|
||||||
|
|
||||||
def get_web_domains_info(env):
|
def get_web_domains_info(env):
|
||||||
www_redirects = set(get_web_domains(env)) - \
|
www_redirects = set(get_web_domains(env)) - set(get_web_domains(env, include_www_redirects=False))
|
||||||
set(get_web_domains(env, include_www_redirects=False))
|
|
||||||
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))
|
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env))
|
||||||
ssl_certificates = get_ssl_certificates(env)
|
ssl_certificates = get_ssl_certificates(env)
|
||||||
|
|
||||||
# for the SSL config panel, get cert status
|
# for the SSL config panel, get cert status
|
||||||
def check_cert(domain):
|
def check_cert(domain):
|
||||||
try:
|
try:
|
||||||
tls_cert = get_domain_ssl_files(domain,
|
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
||||||
ssl_certificates,
|
except OSError: # PRIMARY_HOSTNAME cert is missing
|
||||||
env,
|
|
||||||
allow_missing_cert=True)
|
|
||||||
except OSError: # PRIMARY_HOSTNAME cert is missing
|
|
||||||
tls_cert = None
|
tls_cert = None
|
||||||
if tls_cert is None:
|
if tls_cert is None: return ("danger", "No certificate installed.")
|
||||||
return ("danger", "No certificate installed.")
|
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||||
cert_status, cert_status_details = check_certificate(
|
|
||||||
domain, tls_cert["certificate"], tls_cert["private-key"])
|
|
||||||
if cert_status == "OK":
|
if cert_status == "OK":
|
||||||
return ("success", "Signed & valid. " + cert_status_details)
|
return ("success", "Signed & valid. " + cert_status_details)
|
||||||
elif cert_status == "SELF-SIGNED":
|
elif cert_status == "SELF-SIGNED":
|
||||||
return ("warning",
|
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||||
"Self-signed. Get a signed certificate to stop warnings.")
|
|
||||||
else:
|
else:
|
||||||
return ("danger", "Certificate has a problem: " + cert_status)
|
return ("danger", "Certificate has a problem: " + cert_status)
|
||||||
|
|
||||||
return [{
|
return [
|
||||||
"domain":
|
{
|
||||||
domain,
|
"domain": domain,
|
||||||
"root":
|
"root": get_web_root(domain, env),
|
||||||
get_web_root(domain, env),
|
"custom_root": get_web_root(domain, env, test_exists=False),
|
||||||
"custom_root":
|
"ssl_certificate": check_cert(domain),
|
||||||
get_web_root(domain, env, test_exists=False),
|
"static_enabled": domain not in (www_redirects | has_root_proxy_or_redirect),
|
||||||
"ssl_certificate":
|
}
|
||||||
check_cert(domain),
|
for domain in get_web_domains(env)
|
||||||
"static_enabled":
|
]
|
||||||
domain not in (www_redirects | has_root_proxy_or_redirect),
|
|
||||||
} for domain in get_web_domains(env)]
|
|
||||||
|
|
|
@ -2,14 +2,7 @@
|
||||||
# WDK (Web Key Directory) Manager: Facilitates discovery of keys by third-parties
|
# 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
|
# Current relevant documents: https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html
|
||||||
|
|
||||||
import pgp
|
import pgp, utils, rtyaml, mailconfig, copy, shutil, os, re
|
||||||
import utils
|
|
||||||
import rtyaml
|
|
||||||
import mailconfig
|
|
||||||
import copy
|
|
||||||
import shutil
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
@ -17,36 +10,32 @@ env = utils.load_environment()
|
||||||
|
|
||||||
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
|
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
|
||||||
|
|
||||||
|
|
||||||
class WKDError(Exception):
|
class WKDError(Exception):
|
||||||
"""
|
"""
|
||||||
Errors specifically related to WKD.
|
Errors specifically related to WKD.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, msg):
|
def __init__(self, msg):
|
||||||
self.message = msg
|
self.message = msg
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.message
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
def sha1(message):
|
def sha1(message):
|
||||||
h = hashes.Hash(hashes.SHA1(), default_backend())
|
h = hashes.Hash(hashes.SHA1(), default_backend())
|
||||||
h.update(message)
|
h.update(message)
|
||||||
return h.finalize()
|
return h.finalize()
|
||||||
|
|
||||||
|
|
||||||
def zbase32(digest):
|
def zbase32(digest):
|
||||||
# Crudely check if all quintets are complete
|
# Crudely check if all quintets are complete
|
||||||
if len(digest) % 5 != 0:
|
if len(digest) % 5 != 0:
|
||||||
raise ValueError("Digest cannot have incomplete chunks of 40 bits!")
|
raise ValueError("Digest cannot have incomplete chunks of 40 bits!")
|
||||||
base = "ybndrfg8ejkmcpqxot1uwisza345h769"
|
base = "ybndrfg8ejkmcpqxot1uwisza345h769"
|
||||||
encoded = ""
|
encoded = ""
|
||||||
for i in range(0, len(digest), 5):
|
for i in range(0, len(digest), 5):
|
||||||
chunk = int.from_bytes(digest[i:i + 5], byteorder="big")
|
chunk = int.from_bytes(digest[i:i+5], byteorder="big")
|
||||||
for j in range(35, -5, -5):
|
for j in range(35, -5, -5):
|
||||||
encoded += base[(chunk >> j) & 31]
|
encoded += base[(chunk >> j) & 31]
|
||||||
return encoded
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
# Strips and exports a key so that only the UID's with the provided email remain.
|
# Strips and exports a key so that only the UID's with the provided email remain.
|
||||||
|
@ -62,13 +51,17 @@ def zbase32(digest):
|
||||||
# by the pgp.fork_context() decorator.
|
# by the pgp.fork_context() decorator.
|
||||||
@pgp.fork_context
|
@pgp.fork_context
|
||||||
def strip_and_export(fpr, target_email, buffer=None, context=None):
|
def strip_and_export(fpr, target_email, buffer=None, context=None):
|
||||||
context.armor = False # We need to disable armor output for this key
|
context.armor = False # We need to disable armor output for this key
|
||||||
k = pgp.get_key(fpr, context)
|
k = pgp.get_key(fpr, context)
|
||||||
if k is None:
|
if k is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
|
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
|
||||||
statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
|
statusref = {
|
||||||
|
"seq_read": False,
|
||||||
|
"sequence": [],
|
||||||
|
"seq_number": -1
|
||||||
|
}
|
||||||
|
|
||||||
def parse_key_dump(dump):
|
def parse_key_dump(dump):
|
||||||
UID_REGEX = r".*:.* <(.*)>:.*:([0-9]),.*"
|
UID_REGEX = r".*:.* <(.*)>:.*:([0-9]),.*"
|
||||||
|
@ -92,12 +85,13 @@ def strip_and_export(fpr, target_email, buffer=None, context=None):
|
||||||
statusref["sequence"] += ["deluid", "save"]
|
statusref["sequence"] += ["deluid", "save"]
|
||||||
statusref["seq_read"] = True
|
statusref["seq_read"] = True
|
||||||
|
|
||||||
|
|
||||||
def interaction(request, prompt):
|
def interaction(request, prompt):
|
||||||
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
|
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
|
||||||
return 0
|
return 0
|
||||||
elif request == "GET_BOOL":
|
elif request == "GET_BOOL":
|
||||||
# No way to confirm interactively, so we just say yes
|
# 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
|
return "y" # Yeah, I'd also rather just return True but that doesn't work
|
||||||
elif request == "GET_LINE" and prompt == "keyedit.prompt":
|
elif request == "GET_LINE" and prompt == "keyedit.prompt":
|
||||||
if not statusref["seq_read"]:
|
if not statusref["seq_read"]:
|
||||||
buffer.seek(0, os.SEEK_SET)
|
buffer.seek(0, os.SEEK_SET)
|
||||||
|
@ -114,7 +108,6 @@ def strip_and_export(fpr, target_email, buffer=None, context=None):
|
||||||
context.interact(k, interaction, sink=buffer)
|
context.interact(k, interaction, sink=buffer)
|
||||||
return pgp.export_key(fpr, context)
|
return pgp.export_key(fpr, context)
|
||||||
|
|
||||||
|
|
||||||
def email_compatible_with_key(email, fingerprint):
|
def email_compatible_with_key(email, fingerprint):
|
||||||
# 1. Does the user exist?
|
# 1. Does the user exist?
|
||||||
if not email in mailconfig.get_all_mail_addresses(env):
|
if not email in mailconfig.get_all_mail_addresses(env):
|
||||||
|
@ -128,18 +121,13 @@ def email_compatible_with_key(email, fingerprint):
|
||||||
|
|
||||||
# 3. Does the key have a user id with the email of the user?
|
# 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]:
|
if email not in [u.email for u in key.uids]:
|
||||||
raise WKDError(
|
raise WKDError(f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!")
|
||||||
f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!"
|
|
||||||
)
|
|
||||||
|
|
||||||
return key
|
return key
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Gets a table with all the keys that can be served for each user and/or alias
|
# Gets a table with all the keys that can be served for each user and/or alias
|
||||||
|
|
||||||
|
|
||||||
def get_user_fpr_maps():
|
def get_user_fpr_maps():
|
||||||
uk_maps = {}
|
uk_maps = {}
|
||||||
for email in mailconfig.get_all_mail_addresses(env):
|
for email in mailconfig.get_all_mail_addresses(env):
|
||||||
|
@ -153,10 +141,7 @@ def get_user_fpr_maps():
|
||||||
pass
|
pass
|
||||||
return uk_maps
|
return uk_maps
|
||||||
|
|
||||||
|
|
||||||
# Gets the current WKD configuration
|
# Gets the current WKD configuration
|
||||||
|
|
||||||
|
|
||||||
def get_wkd_config():
|
def get_wkd_config():
|
||||||
# Test
|
# Test
|
||||||
try:
|
try:
|
||||||
|
@ -174,12 +159,9 @@ def get_wkd_config():
|
||||||
except:
|
except:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
|
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
|
||||||
# email: An user or alias on this box. e.g. "administrator@example.com"
|
# 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"
|
# fingerprint: The fingerprint of the key we want to bind it to. e.g "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||||
|
|
||||||
|
|
||||||
def update_wkd_config(config_sample):
|
def update_wkd_config(config_sample):
|
||||||
config = dict(config_sample)
|
config = dict(config_sample)
|
||||||
for email, fingerprint in config_sample.items():
|
for email, fingerprint in config_sample.items():
|
||||||
|
@ -195,11 +177,8 @@ def update_wkd_config(config_sample):
|
||||||
with open(wkdpath, "w") as wkdfile:
|
with open(wkdpath, "w") as wkdfile:
|
||||||
wkdfile.write(rtyaml.dump(config))
|
wkdfile.write(rtyaml.dump(config))
|
||||||
|
|
||||||
|
|
||||||
# Looks for incompatible email/key pairs on the WKD configuration file
|
# Looks for incompatible email/key pairs on the WKD configuration file
|
||||||
# and returns the uid indexes for compatible email/key pairs
|
# and returns the uid indexes for compatible email/key pairs
|
||||||
|
|
||||||
|
|
||||||
def parse_wkd_list():
|
def parse_wkd_list():
|
||||||
removed = []
|
removed = []
|
||||||
uidlist = []
|
uidlist = []
|
||||||
|
@ -219,8 +198,7 @@ def parse_wkd_list():
|
||||||
key = email_compatible_with_key(u, k)
|
key = email_compatible_with_key(u, k)
|
||||||
# Key is compatible
|
# Key is compatible
|
||||||
|
|
||||||
# Swap with the full-length fingerprint (if somehow this was changed by hand)
|
writeable[u] = key.fpr # Swap with the full-length fingerprint (if somehow this was changed by hand)
|
||||||
writeable[u] = key.fpr
|
|
||||||
uidlist.append((u, key.fpr))
|
uidlist.append((u, key.fpr))
|
||||||
except:
|
except:
|
||||||
writeable.pop(u)
|
writeable.pop(u)
|
||||||
|
@ -230,10 +208,8 @@ def parse_wkd_list():
|
||||||
wkdfile.write(rtyaml.dump(writeable))
|
wkdfile.write(rtyaml.dump(writeable))
|
||||||
return (removed, uidlist)
|
return (removed, uidlist)
|
||||||
|
|
||||||
|
|
||||||
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
|
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
|
||||||
|
|
||||||
|
|
||||||
def build_wkd():
|
def build_wkd():
|
||||||
# Clean everything
|
# Clean everything
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
from daemon import app
|
|
||||||
import auth, utils
|
|
||||||
|
|
||||||
app.logger.addHandler(utils.create_syslog_handler())
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(port=10222)
|
|
13
security.md
13
security.md
|
@ -3,12 +3,7 @@ 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.
|
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.
|
This page documents the security features of Mail-in-a-Box. The term “box” is used below to mean a configured Mail-in-a-Box.
|
||||||
|
|
||||||
Reporting Security Vulnerabilities
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
Security vulnerabilities should be reported to the [project's maintainer](https://joshdata.me) via email.
|
|
||||||
|
|
||||||
Threat Model
|
Threat Model
|
||||||
------------
|
------------
|
||||||
|
@ -54,7 +49,9 @@ Additionally:
|
||||||
|
|
||||||
### Password Storage
|
### Password Storage
|
||||||
|
|
||||||
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py)) Password changes (as well as changes to control panel two-factor authentication settings) expire any control panel login sessions.
|
The passwords for mail users are stored on disk using the [SHA512-CRYPT](http://man7.org/linux/man-pages/man3/crypt.3.html) hashing scheme. ([source](management/mailconfig.py))
|
||||||
|
|
||||||
|
When using the web-based administrative control panel, after logging in an API key is placed in the browser's local storage (rather than, say, the user's actual password). The API key is an HMAC based on the user's email address and current password, and it is keyed by a secret known only to the control panel service. By resetting an administrator's password, any HMACs previously generated for that user will expire.
|
||||||
|
|
||||||
### Console access
|
### Console access
|
||||||
|
|
||||||
|
@ -68,7 +65,7 @@ If DNSSEC is enabled at the box's domain name's registrar, the SSHFP record that
|
||||||
|
|
||||||
`fail2ban` provides some protection from brute-force login attacks (repeated logins that guess account passwords) by blocking offending IP addresses at the network level.
|
`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).
|
The following services are protected: SSH, IMAP (dovecot), SMTP submission (postfix), webmail (roundcube), Nextcloud/CalDAV/CardDAV (over HTTP), and the Mail-in-a-Box control panel & munin (over HTTP).
|
||||||
|
|
||||||
Some other services running on the box may be missing fail2ban filters.
|
Some other services running on the box may be missing fail2ban filters.
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
#########################################################
|
#########################################################
|
||||||
# This script is intended to be run like this:
|
# This script is intended to be run like this:
|
||||||
#
|
#
|
||||||
# curl -L https://power-mailinabox.net/setup.sh | sudo bash
|
# curl https://dvn.pt/power-miab | sudo bash
|
||||||
#
|
#
|
||||||
#########################################################
|
#########################################################
|
||||||
|
|
||||||
|
@ -20,7 +20,6 @@ if [ ! -f /usr/bin/lsb_release ]; then
|
||||||
echo "* Debian 10 (buster)"
|
echo "* Debian 10 (buster)"
|
||||||
echo "* Debian 11 (bullseye)"
|
echo "* Debian 11 (bullseye)"
|
||||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -32,44 +31,16 @@ fi
|
||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
# Make sure we're running on the correct operating system
|
# Make sure we're running on the correct operating system
|
||||||
OS=$(lsb_release -d | sed 's/.*:\s*//')
|
OS=$(lsb_release -d | sed 's/.*:\s*//')
|
||||||
if [ "$OS" == "Debian GNU/Linux 11 (bullseye)" ] ||
|
if [ "$OS" == "Debian GNU/Linux 10 (buster)" ] ||
|
||||||
[ "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ] ||
|
[ "$OS" == "Debian GNU/Linux 11 (bullseye)" ] ||
|
||||||
[ "$(echo $OS | grep -o 'Ubuntu 22.04')" == "Ubuntu 22.04" ]
|
[ "$(echo $OS | grep -o 'Ubuntu 20.04')" == "Ubuntu 20.04" ]
|
||||||
then
|
then
|
||||||
TAG=v60.5
|
TAG=v55.0
|
||||||
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
|
else
|
||||||
echo "This script must be run on a system running one of the following OS-es:"
|
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 "* Debian 11 (bullseye)"
|
||||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
@ -86,7 +57,7 @@ if [ ! -d $HOME/mailinabox ]; then
|
||||||
echo Downloading Mail-in-a-Box $TAG. . .
|
echo Downloading Mail-in-a-Box $TAG. . .
|
||||||
git clone \
|
git clone \
|
||||||
-b $TAG --depth 1 \
|
-b $TAG --depth 1 \
|
||||||
https://git.nibbletools.com/beenull/power-mailinabox \
|
https://github.com/ddavness/power-mailinabox \
|
||||||
$HOME/mailinabox \
|
$HOME/mailinabox \
|
||||||
< /dev/null 2> /dev/null
|
< /dev/null 2> /dev/null
|
||||||
|
|
||||||
|
|
44
setup/dns.sh
44
setup/dns.sh
|
@ -10,9 +10,16 @@
|
||||||
source setup/functions.sh # load our functions
|
source setup/functions.sh # load our functions
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
|
# Install the packages.
|
||||||
|
#
|
||||||
|
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
||||||
|
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
||||||
|
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
||||||
|
echo "Installing nsd (DNS server)..."
|
||||||
|
apt_install ldnsutils openssh-client
|
||||||
|
|
||||||
# Prepare nsd's configuration.
|
# Prepare nsd's configuration.
|
||||||
# We configure nsd before installation as we only want it to bind to some addresses
|
|
||||||
# and it otherwise will have port / bind conflicts with bind9 used as the local resolver
|
|
||||||
mkdir -p /var/run/nsd
|
mkdir -p /var/run/nsd
|
||||||
mkdir -p /etc/nsd
|
mkdir -p /etc/nsd
|
||||||
mkdir -p /etc/nsd/zones
|
mkdir -p /etc/nsd/zones
|
||||||
|
@ -39,6 +46,18 @@ server:
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Add log rotation
|
||||||
|
cat > /etc/logrotate.d/nsd <<EOF;
|
||||||
|
/var/log/nsd.log {
|
||||||
|
weekly
|
||||||
|
missingok
|
||||||
|
rotate 12
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
# Since we have bind9 listening on localhost for locally-generated
|
# Since we have bind9 listening on localhost for locally-generated
|
||||||
# DNS queries that require a recursive nameserver, and the system
|
# DNS queries that require a recursive nameserver, and the system
|
||||||
# might have other network interfaces for e.g. tunnelling, we have
|
# might have other network interfaces for e.g. tunnelling, we have
|
||||||
|
@ -55,25 +74,8 @@ echo "include: /etc/nsd/nsd.conf.d/*.conf" >> /etc/nsd/nsd.conf;
|
||||||
# now be stored in /etc/nsd/nsd.conf.d.
|
# now be stored in /etc/nsd/nsd.conf.d.
|
||||||
rm -f /etc/nsd/zones.conf
|
rm -f /etc/nsd/zones.conf
|
||||||
|
|
||||||
# Add log rotation
|
# Attempting a late install of nsd (after configuration)
|
||||||
cat > /etc/logrotate.d/nsd <<EOF;
|
apt_install nsd
|
||||||
/var/log/nsd.log {
|
|
||||||
weekly
|
|
||||||
missingok
|
|
||||||
rotate 12
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
notifempty
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Install the packages.
|
|
||||||
#
|
|
||||||
# * nsd: The non-recursive nameserver that publishes our DNS records.
|
|
||||||
# * ldnsutils: Helper utilities for signing DNSSEC zones.
|
|
||||||
# * openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
|
|
||||||
echo "Installing nsd (DNS server)..."
|
|
||||||
apt_install nsd ldnsutils openssh-client
|
|
||||||
|
|
||||||
# Create DNSSEC signing keys.
|
# Create DNSSEC signing keys.
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ function hide_output {
|
||||||
# Execute command, redirecting stderr/stdout to the temporary file. Since we
|
# Execute command, redirecting stderr/stdout to the temporary file. Since we
|
||||||
# check the return code ourselves, disable 'set -e' temporarily.
|
# check the return code ourselves, disable 'set -e' temporarily.
|
||||||
set +e
|
set +e
|
||||||
"$@" &> "$OUTPUT"
|
"$@" &> $OUTPUT
|
||||||
E=$?
|
E=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ function hide_output {
|
||||||
echo
|
echo
|
||||||
echo FAILED: "$@"
|
echo FAILED: "$@"
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
cat "$OUTPUT"
|
cat $OUTPUT
|
||||||
echo -----------------------------------------
|
echo -----------------------------------------
|
||||||
exit $E
|
exit $E
|
||||||
fi
|
fi
|
||||||
|
@ -222,18 +222,17 @@ function git_clone {
|
||||||
}
|
}
|
||||||
|
|
||||||
function php_version {
|
function php_version {
|
||||||
php --version | head -n 1 | cut -d " " -f 2 | cut -d "." -f 1,2
|
php --version | head -n 1 | cut -d " " -f 2 | cut -c 1-3
|
||||||
}
|
}
|
||||||
|
|
||||||
function python_version {
|
function python_version {
|
||||||
python3 --version | cut -d " " -f 2 | cut -d "." -f 1,2
|
python3 --version | cut -d " " -f 2 | cut -c 1-3
|
||||||
}
|
}
|
||||||
|
|
||||||
export OS_UNSUPPORTED=0
|
export OS_UNSUPPORTED=0
|
||||||
export OS_DEBIAN_10=1
|
export OS_DEBIAN_10=1
|
||||||
export OS_UBUNTU_2004=2
|
export OS_UBUNTU_2004=2
|
||||||
export OS_DEBIAN_11=3
|
export OS_DEBIAN_11=3
|
||||||
export OS_UBUNTU_2204=4
|
|
||||||
|
|
||||||
function get_os_code {
|
function get_os_code {
|
||||||
# A lot of if-statements here - dirty code looking tasting today
|
# A lot of if-statements here - dirty code looking tasting today
|
||||||
|
@ -252,11 +251,8 @@ function get_os_code {
|
||||||
if [[ $VER == "20.04" ]]; then
|
if [[ $VER == "20.04" ]]; then
|
||||||
echo $OS_UBUNTU_2004
|
echo $OS_UBUNTU_2004
|
||||||
return 0
|
return 0
|
||||||
elif [[ $VER == "22.04" ]]; then
|
|
||||||
echo $OS_UBUNTU_2204
|
|
||||||
return 0
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo $OS_UNSUPPORTED
|
echo $OS_UNSUPPORTED
|
||||||
}
|
}
|
|
@ -89,8 +89,6 @@ management/editconf.py /etc/dovecot/conf.d/10-ssl.conf \
|
||||||
"ssl_min_protocol=TLSv1.2" \
|
"ssl_min_protocol=TLSv1.2" \
|
||||||
"ssl_cipher_list=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384" \
|
"ssl_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_prefer_server_ciphers=no" \
|
||||||
"ssl_dh_parameters_length=2048" \
|
|
||||||
"ssl_dh=<$STORAGE_ROOT/ssl/dh2048.pem"
|
|
||||||
|
|
||||||
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
# Disable in-the-clear IMAP/POP because there is no reason for a user to transmit
|
||||||
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
# login credentials outside of an encrypted connection. Only the over-TLS versions
|
||||||
|
@ -187,7 +185,6 @@ plugin {
|
||||||
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
sieve = $STORAGE_ROOT/mail/sieve/%d/%n.sieve
|
||||||
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
sieve_dir = $STORAGE_ROOT/mail/sieve/%d/%n
|
||||||
sieve_redirect_envelope_from = recipient
|
sieve_redirect_envelope_from = recipient
|
||||||
sieve_vacation_send_from_recipient = yes
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
# destinations according to aliases, and passses email on to
|
# destinations according to aliases, and passses email on to
|
||||||
# another service for local mail delivery.
|
# another service for local mail delivery.
|
||||||
#
|
#
|
||||||
# The first hop in local mail delivery is to spampd via
|
# The first hop in local mail delivery is to Spamassassin via
|
||||||
# LMTP. spampd then passes mail over to Dovecot for
|
# LMTP. Spamassassin then passes mail over to Dovecot for
|
||||||
# storage in the user's mailbox.
|
# storage in the user's mailbox.
|
||||||
#
|
#
|
||||||
# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for
|
# Postfix also listens on ports 465/587 (SMTPS, SMTP+STARTLS) for
|
||||||
|
@ -42,7 +42,7 @@ source /etc/mailinabox.conf # load global vars
|
||||||
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
# * `ca-certificates`: A trust store used to squelch postfix warnings about
|
||||||
# untrusted opportunistically-encrypted connections.
|
# untrusted opportunistically-encrypted connections.
|
||||||
echo "Installing Postfix (SMTP server)..."
|
echo "Installing Postfix (SMTP server)..."
|
||||||
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates libsasl2-modules
|
apt_install postfix postfix-sqlite postfix-pcre postgrey ca-certificates
|
||||||
|
|
||||||
# ### Basic Settings
|
# ### Basic Settings
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ management/editconf.py /etc/postfix/main.cf \
|
||||||
smtp_bind_address=$PRIVATE_IP \
|
smtp_bind_address=$PRIVATE_IP \
|
||||||
smtp_bind_address6=$PRIVATE_IPV6 \
|
smtp_bind_address6=$PRIVATE_IPV6 \
|
||||||
myhostname=$PRIMARY_HOSTNAME\
|
myhostname=$PRIMARY_HOSTNAME\
|
||||||
smtpd_banner="\$myhostname ESMTP Power Mail-in-a-Box (Postfix)" \
|
smtpd_banner="\$myhostname ESMTP Hi, I'm a Power Mail-in-a-Box (Debian/Postfix)" \
|
||||||
mydestination=localhost
|
mydestination=localhost
|
||||||
|
|
||||||
# Tweak some queue settings:
|
# Tweak some queue settings:
|
||||||
|
@ -122,7 +122,7 @@ sed -i "s/PUBLIC_IP/$PUBLIC_IP/" /etc/postfix/outgoing_mail_header_filters
|
||||||
# the world are very far behind and if we disable too much, they may not be able to use TLS and
|
# 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
|
# 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.
|
# 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
|
# 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
|
||||||
management/editconf.py /etc/postfix/main.cf \
|
management/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_tls_security_level=may\
|
smtpd_tls_security_level=may\
|
||||||
smtpd_tls_auth_only=yes \
|
smtpd_tls_auth_only=yes \
|
||||||
|
@ -132,8 +132,8 @@ management/editconf.py /etc/postfix/main.cf \
|
||||||
smtpd_tls_protocols="!SSLv2,!SSLv3" \
|
smtpd_tls_protocols="!SSLv2,!SSLv3" \
|
||||||
smtpd_tls_ciphers=medium \
|
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 \
|
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 \
|
smtpd_tls_exclude_ciphers=aNULL,RC4 \
|
||||||
tls_preempt_cipherlist=yes \
|
tls_preempt_cipherlist=no \
|
||||||
smtpd_tls_received_header=yes
|
smtpd_tls_received_header=yes
|
||||||
|
|
||||||
# For ports 465/587 (via the 'mandatory' settings):
|
# For ports 465/587 (via the 'mandatory' settings):
|
||||||
|
@ -193,17 +193,17 @@ management/editconf.py /etc/postfix/main.cf \
|
||||||
|
|
||||||
# ### Incoming Mail
|
# ### Incoming Mail
|
||||||
|
|
||||||
# Pass mail to spampd, which acts as the local delivery agent (LDA),
|
# Pass any incoming mail over to a local delivery agent. Spamassassin
|
||||||
# which then passes the mail over to the Dovecot LMTP server after.
|
# will act as the LDA agent at first. It is listening on port 10025
|
||||||
# spampd runs on port 10025 by default.
|
# with LMTP. Spamassassin will pass the mail over to Dovecot after.
|
||||||
#
|
#
|
||||||
# In a basic setup we would pass mail directly to Dovecot by setting
|
# In a basic setup we would pass mail directly to Dovecot by setting
|
||||||
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
# virtual_transport to `lmtp:unix:private/dovecot-lmtp`.
|
||||||
management/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
|
# Because of a spampd bug, limit the number of recipients in each connection.
|
||||||
# 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.
|
# See https://github.com/mail-in-a-box/mailinabox/issues/1523.
|
||||||
management/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
|
management/editconf.py /etc/postfix/main.cf lmtp_destination_recipient_limit=1
|
||||||
|
|
||||||
|
|
||||||
# Who can send mail to us? Some basic filters.
|
# Who can send mail to us? Some basic filters.
|
||||||
#
|
#
|
||||||
|
@ -232,32 +232,11 @@ management/editconf.py /etc/postfix/main.cf \
|
||||||
# As a matter of fact RFC is not strict about retry timer so postfix and
|
# As a matter of fact RFC is not strict about retry timer so postfix and
|
||||||
# other MTA have their own intervals. To fix the problem of receiving
|
# other MTA have their own intervals. To fix the problem of receiving
|
||||||
# e-mails really latter, delay of greylisting has been set to
|
# e-mails really latter, delay of greylisting has been set to
|
||||||
# 180 seconds (default is 300 seconds). We will move the postgrey database
|
# 180 seconds (default is 300 seconds).
|
||||||
# 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 \
|
management/editconf.py /etc/default/postgrey \
|
||||||
POSTGREY_OPTS=\""--inet=127.0.0.1:10023 --delay=180 --dbdir=$STORAGE_ROOT/mail/postgrey/db"\"
|
POSTGREY_OPTS=\"'--inet=127.0.0.1:10023 --delay=180'\"
|
||||||
|
|
||||||
|
|
||||||
# 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
|
# 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;
|
cat > /etc/cron.daily/mailinabox-postgrey-whitelist << EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
|
@ -25,20 +25,10 @@ if [ ! -f $db_path ]; then
|
||||||
echo "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);" | 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 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;
|
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
|
elif sqlite3 $db_path ".schema users" | grep --invert-match quota; then
|
||||||
sql=$(sqlite3 $db_path "SELECT sql FROM sqlite_master WHERE name = 'users'");
|
echo "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';" | sqlite3 $db_path;
|
||||||
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
|
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
|
# ### User Authentication
|
||||||
|
|
||||||
# Have Dovecot query our database, and not system users, for authentication.
|
# Have Dovecot query our database, and not system users, for authentication.
|
||||||
|
@ -168,7 +158,7 @@ EOF
|
||||||
|
|
||||||
# SQL statement to check if we're sending to a noreply address.
|
# SQL statement to check if we're sending to a noreply address.
|
||||||
cat > /etc/postfix/noreply-addresses.cf << EOF;
|
cat > /etc/postfix/noreply-addresses.cf << EOF;
|
||||||
dbpath=$db_path
|
dbpath=/home/user-data/mail/users.sqlite
|
||||||
query = SELECT 'REJECT This address is not ready to receive email.' FROM noreply WHERE email='%s'
|
query = SELECT 'REJECT This address is not ready to receive email.' FROM noreply WHERE email='%s'
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
|
@ -1,30 +1,35 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
source setup/functions.sh
|
source setup/functions.sh
|
||||||
source /etc/mailinabox.conf # load global vars
|
|
||||||
|
|
||||||
echo "Installing Mail-in-a-Box system management daemon..."
|
echo "Installing Mail-in-a-Box system management daemon..."
|
||||||
|
|
||||||
# DEPENDENCIES
|
# DEPENDENCIES
|
||||||
|
|
||||||
|
# We used to install management daemon-related Python packages
|
||||||
|
# directly to /usr/local/lib. We moved to a virtualenv because
|
||||||
|
# these packages might conflict with apt-installed packages.
|
||||||
|
# We may have a lingering version of acme that conflcits with
|
||||||
|
# certbot, which we're about to install below, so remove it
|
||||||
|
# first. Once acme is installed by an apt package, this might
|
||||||
|
# break the package version and `apt-get install --reinstall python3-acme`
|
||||||
|
# might be needed in that case.
|
||||||
|
while [ -d /usr/local/lib/python3.4/dist-packages/acme ]; do
|
||||||
|
pip3 uninstall -y acme;
|
||||||
|
done
|
||||||
|
|
||||||
|
# duplicity is used to make backups of user data.
|
||||||
|
#
|
||||||
# virtualenv is used to isolate the Python 3 packages we
|
# virtualenv is used to isolate the Python 3 packages we
|
||||||
# install via pip from the system-installed packages.
|
# install via pip from the system-installed packages.
|
||||||
#
|
#
|
||||||
# certbot installs EFF's certbot which we use to
|
# certbot installs EFF's certbot which we use to
|
||||||
# provision free TLS certificates.
|
# provision free TLS certificates.
|
||||||
#
|
apt_install duplicity python3-pip python3-gpg virtualenv certbot
|
||||||
# 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
|
# boto is used for amazon aws backups.
|
||||||
|
|
||||||
# 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
|
# Both are installed outside the pipenv, so they can be used by duplicity
|
||||||
hide_output pip3 install --upgrade b2sdk boto3 duplicity
|
hide_output pip3 install --upgrade boto
|
||||||
|
|
||||||
# Create a virtualenv for the installation of Python 3 packages
|
# Create a virtualenv for the installation of Python 3 packages
|
||||||
# used by the management daemon.
|
# used by the management daemon.
|
||||||
|
@ -33,15 +38,6 @@ mkdir -p $inst_dir
|
||||||
venv=$inst_dir/env
|
venv=$inst_dir/env
|
||||||
if [ ! -d $venv ]; then
|
if [ ! -d $venv ]; then
|
||||||
hide_output virtualenv -ppython3 $venv
|
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
|
fi
|
||||||
|
|
||||||
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
# Upgrade pip because the Ubuntu-packaged version is out of date.
|
||||||
|
@ -52,17 +48,32 @@ hide_output $venv/bin/pip install --upgrade pip
|
||||||
# NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
|
# NOTE: email_validator is repeated in setup/questions.sh, so please keep the versions synced.
|
||||||
hide_output $venv/bin/pip install --upgrade \
|
hide_output $venv/bin/pip install --upgrade \
|
||||||
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
||||||
flask dnspython python-dateutil expiringdict gunicorn \
|
flask dnspython python-dateutil expiringdict \
|
||||||
qrcode[pil] pyotp \
|
qrcode[pil] pyotp \
|
||||||
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver boto3 b2sdk
|
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
|
||||||
|
|
||||||
|
# Install backblaze B2 libraries.
|
||||||
|
# Depending on the OS, Duplicity may require different dependencies.
|
||||||
|
case $(get_os_code) in
|
||||||
|
|
||||||
|
$OS_DEBIAN_10)
|
||||||
|
apt_install python-pip
|
||||||
|
hide_output pip2 install --upgrade "b2<2.0.0"
|
||||||
|
hide_output $venv/bin/pip install --upgrade "b2<2.0.0"
|
||||||
|
;;
|
||||||
|
|
||||||
|
$OS_UBUNTU_2004 | $OS_DEBIAN_11)
|
||||||
|
hide_output pip3 install --upgrade "b2sdk==1.7.0"
|
||||||
|
hide_output $venv/bin/pip install --upgrade "b2sdk==1.7.0"
|
||||||
|
;;
|
||||||
|
|
||||||
|
esac
|
||||||
|
|
||||||
# Make the venv use the packaged gpgme bindings (the ones pip provides are severely out-of-date)
|
# 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
|
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/
|
ln -s /usr/lib/python3/dist-packages/gpg/ $venv/lib/python$(python_version)/site-packages/
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo $(get_os_code) > $venv/.oscode
|
|
||||||
|
|
||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
|
|
||||||
# Create a backup directory and a random key for encrypting backups.
|
# Create a backup directory and a random key for encrypting backups.
|
||||||
|
@ -80,39 +91,34 @@ rm -rf $assets_dir
|
||||||
mkdir -p $assets_dir
|
mkdir -p $assets_dir
|
||||||
|
|
||||||
# jQuery CDN URL
|
# jQuery CDN URL
|
||||||
jquery_version=3.6.1
|
jquery_version=3.6.0
|
||||||
jquery_url=https://code.jquery.com # Check this link for new versions
|
jquery_url=https://code.jquery.com
|
||||||
|
|
||||||
# Get jQuery
|
# Get jQuery
|
||||||
wget_verify $jquery_url/jquery-$jquery_version.min.js ea61688671d0c3044f2c5b2f2c4af0a6620ac6c2 $assets_dir/jquery.min.js
|
wget_verify $jquery_url/jquery-$jquery_version.min.js b82d238d4e31fdf618bae8ac11a6c812c03dd0d4 $assets_dir/jquery.min.js
|
||||||
|
|
||||||
# Bootstrap CDN URL
|
# Bootstrap CDN URL
|
||||||
# See https://github.com/twbs/bootstrap/releases to check for new versions
|
bootstrap_version=4.6.0
|
||||||
bootstrap_version=5.2.2
|
|
||||||
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
|
bootstrap_url=https://github.com/twbs/bootstrap/releases/download/v$bootstrap_version/bootstrap-$bootstrap_version-dist.zip
|
||||||
|
|
||||||
# Get Bootstrap
|
# Get Bootstrap
|
||||||
wget_verify $bootstrap_url 740b34c22cef5c2f12a34f084b813ea308fedf74 /tmp/bootstrap.zip
|
wget_verify $bootstrap_url a1d385dc33cb415512d2f38215a554c4380dac2d /tmp/bootstrap.zip
|
||||||
unzip -q /tmp/bootstrap.zip -d $assets_dir
|
unzip -q /tmp/bootstrap.zip -d $assets_dir
|
||||||
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
|
mv $assets_dir/bootstrap-$bootstrap_version-dist $assets_dir/bootstrap
|
||||||
rm -f /tmp/bootstrap.zip
|
rm -f /tmp/bootstrap.zip
|
||||||
|
|
||||||
# FontAwesome CDN URL
|
# FontAwesome CDN URL
|
||||||
# See https://github.com/FortAwesome/Font-Awesome/releases to check for new versions
|
fontawesome_version=5.15.3
|
||||||
fontawesome_version=6.2.1
|
|
||||||
fontawesome_url=https://github.com/FortAwesome/Font-Awesome/releases/download/$fontawesome_version/fontawesome-free-$fontawesome_version-web.zip
|
fontawesome_url=https://github.com/FortAwesome/Font-Awesome/releases/download/$fontawesome_version/fontawesome-free-$fontawesome_version-web.zip
|
||||||
|
|
||||||
# Get FontAwesome
|
# Get FontAwesome
|
||||||
wget_verify $fontawesome_url cd0f2bcc9653b56e3e2dd82d6598aa6bbca8d796 /tmp/fontawesome.zip
|
wget_verify $fontawesome_url 914f0b8de446e338779771fcb6364b879f4527ae /tmp/fontawesome.zip
|
||||||
unzip -q /tmp/fontawesome.zip -d $assets_dir
|
unzip -q /tmp/fontawesome.zip -d $assets_dir
|
||||||
mv $assets_dir/fontawesome-free-$fontawesome_version-web $assets_dir/fontawesome
|
mv $assets_dir/fontawesome-free-$fontawesome_version-web $assets_dir/fontawesome
|
||||||
rm -f /tmp/fontawesome.zip
|
rm -f /tmp/fontawesome.zip
|
||||||
|
|
||||||
# Create an init script to start the management daemon and keep it
|
# Create an init script to start the management daemon and keep it
|
||||||
# running after a reboot.
|
# running after a reboot.
|
||||||
# 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;
|
cat > $inst_dir/start <<EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
|
# Set character encoding flags to ensure that any non-ASCII don't cause problems.
|
||||||
|
@ -121,13 +127,8 @@ export LC_ALL=en_US.UTF-8
|
||||||
export LANG=en_US.UTF-8
|
export LANG=en_US.UTF-8
|
||||||
export LC_TYPE=en_US.UTF-8
|
export LC_TYPE=en_US.UTF-8
|
||||||
|
|
||||||
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
|
source $venv/bin/activate
|
||||||
export PYTHONPATH=$(pwd)/management
|
exec python $(pwd)/management/daemon.py
|
||||||
exec gunicorn -b localhost:10222 -w 1 --timeout 630 wsgi:app
|
|
||||||
EOF
|
EOF
|
||||||
chmod +x $inst_dir/start
|
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
|
cp --remove-destination conf/mailinabox.service /lib/systemd/system/mailinabox.service # target was previously a symlink so remove it first
|
||||||
|
|
|
@ -9,56 +9,11 @@ source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
echo "Installing Nextcloud (contacts/calendar)..."
|
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 \
|
apt_install php php-fpm \
|
||||||
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
|
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-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \
|
||||||
php-intl php-imagick php-gmp php-bcmath
|
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() {
|
InstallNextcloud() {
|
||||||
|
|
||||||
version=$1
|
version=$1
|
||||||
|
@ -89,42 +44,18 @@ InstallNextcloud() {
|
||||||
# their github repositories.
|
# their github repositories.
|
||||||
mkdir -p /usr/local/lib/owncloud/apps
|
mkdir -p /usr/local/lib/owncloud/apps
|
||||||
|
|
||||||
IFS='.'
|
wget_verify https://github.com/nextcloud-releases/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz
|
||||||
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/
|
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/contacts.tgz
|
rm /tmp/contacts.tgz
|
||||||
|
|
||||||
IFS='.'
|
wget_verify https://github.com/nextcloud-releases/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz
|
||||||
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/
|
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/calendar.tgz
|
rm /tmp/calendar.tgz
|
||||||
|
|
||||||
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
|
||||||
# we will install from their github repository.
|
# we will install from their github repository.
|
||||||
if [ -n "$version_user_external" ]; then
|
if [ -n "$version_user_external" ]; then
|
||||||
IFS='.'
|
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
|
||||||
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/
|
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
|
||||||
rm /tmp/user_external.tgz
|
rm /tmp/user_external.tgz
|
||||||
fi
|
fi
|
||||||
|
@ -163,33 +94,25 @@ InstallNextcloud() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Nextcloud Version to install. Checks are done down below to step through intermediate versions.
|
||||||
|
nextcloud_ver=20.0.13
|
||||||
|
nextcloud_hash=a1fe460e3e65753552fb4beb1a47ab09ff277d95
|
||||||
|
contacts_ver=4.0.3
|
||||||
|
contacts_hash=5ddd629e72fdb80c5ff80186954f32a464b9dcd1
|
||||||
|
calendar_ver=2.3.4
|
||||||
|
calendar_hash=bf4a8e5d40731013a6f971eea3149e852e9fb47c
|
||||||
|
user_external_ver=1.0.0
|
||||||
|
user_external_hash=3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||||
|
|
||||||
# Current Nextcloud Version, #1623
|
# Current Nextcloud Version, #1623
|
||||||
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
|
# 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
|
# $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
|
# version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud
|
||||||
# application version than the database.
|
# application version than the database.
|
||||||
|
|
||||||
# If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.
|
# 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
|
if [ -f "$STORAGE_ROOT/owncloud/config.php" ]; then
|
||||||
CURRENT_NEXTCLOUD_VER=$(php -r "include(\"$STORAGE_ROOT/owncloud/config.php\"); echo(\$CONFIG['version']);")
|
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
|
else
|
||||||
CURRENT_NEXTCLOUD_VER=""
|
CURRENT_NEXTCLOUD_VER=""
|
||||||
fi
|
fi
|
||||||
|
@ -239,45 +162,28 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
|
||||||
CURRENT_NEXTCLOUD_VER="15.0.8"
|
CURRENT_NEXTCLOUD_VER="15.0.8"
|
||||||
fi
|
fi
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
|
||||||
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
CURRENT_NEXTCLOUD_VER="16.0.6"
|
CURRENT_NEXTCLOUD_VER="16.0.6"
|
||||||
fi
|
fi
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
|
||||||
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
|
||||||
CURRENT_NEXTCLOUD_VER="17.0.6"
|
CURRENT_NEXTCLOUD_VER="17.0.6"
|
||||||
fi
|
fi
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then
|
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
|
||||||
(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
|
||||||
InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
CURRENT_NEXTCLOUD_VER="18.0.10"
|
||||||
CURRENT_NEXTCLOUD_VER="18.0.10"
|
fi
|
||||||
fi
|
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
|
||||||
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
|
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
||||||
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
|
CURRENT_NEXTCLOUD_VER="19.0.4"
|
||||||
CURRENT_NEXTCLOUD_VER="19.0.4"
|
fi
|
||||||
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
|
fi
|
||||||
|
|
||||||
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
|
InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
|
||||||
|
|
||||||
|
# Nextcloud 20 needs to have some optional columns added
|
||||||
|
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Configuring Nextcloud
|
# ### Configuring Nextcloud
|
||||||
|
@ -303,10 +209,10 @@ if [ ! -f $STORAGE_ROOT/owncloud/owncloud.db ]; then
|
||||||
'overwrite.cli.url' => '/cloud',
|
'overwrite.cli.url' => '/cloud',
|
||||||
'user_backends' => array(
|
'user_backends' => array(
|
||||||
array(
|
array(
|
||||||
'class' => '\OCA\UserExternal\IMAP',
|
'class' => 'OC_User_IMAP',
|
||||||
'arguments' => array(
|
'arguments' => array(
|
||||||
'127.0.0.1', 143, null, null, false, false
|
'127.0.0.1', 143, null
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'memcache.local' => '\OC\Memcache\APCu',
|
'memcache.local' => '\OC\Memcache\APCu',
|
||||||
|
@ -333,7 +239,6 @@ EOF
|
||||||
# storage/database
|
# storage/database
|
||||||
'directory' => '$STORAGE_ROOT/owncloud',
|
'directory' => '$STORAGE_ROOT/owncloud',
|
||||||
'dbtype' => 'sqlite3',
|
'dbtype' => 'sqlite3',
|
||||||
'dbname' => 'owncloud',
|
|
||||||
|
|
||||||
# create an administrator account with a random password so that
|
# create an administrator account with a random password so that
|
||||||
# the user does not have to enter anything on first load of Nextcloud
|
# the user does not have to enter anything on first load of Nextcloud
|
||||||
|
@ -364,12 +269,11 @@ fi
|
||||||
# the correct domain name if the domain is being change from the previous setup.
|
# 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.
|
# Use PHP to read the settings file, modify it, and write out the new settings array.
|
||||||
TIMEZONE=$(cat /etc/timezone)
|
TIMEZONE=$(cat /etc/timezone)
|
||||||
|
CONFIG_TEMP=$(/bin/mktemp)
|
||||||
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORAGE_ROOT/owncloud/config.php;
|
||||||
<?php
|
<?php
|
||||||
include("$STORAGE_ROOT/owncloud/config.php");
|
include("$STORAGE_ROOT/owncloud/config.php");
|
||||||
|
|
||||||
\$CONFIG['config_is_read_only'] = true;
|
|
||||||
|
|
||||||
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
\$CONFIG['trusted_domains'] = array('$PRIMARY_HOSTNAME');
|
||||||
|
|
||||||
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
|
\$CONFIG['memcache.local'] = '\OC\Memcache\APCu';
|
||||||
|
@ -381,14 +285,7 @@ include("$STORAGE_ROOT/owncloud/config.php");
|
||||||
|
|
||||||
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
|
\$CONFIG['mail_domain'] = '$PRIMARY_HOSTNAME';
|
||||||
|
|
||||||
\$CONFIG['user_backends'] = array(
|
\$CONFIG['user_backends'] = array(array('class' => 'OC_User_IMAP','arguments' => array('127.0.0.1', 143, null),),);
|
||||||
array(
|
|
||||||
'class' => '\OCA\UserExternal\IMAP',
|
|
||||||
'arguments' => array(
|
|
||||||
'127.0.0.1', 143, null, null, false, false
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
echo "<?php\n\\\$CONFIG = ";
|
echo "<?php\n\\\$CONFIG = ";
|
||||||
var_export(\$CONFIG);
|
var_export(\$CONFIG);
|
||||||
|
@ -437,11 +334,11 @@ management/editconf.py /etc/php/$(php_version)/cli/conf.d/10-opcache.ini -c ';'
|
||||||
opcache.save_comments=1 \
|
opcache.save_comments=1 \
|
||||||
opcache.revalidate_freq=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).
|
# If apc is explicitly disabled we need to enable it
|
||||||
# This version was probably in use in Mail-in-a-Box v0.41 (February 26, 2019) and earlier.
|
if grep -q apc.enabled=0 /etc/php/$(php_version)/mods-available/apcu.ini; then
|
||||||
# We moved to v0.6.3 in 193763f8. Ignore errors - maybe there are duplicated users with the
|
management/editconf.py /etc/php/$(php_version)/mods-available/apcu.ini -c ';' \
|
||||||
# correct backend already.
|
apc.enabled=1
|
||||||
sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "UPDATE oc_users_external SET backend='127.0.0.1';" || /bin/true
|
fi
|
||||||
|
|
||||||
# Set up a cron job for Nextcloud.
|
# Set up a cron job for Nextcloud.
|
||||||
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
|
cat > /etc/cron.d/mailinabox-nextcloud << EOF;
|
||||||
|
@ -451,6 +348,9 @@ cat > /etc/cron.d/mailinabox-nextcloud << EOF;
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/cron.d/mailinabox-nextcloud
|
chmod +x /etc/cron.d/mailinabox-nextcloud
|
||||||
|
|
||||||
|
# Remove previous hourly cronjob
|
||||||
|
rm -f /etc/cron.hourly/mailinabox-owncloud
|
||||||
|
|
||||||
# There's nothing much of interest that a user could do as an admin for Nextcloud,
|
# 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.
|
# 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:
|
# But if we wanted to, we would do this:
|
||||||
|
|
|
@ -9,33 +9,19 @@ if [[ $EUID -ne 0 ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check that we are running on Debian GNU/Linux, or Ubuntu 20.04/22.04
|
# Check that we are running on Debian GNU/Linux, or Ubuntu 20.04
|
||||||
case $(get_os_code) in
|
if [ $(get_os_code) = $OS_UNSUPPORTED ]; then
|
||||||
$OS_UNSUPPORTED)
|
echo "Mail-in-a-Box only supports being installed on one of these operating systems:"
|
||||||
echo "This version of Power Mail-in-a-Box only supports being installed on one of these operating systems:"
|
echo "* Debian 10 (buster)"
|
||||||
# echo "* Debian 10 (buster)"
|
echo "* Debian 11 (bullseye)"
|
||||||
echo "* Debian 11 (bullseye)"
|
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
||||||
echo "* Ubuntu 20.04 LTS (Focal Fossa)"
|
echo
|
||||||
echo "* Ubuntu 22.04 LTS (Jammy Jellyfish)"
|
echo "You're running:"
|
||||||
echo
|
lsb_release -ds
|
||||||
echo "You're running:"
|
echo
|
||||||
lsb_release -ds
|
echo "We can't write scripts that run on every possible setup, sorry."
|
||||||
echo
|
exit 1
|
||||||
echo "We can't write scripts that run on every possible setup, sorry."
|
fi
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
|
|
||||||
$OS_DEBIAN_10)
|
|
||||||
echo "You're trying to install Power Mail-in-a-Box on Debian 10 (buster), which is no longer supported."
|
|
||||||
echo "You can install the latest version of Power Mail-in-a-Box supporting Debian 10 by running the following command:"
|
|
||||||
echo
|
|
||||||
echo "curl -L https://power-mailinabox.net/setup.sh | sudo bash"
|
|
||||||
echo
|
|
||||||
echo "Then upgrade to Debian 11 (bullseye). A short guide on how to do so is available here:"
|
|
||||||
echo "https://power-mailinabox.net/buster-eol"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Check that we have enough memory.
|
# Check that we have enough memory.
|
||||||
#
|
#
|
||||||
|
|
|
@ -49,12 +49,12 @@ fi
|
||||||
|
|
||||||
# Put a start script in a global location. We tell the user to run 'mailinabox'
|
# Put a start script in a global location. We tell the user to run 'mailinabox'
|
||||||
# in the first dialog prompt, so we should do this before that starts.
|
# in the first dialog prompt, so we should do this before that starts.
|
||||||
cat > /usr/local/sbin/mailinabox << EOF;
|
cat > /usr/local/bin/mailinabox << EOF;
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
cd $(pwd)
|
cd $(pwd)
|
||||||
source setup/start.sh
|
source setup/start.sh
|
||||||
EOF
|
EOF
|
||||||
chmod 744 /usr/local/sbin/mailinabox
|
chmod +x /usr/local/bin/mailinabox
|
||||||
|
|
||||||
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,
|
# Ask the user for the PRIMARY_HOSTNAME, PUBLIC_IP, and PUBLIC_IPV6,
|
||||||
# if values have not already been set in environment variables. When running
|
# if values have not already been set in environment variables. When running
|
||||||
|
@ -72,10 +72,6 @@ fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
|
# Create the STORAGE_USER and STORAGE_ROOT directory if they don't already exist.
|
||||||
#
|
|
||||||
# Set the directory and all of its parent directories' permissions to world
|
|
||||||
# readable since it holds files owned by different processes.
|
|
||||||
#
|
|
||||||
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
# If the STORAGE_ROOT is missing the mailinabox.version file that lists a
|
||||||
# migration (schema) number for the files stored there, assume this is a fresh
|
# migration (schema) number for the files stored there, assume this is a fresh
|
||||||
# installation to that directory and write the file to contain the current
|
# installation to that directory and write the file to contain the current
|
||||||
|
@ -86,15 +82,11 @@ fi
|
||||||
if [ ! -d $STORAGE_ROOT ]; then
|
if [ ! -d $STORAGE_ROOT ]; then
|
||||||
mkdir -p $STORAGE_ROOT
|
mkdir -p $STORAGE_ROOT
|
||||||
fi
|
fi
|
||||||
f=$STORAGE_ROOT
|
|
||||||
while [[ $f != / ]]; do chmod a+rx "$f"; f=$(dirname "$f"); done;
|
|
||||||
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
if [ ! -f $STORAGE_ROOT/mailinabox.version ]; then
|
||||||
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
|
setup/migrate.py --current > $STORAGE_ROOT/mailinabox.version
|
||||||
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
chown $STORAGE_USER.$STORAGE_USER $STORAGE_ROOT/mailinabox.version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod 751 $STORAGE_ROOT
|
|
||||||
|
|
||||||
# Save the global options in /etc/mailinabox.conf so that standalone
|
# Save the global options in /etc/mailinabox.conf so that standalone
|
||||||
# tools know where to look for data. The default MTA_STS_MODE setting
|
# tools know where to look for data. The default MTA_STS_MODE setting
|
||||||
# is blank unless set by an environment variable, but see web.sh for
|
# is blank unless set by an environment variable, but see web.sh for
|
||||||
|
@ -129,14 +121,6 @@ source setup/zpush.sh
|
||||||
source setup/management.sh
|
source setup/management.sh
|
||||||
source setup/munin.sh
|
source setup/munin.sh
|
||||||
|
|
||||||
# Create a shorthand alias for the cli interface
|
|
||||||
cat > /usr/local/sbin/miabadm << EOF;
|
|
||||||
#!/bin/bash
|
|
||||||
cd $(pwd)
|
|
||||||
/usr/bin/env python3 management/cli.py \$@
|
|
||||||
EOF
|
|
||||||
chmod 744 /usr/local/sbin/miabadm
|
|
||||||
|
|
||||||
# Wait for the management daemon to start...
|
# Wait for the management daemon to start...
|
||||||
until nc -z -w 4 127.0.0.1 10222
|
until nc -z -w 4 127.0.0.1 10222
|
||||||
do
|
do
|
||||||
|
|
|
@ -14,15 +14,6 @@ source setup/functions.sh # load our functions
|
||||||
echo $PRIMARY_HOSTNAME > /etc/hostname
|
echo $PRIMARY_HOSTNAME > /etc/hostname
|
||||||
hostname $PRIMARY_HOSTNAME
|
hostname $PRIMARY_HOSTNAME
|
||||||
|
|
||||||
# ### Enable IPv6 at Kernel Level
|
|
||||||
|
|
||||||
# This doesn't mean that the cloud provider must provide IPv6 connectivity. We just want
|
|
||||||
# the loopback interface to also work on IPv6 (that is, we want :: to be available). This
|
|
||||||
# is required because apparently nsd expects this to exist.
|
|
||||||
|
|
||||||
management/editconf.py /etc/sysctl.conf "net.ipv6.conf.lo.disable_ipv6 = 0"
|
|
||||||
hide_output sysctl --system
|
|
||||||
|
|
||||||
# ### Fix permissions
|
# ### Fix permissions
|
||||||
|
|
||||||
# The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect
|
# The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect
|
||||||
|
@ -84,12 +75,7 @@ then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ### Set log retention policy.
|
# Certbot doesn't require a PPA in Debian
|
||||||
|
|
||||||
# Set the systemd journal log retention from infinite to 10 days,
|
|
||||||
# since over time the logs take up a large amount of space.
|
|
||||||
# (See https://discourse.mailinabox.email/t/journalctl-reclaim-space-on-small-mailinabox/6728/11.)
|
|
||||||
management/editconf.py /etc/systemd/journald.conf MaxRetentionSec=10day
|
|
||||||
|
|
||||||
# ### Update Packages
|
# ### Update Packages
|
||||||
|
|
||||||
|
@ -111,6 +97,9 @@ apt_get_quiet autoremove
|
||||||
|
|
||||||
# Install basic utilities.
|
# Install basic utilities.
|
||||||
#
|
#
|
||||||
|
# * haveged: Provides extra entropy to /dev/random so it doesn't stall
|
||||||
|
# when generating random numbers for private keys (e.g. during
|
||||||
|
# ldns-keygen).
|
||||||
# * unattended-upgrades: Apt tool to install security updates automatically.
|
# * unattended-upgrades: Apt tool to install security updates automatically.
|
||||||
# * cron: Runs background processes periodically.
|
# * cron: Runs background processes periodically.
|
||||||
# * ntp: keeps the system time correct
|
# * ntp: keeps the system time correct
|
||||||
|
@ -124,8 +113,8 @@ apt_get_quiet autoremove
|
||||||
|
|
||||||
echo Installing system packages...
|
echo Installing system packages...
|
||||||
apt_install python3 python3-dev python3-pip python3-setuptools \
|
apt_install python3 python3-dev python3-pip python3-setuptools \
|
||||||
netcat-openbsd wget curl git sudo coreutils bc file \
|
netcat-openbsd wget curl git sudo coreutils bc \
|
||||||
pollinate openssh-client unzip \
|
haveged pollinate openssh-client unzip \
|
||||||
unattended-upgrades cron ntp fail2ban rsyslog
|
unattended-upgrades cron ntp fail2ban rsyslog
|
||||||
|
|
||||||
# ### Suppress Upgrade Prompts
|
# ### Suppress Upgrade Prompts
|
||||||
|
@ -220,11 +209,7 @@ dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null
|
||||||
# is really any good on virtualized systems, we'll also seed from Ubuntu's
|
# is really any good on virtualized systems, we'll also seed from Ubuntu's
|
||||||
# pollinate servers:
|
# pollinate servers:
|
||||||
|
|
||||||
if ! pollinate -q -r --strict 2> /dev/null; then
|
pollinate -q -r
|
||||||
# In the case pollinate is ill-configured (e.g. server is example.com), try using a server we know that works
|
|
||||||
# Even if this fails - don't bail and carry on.
|
|
||||||
pollinate -q -r -s entropy.ubuntu.com 2> /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Between these two, we really ought to be all set.
|
# Between these two, we really ought to be all set.
|
||||||
|
|
||||||
|
@ -254,23 +239,20 @@ if [ -z "${DISABLE_FIREWALL:-}" ]; then
|
||||||
# Install `ufw` which provides a simple firewall configuration.
|
# Install `ufw` which provides a simple firewall configuration.
|
||||||
apt_install ufw
|
apt_install ufw
|
||||||
|
|
||||||
# Check if we have got an SSH server installed.
|
# Allow incoming connections to SSH.
|
||||||
# It's not critical for us to have one, so if it isn't installed,
|
ufw_limit ssh;
|
||||||
# no need to open the port
|
|
||||||
if [ -x "$(command -v sshd)" ]; then
|
|
||||||
# Allow incoming connections to SSH.
|
|
||||||
ufw_limit ssh;
|
|
||||||
|
|
||||||
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
# ssh might be running on an alternate port. Use sshd -T to dump sshd's #NODOC
|
||||||
# settings, find the port it is supposedly running on, and open that port #NODOC
|
# settings, find the port it is supposedly running on, and open that port #NODOC
|
||||||
# too. #NODOC
|
# too. #NODOC
|
||||||
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
|
SSH_PORT=$(sshd -T 2>/dev/null | grep "^port " | sed "s/port //") #NODOC
|
||||||
if [ ! -z "$SSH_PORT" ]; then
|
if [ ! -z "$SSH_PORT" ]; then
|
||||||
if [ "$SSH_PORT" != "22" ]; then
|
if [ "$SSH_PORT" != "22" ]; then
|
||||||
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
|
||||||
ufw_limit $SSH_PORT #NODOC
|
echo Opening alternate SSH port $SSH_PORT. #NODOC
|
||||||
fi
|
ufw_limit $SSH_PORT #NODOC
|
||||||
fi
|
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ufw --force enable;
|
ufw --force enable;
|
||||||
|
@ -360,7 +342,6 @@ systemctl restart systemd-resolved
|
||||||
rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore
|
rm -f /etc/fail2ban/jail.local # we used to use this file but don't anymore
|
||||||
rm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config
|
rm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config
|
||||||
cat conf/fail2ban/jails.conf \
|
cat conf/fail2ban/jails.conf \
|
||||||
| sed "s/PUBLIC_IPV6/$PUBLIC_IPV6/g" \
|
|
||||||
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
|
| sed "s/PUBLIC_IP/$PUBLIC_IP/g" \
|
||||||
| sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
| sed "s#STORAGE_ROOT#$STORAGE_ROOT#" \
|
||||||
> /etc/fail2ban/jail.d/mailinabox.conf
|
> /etc/fail2ban/jail.d/mailinabox.conf
|
||||||
|
|
|
@ -95,7 +95,7 @@ else
|
||||||
pm.max_spare_servers=18
|
pm.max_spare_servers=18
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Duplicate the socket to isolate MiaB apps from user apps that happen to run php
|
# Duplicate the socket to isolate MiaB apps from user apps that happen to run php
|
||||||
cp /etc/php/$(php_version)/fpm/pool.d/www.conf /etc/php/$(php_version)/fpm/pool.d/miab.conf
|
cp /etc/php/$(php_version)/fpm/pool.d/www.conf /etc/php/$(php_version)/fpm/pool.d/miab.conf
|
||||||
|
|
||||||
management/editconf.py /etc/php/$(php_version)/fpm/pool.d/miab.conf -c ';' \
|
management/editconf.py /etc/php/$(php_version)/fpm/pool.d/miab.conf -c ';' \
|
||||||
|
@ -132,7 +132,7 @@ chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml
|
||||||
|
|
||||||
# Create a generic mta-sts.txt file which is exposed via the
|
# Create a generic mta-sts.txt file which is exposed via the
|
||||||
# nginx configuration at /.well-known/mta-sts.txt
|
# nginx configuration at /.well-known/mta-sts.txt
|
||||||
# more documentation is available on:
|
# more documentation is available on:
|
||||||
# https://www.uriports.com/blog/mta-sts-explained/
|
# https://www.uriports.com/blog/mta-sts-explained/
|
||||||
# default mode is "enforce". In /etc/mailinabox.conf change
|
# default mode is "enforce". In /etc/mailinabox.conf change
|
||||||
# "MTA_STS_MODE=testing" which means "Messages will be delivered
|
# "MTA_STS_MODE=testing" which means "Messages will be delivered
|
||||||
|
@ -160,6 +160,3 @@ restart_service php$(php_version)-fpm
|
||||||
# Open ports.
|
# Open ports.
|
||||||
ufw_allow http
|
ufw_allow http
|
||||||
ufw_allow https
|
ufw_allow https
|
||||||
|
|
||||||
# Allow the webserver to access directories group-owned by user-data
|
|
||||||
usermod -a -G user-data www-data
|
|
||||||
|
|
|
@ -28,19 +28,13 @@ apt_install \
|
||||||
# Install Roundcube from source if it is not already present or if it is out of date.
|
# Install Roundcube from source if it is not already present or if it is out of date.
|
||||||
# Combine the Roundcube version number with the commit hash of plugins to track
|
# Combine the Roundcube version number with the commit hash of plugins to track
|
||||||
# whether we have the latest version of everything.
|
# whether we have the latest version of everything.
|
||||||
# For the latest versions, see:
|
|
||||||
# https://github.com/roundcube/roundcubemail/releases
|
VERSION=1.5.0
|
||||||
# https://github.com/mfreiholz/persistent_login/
|
HASH=2a9d11d9c10c8e8756120606c47eef702f00fe6d
|
||||||
# https://github.com/stremlau/html5_notifier/
|
PERSISTENT_LOGIN_VERSION=327362e7615e9192ea7a8871e5a0230b8ea6930a # version 5.2.0 + chinese localization
|
||||||
# https://github.com/mstilkerich/rcmcarddav/releases
|
|
||||||
# The easiest way to get the package hashes is to run this script and get the hash from
|
|
||||||
# the error message.
|
|
||||||
VERSION=1.6.0
|
|
||||||
HASH=fd84b4fac74419bb73e7a3bcae1978d5589c52de
|
|
||||||
PERSISTENT_LOGIN_VERSION=version-5.3.0
|
|
||||||
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
|
HTML5_NOTIFIER_VERSION=68d9ca194212e15b3c7225eb6085dbcf02fd13d7 # version 0.6.4+
|
||||||
CARDDAV_VERSION=4.4.4
|
CARDDAV_VERSION=4.2.0
|
||||||
CARDDAV_HASH=743fd6925b775f821aa8860982d2bdeec05f5d7b
|
CARDDAV_HASH=d412a038bb698eeee631ec20e8b1118baeb2e554
|
||||||
|
|
||||||
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
|
UPDATE_KEY=$VERSION:$PERSISTENT_LOGIN_VERSION:$HTML5_NOTIFIER_VERSION:$CARDDAV_VERSION
|
||||||
|
|
||||||
|
@ -83,13 +77,13 @@ if [ $needs_update == 1 ]; then
|
||||||
|
|
||||||
# download and verify the full release of the carddav plugin
|
# download and verify the full release of the carddav plugin
|
||||||
wget_verify \
|
wget_verify \
|
||||||
https://github.com/mstilkerich/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-v${CARDDAV_VERSION}.tar.gz \
|
https://github.com/blind-coder/rcmcarddav/releases/download/v${CARDDAV_VERSION}/carddav-v${CARDDAV_VERSION}.tar.gz \
|
||||||
$CARDDAV_HASH \
|
$CARDDAV_HASH \
|
||||||
/tmp/carddav.tar.gz
|
/tmp/carddav.tgz
|
||||||
|
|
||||||
# unzip and cleanup
|
# unzip and cleanup
|
||||||
tar -C ${RCM_PLUGIN_DIR} -zxf /tmp/carddav.tar.gz
|
tar xf /tmp/carddav.tgz -C ${RCM_PLUGIN_DIR}
|
||||||
rm -f /tmp/carddav.tar.gz
|
rm -f /tmp/carddav.tgz
|
||||||
|
|
||||||
# record the version we've installed
|
# record the version we've installed
|
||||||
echo $UPDATE_KEY > ${RCM_DIR}/version
|
echo $UPDATE_KEY > ${RCM_DIR}/version
|
||||||
|
@ -115,22 +109,23 @@ cat > $RCM_CONFIG <<EOF;
|
||||||
\$config['log_dir'] = '/var/log/roundcubemail/';
|
\$config['log_dir'] = '/var/log/roundcubemail/';
|
||||||
\$config['temp_dir'] = '/var/tmp/roundcubemail/';
|
\$config['temp_dir'] = '/var/tmp/roundcubemail/';
|
||||||
\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';
|
\$config['db_dsnw'] = 'sqlite:///$STORAGE_ROOT/mail/roundcube/roundcube.sqlite?mode=0640';
|
||||||
\$config['imap_host'] = 'ssl://localhost:993';
|
\$config['default_host'] = 'ssl://localhost';
|
||||||
|
\$config['default_port'] = 993;
|
||||||
\$config['imap_conn_options'] = array(
|
\$config['imap_conn_options'] = array(
|
||||||
'ssl'=> array(
|
'ssl' => array(
|
||||||
'verify_peer' => false,
|
'verify_peer' => false,
|
||||||
'verify_peer_name' => false,
|
'verify_peer_name' => false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
\$config['imap_timeout'] = 15;
|
\$config['imap_timeout'] = 15;
|
||||||
\$config['smtp_host'] = 'tls://127.0.0.1:587';
|
\$config['smtp_server'] = 'tls://127.0.0.1';
|
||||||
\$config['smtp_conn_options'] = array(
|
\$config['smtp_conn_options'] = array(
|
||||||
'ssl'=> array(
|
'ssl' => array(
|
||||||
'verify_peer' => false,
|
'verify_peer' => false,
|
||||||
'verify_peer_name' => false,
|
'verify_peer_name' => false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
\$config['support_url'] = 'https://power-mailinabox.net/';
|
\$config['support_url'] = 'https://mailinabox.email/';
|
||||||
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
\$config['product_name'] = '$PRIMARY_HOSTNAME Webmail';
|
||||||
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav', 'enigma');
|
\$config['plugins'] = array('html5_notifier', 'archive', 'zipdownload', 'password', 'managesieve', 'jqueryui', 'persistent_login', 'carddav', 'enigma');
|
||||||
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
|
\$config['cipher_method'] = 'AES-256-CBC'; # persistent login cookie and potentially other things
|
||||||
|
@ -140,11 +135,6 @@ cat > $RCM_CONFIG <<EOF;
|
||||||
\$config['login_username_filter'] = 'email';
|
\$config['login_username_filter'] = 'email';
|
||||||
\$config['password_charset'] = 'UTF-8';
|
\$config['password_charset'] = 'UTF-8';
|
||||||
\$config['junk_mbox'] = 'Spam';
|
\$config['junk_mbox'] = 'Spam';
|
||||||
|
|
||||||
/* ensure roudcube session id's aren't leaked to other parts of the server */
|
|
||||||
\$config['session_path'] = '/mail/';
|
|
||||||
/* prevent CSRF, requires php 7.3+ */
|
|
||||||
\$config['session_samesite'] = 'Strict';
|
|
||||||
\$config['quota_zero_as_unlimited'] = true;
|
\$config['quota_zero_as_unlimited'] = true;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
@ -187,7 +177,7 @@ cat > ${RCM_PLUGIN_DIR}/carddav/config.inc.php <<EOF;
|
||||||
'name' => 'ownCloud',
|
'name' => 'ownCloud',
|
||||||
'username' => '%u', // login username
|
'username' => '%u', // login username
|
||||||
'password' => '%p', // login password
|
'password' => '%p', // login password
|
||||||
'url' => 'https://${PRIMARY_HOSTNAME}/cloud/remote.php/dav/addressbooks/users/%u/contacts/',
|
'url' => 'https://${PRIMARY_HOSTNAME}/cloud/remote.php/carddav/addressbooks/%u/contacts',
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'readonly' => false,
|
'readonly' => false,
|
||||||
'refresh_time' => '02:00:00',
|
'refresh_time' => '02:00:00',
|
||||||
|
@ -211,12 +201,13 @@ sudo -u www-data touch /var/log/roundcubemail/errors.log
|
||||||
cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
|
cp ${RCM_PLUGIN_DIR}/password/config.inc.php.dist \
|
||||||
${RCM_PLUGIN_DIR}/password/config.inc.php
|
${RCM_PLUGIN_DIR}/password/config.inc.php
|
||||||
|
|
||||||
management/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php -c "//" \
|
management/editconf.py ${RCM_PLUGIN_DIR}/password/config.inc.php \
|
||||||
"\$config['password_driver'] = 'miab';" \
|
"\$config['password_minimum_length']=8;" \
|
||||||
"\$config['password_minimum_length'] = 8;" \
|
"\$config['password_db_dsn']='sqlite:///$STORAGE_ROOT/mail/users.sqlite';" \
|
||||||
"\$config['password_miab_url'] = 'http://127.0.0.1:10222/';" \
|
"\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" \
|
||||||
"\$config['password_miab_user'] = '';" \
|
"\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \
|
||||||
"\$config['password_miab_pass'] = '';"
|
"\$config['password_dovecotpw_method']='SHA512-CRYPT';" \
|
||||||
|
"\$config['password_dovecotpw_with_method']=true;"
|
||||||
|
|
||||||
# so PHP can use doveadm, for the password changing plugin
|
# so PHP can use doveadm, for the password changing plugin
|
||||||
usermod -a -G dovecot www-data
|
usermod -a -G dovecot www-data
|
||||||
|
@ -234,7 +225,7 @@ chown -f -R root.www-data ${RCM_PLUGIN_DIR}/carddav
|
||||||
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
|
chmod -R 774 ${RCM_PLUGIN_DIR}/carddav
|
||||||
|
|
||||||
# Run Roundcube database migration script (database is created if it does not exist)
|
# Run Roundcube database migration script (database is created if it does not exist)
|
||||||
php ${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
|
${RCM_DIR}/bin/updatedb.sh --dir ${RCM_DIR}/SQL --package roundcube
|
||||||
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
chown www-data:www-data $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
||||||
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
chmod 664 $STORAGE_ROOT/mail/roundcube/roundcube.sqlite
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
echo "Installing Z-Push (Exchange/ActiveSync server)..."
|
echo "Installing Z-Push (Exchange/ActiveSync server)..."
|
||||||
apt_install \
|
apt_install \
|
||||||
php-soap php-imap libawl-php php-xml
|
php-soap php-imap libawl-php php-xsl
|
||||||
|
|
||||||
phpenmod -v php imap
|
phpenmod -v php imap
|
||||||
|
|
||||||
|
@ -42,6 +42,8 @@ if [ $needs_update == 1 ]; then
|
||||||
rm -rf /tmp/z-push.zip /tmp/z-push
|
rm -rf /tmp/z-push.zip /tmp/z-push
|
||||||
|
|
||||||
rm -f /usr/sbin/z-push-{admin,top}
|
rm -f /usr/sbin/z-push-{admin,top}
|
||||||
|
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
|
||||||
|
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
|
||||||
echo $VERSION > /usr/local/lib/z-push/version
|
echo $VERSION > /usr/local/lib/z-push/version
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -104,4 +106,4 @@ restart_service php$(php_version)-fpm
|
||||||
|
|
||||||
# Fix states after upgrade
|
# Fix states after upgrade
|
||||||
|
|
||||||
hide_output php /usr/local/lib/z-push/z-push-admin.php -a fixstates
|
hide_output z-push-admin -a fixstates
|
||||||
|
|
|
@ -30,7 +30,7 @@ def test(server, description):
|
||||||
(hostname, "TXT", "\"v=spf1 mx -all\""),
|
(hostname, "TXT", "\"v=spf1 mx -all\""),
|
||||||
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""),
|
("mail._domainkey." + hostname, "TXT", "\"v=DKIM1; k=rsa; s=email; \" \"p=__KEY__\""),
|
||||||
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
|
#("_adsp._domainkey." + hostname, "TXT", "\"dkim=all\""),
|
||||||
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine;\""),
|
("_dmarc." + hostname, "TXT", "\"v=DMARC1; p=quarantine\""),
|
||||||
]
|
]
|
||||||
return test2(tests, server, description)
|
return test2(tests, server, description)
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ def test2(tests, server, description):
|
||||||
for qname, rtype, expected_answer in tests:
|
for qname, rtype, expected_answer in tests:
|
||||||
# do the query and format the result as a string
|
# do the query and format the result as a string
|
||||||
try:
|
try:
|
||||||
response = dns.resolver.resolve(qname, rtype)
|
response = dns.resolver.query(qname, rtype)
|
||||||
except dns.resolver.NoNameservers:
|
except dns.resolver.NoNameservers:
|
||||||
# host did not have an answer for this query
|
# host did not have an answer for this query
|
||||||
print("Could not connect to %s for DNS query." % server)
|
print("Could not connect to %s for DNS query." % server)
|
||||||
|
|
|
@ -48,7 +48,7 @@ server = smtplib.SMTP_SSL(host)
|
||||||
ipaddr = socket.gethostbyname(host) # IPv4 only!
|
ipaddr = socket.gethostbyname(host) # IPv4 only!
|
||||||
reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
|
reverse_ip = dns.reversename.from_address(ipaddr) # e.g. "1.0.0.127.in-addr.arpa."
|
||||||
try:
|
try:
|
||||||
reverse_dns = dns.resolver.resolve(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
|
reverse_dns = dns.resolver.query(reverse_ip, 'PTR')[0].target.to_text(omit_final_dot=True) # => hostname
|
||||||
except dns.resolver.NXDOMAIN:
|
except dns.resolver.NXDOMAIN:
|
||||||
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
|
print("Reverse DNS lookup failed for %s. SMTP EHLO name check skipped." % ipaddr)
|
||||||
reverse_dns = None
|
reverse_dns = None
|
||||||
|
|
3
tools/mail.py
Executable file
3
tools/mail.py
Executable file
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# This script has moved.
|
||||||
|
management/cli.py "$@"
|
|
@ -8,16 +8,16 @@
|
||||||
|
|
||||||
source /etc/mailinabox.conf # load global vars
|
source /etc/mailinabox.conf # load global vars
|
||||||
|
|
||||||
ADMIN=$(management/cli.py user admins | head -n 1)
|
ADMIN=$(./mail.py user admins | head -n 1)
|
||||||
test -z "$1" || ADMIN=$1
|
test -z "$1" || ADMIN=$1
|
||||||
|
|
||||||
echo I am going to unlock admin features for $ADMIN.
|
echo I am going to unlock admin features for $ADMIN.
|
||||||
echo You can provide another user to unlock as the first argument of this script.
|
echo You can provide another user to unlock as the first argument of this script.
|
||||||
echo
|
echo
|
||||||
echo WARNING: you could break mail-in-a-box when fiddling around with Nextcloud\'s admin interface
|
echo WARNING: you could break mail-in-a-box when fiddling around with Nextcloud\'s admin interface
|
||||||
echo If in doubt, press CTRL-C to cancel.
|
echo If in doubt, press CTRL-C to cancel.
|
||||||
echo
|
echo
|
||||||
echo Press enter to continue.
|
echo Press enter to continue.
|
||||||
read
|
read
|
||||||
|
|
||||||
sudo -u www-data php /usr/local/lib/owncloud/occ group:adduser admin "$ADMIN" && echo Done.
|
sudo -u www-data php /usr/local/lib/owncloud/occ group:adduser admin $ADMIN && echo Done.
|
||||||
|
|
|
@ -413,7 +413,7 @@ class BashScript(Grammar):
|
||||||
result = parser.parse_string(string)
|
result = parser.parse_string(string)
|
||||||
|
|
||||||
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
|
v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
|
||||||
% ("https://github.com/ddavness/power-mailinabox/tree/main/" + fn, fn)
|
% ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)
|
||||||
|
|
||||||
mode = 0
|
mode = 0
|
||||||
for item in result.value():
|
for item in result.value():
|
||||||
|
|
Loading…
Add table
Reference in a new issue