Add some level of style guidance, reformat files

This commit is contained in:
David Duque 2022-02-04 23:26:24 +00:00
parent 14534fd0cb
commit a8f5a78518
No known key found for this signature in database
GPG key ID: 913FE0F2477D7D6B
35 changed files with 6396 additions and 4609 deletions

View file

@ -4,30 +4,23 @@
root = true root = true
[*] [Vagrantfile]
indent_style = space indent_style = space
indent_size = 4 indent_size = 2
[*.rb]
indent_style = space
indent_size = 2
[**.html]
indent_style = tab
[**.py]
indent_style = tab
[**]
indent_style = tab
end_of_line = lf 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

3
.style.yapf Normal file
View file

@ -0,0 +1,3 @@
[style]
based_on_style = pep8
use_tabs = True

View file

@ -1,4 +1,9 @@
import base64, os, os.path, hmac, json, secrets import base64
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
@ -10,14 +15,18 @@ 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(max_len=64, max_age_seconds=self.max_session_duration.total_seconds()) self.sessions = ExpiringDict(
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"""
@ -26,7 +35,8 @@ class AuthService:
# Based on answer by A-B-B: http://stackoverflow.com/a/15015748 # Based on answer by A-B-B: http://stackoverflow.com/a/15015748
old_umask = os.umask(0) old_umask = os.umask(0)
try: try:
return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w') return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode),
'w')
finally: finally:
os.umask(old_umask) os.umask(old_umask)
@ -46,8 +56,10 @@ 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)
@ -59,12 +71,15 @@ 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(request.headers.get('Authorization', '')) username, password = parse_http_authorization_basic(
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("No email address, password, session key, or API key provided.") raise ValueError(
"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.
@ -72,7 +87,8 @@ 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", env) and not login_only: if self.get_session(username, password, "login",
env) and not login_only:
sessionid = password sessionid = password
session = self.sessions[sessionid] session = self.sessions[sessionid]
if logout: if logout:
@ -96,7 +112,8 @@ 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): raise ValueError(privs[0]) if isinstance(privs, tuple):
raise ValueError(privs[0])
# Return the authorization information. # Return the authorization information.
return (username, privs) return (username, privs)
@ -120,9 +137,12 @@ 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", "pw", "/usr/bin/doveadm",
"-p", pw, "pw",
"-t", pw_hash, "-p",
pw,
"-t",
pw_hash,
]) ])
except: except:
# Login failed. # Login failed.
@ -141,7 +161,8 @@ 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), sort_keys=True).encode("utf8") msg += b" " + json.dumps(get_hash_mfa_state(email, env),
sort_keys=True).encode("utf8")
# Make a HMAC using the system API key as a hash key. # Make a HMAC using the system API key as a hash key.
hash_key = self.key.encode('ascii') hash_key = self.key.encode('ascii')
@ -152,15 +173,21 @@ class AuthService:
token = secrets.token_hex(32) token = secrets.token_hex(32)
self.sessions[token] = { self.sessions[token] = {
"email": username, "email": username,
"password_token": self.create_user_password_state_token(username, env), "password_token":
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: return None if session_key not in self.sessions:
return None
session = self.sessions[session_key] session = self.sessions[session_key]
if session_type == "login" and session["email"] != user_email: return None if session_type == "login" and session["email"] != user_email:
if session["type"] != session_type: return None return None
if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None if session["type"] != session_type:
return None
if session["password_token"] != self.create_user_password_state_token(
session["email"], env):
return None
return session return session

View file

@ -7,14 +7,23 @@
# 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, os.path, shutil, glob, re, datetime, sys import os
import dateutil.parser, dateutil.relativedelta, dateutil.tz import os.path
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, fix_boto, get_php_version, get_os_code 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):
def rsync_ssh_options(port=22, direct=False):
# Just in case we pass a string # Just in case we pass a string
try: try:
port = int(port) port = int(port)
@ -29,30 +38,39 @@ def rsync_ssh_options(port = 22, direct = False):
f"--rsync-options= -e \"/usr/bin/ssh -oStrictHostKeyChecking=no -oBatchMode=yes -p {port} -i /root/.ssh/id_rsa_miab\"", 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: return clip if ref < date:
return clip
rd = dateutil.relativedelta.relativedelta(ref, date) rd = dateutil.relativedelta.relativedelta(ref, date)
if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months) if rd.years > 1:
if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months) return "%d years, %d months" % (rd.years, rd.months)
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days) if rd.years == 1:
if rd.months == 1: return "%d month, %d days" % (rd.months, rd.days) return "%d year, %d months" % (rd.years, rd.months)
if rd.days >= 7: return "%d days" % rd.days if rd.months > 1:
if rd.days > 1: return "%d days, %d hours" % (rd.days, rd.hours) return "%d months, %d days" % (rd.months, rd.days)
if rd.days == 1: return "%d day, %d hours" % (rd.days, rd.hours) if rd.months == 1:
return "%d month, %d days" % (rd.months, rd.days)
if rd.days >= 7:
return "%d days" % rd.days
if rd.days > 1:
return "%d days, %d hours" % (rd.days, rd.hours)
if rd.days == 1:
return "%d day, %d hours" % (rd.days, rd.hours)
return "%d hours, %d minutes" % (rd.hours, rd.minutes) 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,23 +83,30 @@ def backup_status(env):
"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
"volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful) # 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/bin/duplicity", "/usr/bin/duplicity",
"collection-status", "collection-status",
"--archive-dir", backup_cache_dir, "--archive-dir",
"--gpg-options", "--cipher-algo=AES256", backup_cache_dir,
"--log-fd", "1", "--gpg-options",
"--cipher-algo=AES256",
"--log-fd",
"1",
config["target"], config["target"],
] + rsync_ssh_options(port = config["target_rsync_port"]), ] + rsync_ssh_options(port=config["target_rsync_port"]),
get_env(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: " + collection_status) raise Exception("Something is wrong with the backup: " +
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)
@ -94,8 +119,11 @@ 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(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn) m = re.match(
if not m: continue # not a part of a current backup chain r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.",
fn)
if not m:
continue # not a part of a current backup chain
key = m.group("date") key = m.group("date")
if key in backups: if key in backups:
backups[key]["size"] += size backups[key]["size"] += size
@ -104,7 +132,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
@ -133,16 +161,23 @@ 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) / (incremental_size/incremental_count) est_days_to_next_full = (.5 * first_full_size - incremental_size) / (
est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full) incremental_size / incremental_count)
est_time_of_next_full = first_date + \
datetime.timedelta(days=est_days_to_next_full)
# ...And then the part of the algorithm based on full backup age: # ...And then the part of the algorithm based on full backup age:
est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1)) est_time_of_next_full = min(
est_time_of_next_full, first_full_date +
datetime.timedelta(days=config["min_age_in_days"] * 10 + 1))
# It still can't be deleted until it's old enough. # It still can't be deleted until it's old enough.
est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"])) est_deleted_on = max(
est_time_of_next_full,
first_date + datetime.timedelta(days=config["min_age_in_days"]))
deleted_in = "approx. %d days" % round((est_deleted_on-now).total_seconds()/60/60/24 + .5) deleted_in = "approx. %d days" % round(
(est_deleted_on - now).total_seconds() / 60 / 60 / 24 + .5)
# When will a backup be deleted? Set the deleted_in field of each backup. # When will a backup be deleted? Set the deleted_in field of each backup.
saw_full = False saw_full = False
@ -158,7 +193,11 @@ 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(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup") deleted_in = reldate(
now,
dateutil.parser.parse(bak["date"]) +
datetime.timedelta(days=config["min_age_in_days"]),
"on next daily backup")
bak["deleted_in"] = deleted_in bak["deleted_in"] = deleted_in
return { return {
@ -166,6 +205,7 @@ 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
@ -181,9 +221,11 @@ 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(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()): if dateutil.parser.parse(bak["date"]) + datetime.timedelta(
days=config["min_age_in_days"] * 10 +
1) < datetime.datetime.now(dateutil.tz.tzlocal()):
return True return True
return False return False
else: else:
@ -191,6 +233,7 @@ 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.
@ -201,14 +244,16 @@ 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: raise Exception("secret_key.txt's first line is too short!") if len(passphrase) < 43:
raise Exception("secret_key.txt's first line is too short!")
return passphrase return passphrase
def get_env(env): def get_env(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"]
@ -216,10 +261,12 @@ def get_env(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"
@ -260,7 +307,10 @@ 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', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True) code, ret = shell('check_output',
["/usr/sbin/service", service, command],
capture_stderr=True,
trap=True)
if code != 0: if code != 0:
print(ret) print(ret)
if quit: if quit:
@ -284,18 +334,12 @@ 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/bin/duplicity", "/usr/bin/duplicity", "full" if full_backup else "incr",
"full" if full_backup else "incr", "--verbosity", "warning", "--no-print-statistics", "--archive-dir",
"--verbosity", "warning", "--no-print-statistics", backup_cache_dir, "--exclude", backup_root, "--volsize", "250",
"--archive-dir", backup_cache_dir, "--gpg-options", "--cipher-algo=AES256", env["STORAGE_ROOT"],
"--exclude", backup_root, config["target"], "--allow-source-mismatch"
"--volsize", "250", ] + rsync_ssh_options(port=config["target_rsync_port"]), get_env(env))
"--gpg-options", "--cipher-algo=AES256",
env["STORAGE_ROOT"],
config["target"],
"--allow-source-mismatch"
] + rsync_ssh_options(port = config["target_rsync_port"]),
get_env(env))
finally: finally:
# Start services again. # Start services again.
service_command("dovecot", "start", quit=False) service_command("dovecot", "start", quit=False)
@ -305,15 +349,10 @@ 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/bin/duplicity", "/usr/bin/duplicity", "remove-older-than",
"remove-older-than", "%dD" % config["min_age_in_days"], "--verbosity", "error",
"%dD" % config["min_age_in_days"], "--archive-dir", backup_cache_dir, "--force", config["target"]
"--verbosity", "error", ] + rsync_ssh_options(port=config["target_rsync_port"]), get_env(env))
"--archive-dir", backup_cache_dir,
"--force",
config["target"]
] + rsync_ssh_options(port = config["target_rsync_port"]),
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
@ -321,19 +360,15 @@ 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/bin/duplicity", "/usr/bin/duplicity", "cleanup", "--verbosity", "error",
"cleanup", "--archive-dir", backup_cache_dir, "--force", config["target"]
"--verbosity", "error", ] + rsync_ssh_options(port=config["target_rsync_port"]), get_env(env))
"--archive-dir", backup_cache_dir,
"--force",
config["target"]
] + rsync_ssh_options(port = config["target_rsync_port"]),
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', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir]) shell('check_call',
["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
# Execute a post-backup script that does the copying to a remote server. # Execute a post-backup script that does the copying to a remote server.
# 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,6 +391,7 @@ 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')
@ -364,14 +400,18 @@ def run_duplicity_verification():
shell('check_call', [ shell('check_call', [
"/usr/bin/duplicity", "/usr/bin/duplicity",
"--verbosity", "info", "--verbosity",
"info",
"verify", "verify",
"--compare-data", "--compare-data",
"--archive-dir", backup_cache_dir, "--archive-dir",
"--exclude", backup_root, backup_cache_dir,
"--exclude",
backup_root,
config["target"], config["target"],
env["STORAGE_ROOT"], env["STORAGE_ROOT"],
] + rsync_ssh_options(port = config["target_rsync_port"]), get_env(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()
@ -380,11 +420,13 @@ def run_duplicity_restore(args):
shell('check_call', [ shell('check_call', [
"/usr/bin/duplicity", "/usr/bin/duplicity",
"restore", "restore",
"--archive-dir", backup_cache_dir, "--archive-dir",
backup_cache_dir,
config["target"], config["target"],
] + rsync_ssh_options(port = config["target_rsync_port"]) + args, ] + rsync_ssh_options(port=config["target_rsync_port"]) + args,
get_env(env)) get_env(env))
def list_target_files(config): def list_target_files(config):
import urllib.parse import urllib.parse
try: try:
@ -393,7 +435,8 @@ 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))) for fn in os.listdir(target.path)] return [(fn, os.path.getsize(os.path.join(target.path, fn)))
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'.* ([^ ]*) [^ ]* [^ ]* (.*)')
@ -405,23 +448,24 @@ 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', rsync_command = [
'-e', 'rsync', '-e',
rsync_ssh_options(config["target_rsync_port"], direct = True), rsync_ssh_options(config["target_rsync_port"], direct=True),
'--list-only', '--list-only', '-r',
'-r', rsync_target.format(host=target.netloc, path=target_path)
rsync_target.format(
host=target.netloc,
path=target_path)
] ]
code, listing = shell('check_output', rsync_command, trap=True, capture_stderr=True) code, listing = shell('check_output',
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], int(match.groups()[0].replace(',',''))) ) ret.append((match.groups()[1],
int(match.groups()[0].replace(',', ''))))
return ret return ret
else: else:
if 'Permission denied (publickey).' in listing: if 'Permission denied (publickey).' in listing:
@ -429,14 +473,17 @@ 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(target.hostname) reason = "The IP address {} is unreachable.".format(
target.hostname)
elif 'Could not resolve hostname' in listing: elif 'Could not resolve hostname' in listing:
reason = "The hostname {} cannot be resolved.".format(target.hostname) reason = "The hostname {} cannot be resolved.".format(
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("Connection to rsync host failed: {}".format(reason)) raise ValueError(
"Connection to rsync host failed: {}".format(reason))
elif target.scheme == "s3": elif target.scheme == "s3":
# match to a Region # match to a Region
@ -457,7 +504,9 @@ def list_target_files(config):
# Create a custom region with custom endpoint # Create a custom region with custom endpoint
if custom_region: if custom_region:
from boto.s3.connection import S3Connection from boto.s3.connection import S3Connection
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=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 == '/':
@ -468,7 +517,8 @@ def list_target_files(config):
# connect to the region & bucket # connect to the region & bucket
try: try:
conn = region.connect(aws_access_key_id=config["target_user"], aws_secret_access_key=config["target_pass"]) conn = region.connect(aws_access_key_id=config["target_user"],
aws_secret_access_key=config["target_pass"])
bucket = conn.get_bucket(bucket) bucket = conn.get_bucket(bucket)
except BotoServerError as e: except BotoServerError as e:
if e.status == 403: if e.status == 403:
@ -479,7 +529,8 @@ def list_target_files(config):
raise ValueError("Incorrect region for this bucket.") raise ValueError("Incorrect region for this bucket.")
raise ValueError(e.reason) raise ValueError(e.reason)
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)] 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
@ -500,21 +551,26 @@ def list_target_files(config):
# 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(':')+1:target.netloc.index('@')] b2_application_key = target.netloc[target.netloc.index(':') +
b2_bucket = target.netloc[target.netloc.index('@')+1:] 1:target.netloc.index('@')]
b2_bucket = target.netloc[target.netloc.index('@') + 1:]
try: try:
b2_api.authorize_account("production", b2_application_keyid, b2_application_key) b2_api.authorize_account("production", b2_application_keyid,
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("B2 Bucket does not exist. Please double check your information!") raise ValueError(
"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:
raise ValueError(config["target"]) raise ValueError(config["target"])
def backup_set_custom(env, target, target_user, target_pass, target_rsync_port, min_age): def backup_set_custom(env, target, target_user, target_pass, target_rsync_port,
min_age):
config = get_backup_config(env, for_save=True) config = get_backup_config(env, for_save=True)
# min_age must be an int # min_age must be an int
@ -546,20 +602,19 @@ 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 = { config = {"min_age_in_days": 3, "target": "local", "target_rsync_port": 22}
"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(open(os.path.join(backup_root, 'custom.yaml'))) custom_config = rtyaml.load(
if not isinstance(custom_config, dict): raise ValueError() # caught below open(os.path.join(backup_root, 'custom.yaml')))
if not isinstance(custom_config, dict):
raise ValueError() # caught below
config.update(custom_config) config.update(custom_config)
except: except:
pass pass
@ -587,11 +642,13 @@ 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":
@ -601,7 +658,8 @@ 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(load_environment())): for fn, size in list_target_files(get_backup_config(
load_environment())):
print("{}\t{}".format(fn, size)) print("{}\t{}".format(fn, size))
elif sys.argv[-1] == "--status": elif sys.argv[-1] == "--status":

View file

@ -6,7 +6,14 @@
# 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, getpass, urllib.request, urllib.error, json, re, csv import sys
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.)
@ -14,7 +21,9 @@ def mgmt(cmd, data=None, is_json=False):
setup_key_auth(mgmt_uri) setup_key_auth(mgmt_uri)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None) req = urllib.request.Request(
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:
@ -23,16 +32,20 @@ def mgmt(cmd, data=None, is_json=False):
print(e.read().decode("utf8")) print(e.read().decode("utf8"))
except: except:
pass pass
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr) print(
"The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.",
file=sys.stderr)
elif hasattr(e, 'read'): 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: resp = json.loads(resp) if is_json:
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: ')
@ -46,47 +59,47 @@ def read_password():
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( auth_handler.add_password(realm='Mail-in-a-Box Management Server',
realm='Mail-in-a-Box Management Server',
uri=mgmt_uri, uri=mgmt_uri,
user=key, user=key,
passwd='') 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( """.format(cli="management/cli.py"))
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': continue if user['status'] == 'inactive':
continue
print(user['email'], end='') print(user['email'], end='')
if "admin" in user['privileges']: if "admin" in user['privileges']:
print("*", end='') print("*", end='')
@ -107,19 +120,25 @@ 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", "remove-admin") and len(sys.argv) == 4: elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin",
"remove-admin") and len(
sys.argv) == 4:
if sys.argv[2] == "make-admin": if sys.argv[2] == "make-admin":
action = "add" action = "add"
else: else:
action = "remove" action = "remove"
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" })) print(
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.
@ -135,36 +154,51 @@ 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", { "email": sys.argv[3], "quota": sys.argv[4] }) users = mgmt("/mail/users/quota", {
"email": sys.argv[3],
"quota": sys.argv[4]
})
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]: elif sys.argv[1] == "user" and len(
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(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]: elif sys.argv[1] == "user" and len(
sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
# Disable MFA (all or a particular device) for a user. # Disable MFA (all or a particular device) for a user.
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None })) print(
mgmt(
"/mfa/disable", {
"user": sys.argv[4],
"mfa-id": sys.argv[5] if len(sys.argv) == 6 else None
}))
elif sys.argv[1] == "alias" and len(sys.argv) == 2: elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases")) print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) print(
mgmt("/mail/aliases/add", {
"address": sys.argv[3],
"forwards_to": sys.argv[4]
}))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "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(sys.argv) == 3: elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
sys.argv) == 3:
print(mgmt("/system/default-quota?text=1")) print(mgmt("/system/default-quota?text=1"))
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 4: elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
print(mgmt("/system/default-quota", { "default_quota": sys.argv[3]})) sys.argv) == 4:
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

View file

@ -24,9 +24,17 @@
# create the new config file in memory # create the new config file in memory
import sys, re import sys
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):
found = set() found = set()
buf = "" buf = ""
input_lines = list(open(filename, "r+")) input_lines = list(open(filename, "r+"))
@ -45,25 +53,26 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
# Check that 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*)" "(\s*)" + "(" + re.escape(comment_char) + "\s*)?" +
+ "(" + re.escape(comment_char) + "\s*)?" re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
+ re.escape(name) + delimiter_re + "(.*?)\s*$", if not m:
line, re.S) continue
if not m: continue
indent, is_comment, existing_val = m.groups() indent, is_comment, existing_val = m.groups()
# If this is already the setting, do nothing. # If this is already the setting, do nothing.
if is_comment is None and existing_val == val: if is_comment is None and existing_val == val:
# 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: break if i in found:
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("\n", "\n" + comment_char) + "\n" buf += comment_char + line.rstrip().replace(
"\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
@ -97,11 +106,14 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
# 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("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]") print(
"usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]"
)
sys.exit(1) sys.exit(1)
# parse command line arguments # parse command line arguments
@ -140,4 +152,5 @@ 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, folded_lines, testing) edit_conf(filename, settings, delimiter_re, delimiter, comment_char,
folded_lines, testing)

View file

@ -39,17 +39,19 @@ 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", "noreply-daemon@" + env['PRIMARY_HOSTNAME']) msg['From'] = "\"%s\" <%s>" % ("System Management Daemon",
"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(html.escape(content)) content_html = "<html><body><pre>{}</pre></body></html>".format(
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

View file

@ -16,7 +16,6 @@ from dateutil.relativedelta import relativedelta
import utils import utils
LOG_FILES = ( LOG_FILES = (
'/var/log/mail.log.6.gz', '/var/log/mail.log.6.gz',
'/var/log/mail.log.5.gz', '/var/log/mail.log.5.gz',
@ -32,8 +31,7 @@ TIME_DELTAS = OrderedDict([
('month', datetime.timedelta(weeks=4)), ('month', datetime.timedelta(weeks=4)),
('2weeks', datetime.timedelta(days=14)), ('2weeks', datetime.timedelta(days=14)),
('week', datetime.timedelta(days=7)), ('week', datetime.timedelta(days=7)),
('2days', datetime.timedelta(days=2)), ('2days', datetime.timedelta(days=2)), ('day', datetime.timedelta(days=1)),
('day', datetime.timedelta(days=1)),
('12hours', datetime.timedelta(hours=12)), ('12hours', datetime.timedelta(hours=12)),
('6hours', datetime.timedelta(hours=6)), ('6hours', datetime.timedelta(hours=6)),
('hour', datetime.timedelta(hours=1)), ('hour', datetime.timedelta(hours=1)),
@ -41,7 +39,8 @@ TIME_DELTAS = OrderedDict([
('10min', datetime.timedelta(minutes=10)), ('10min', datetime.timedelta(minutes=10)),
('5min', datetime.timedelta(minutes=5)), ('5min', datetime.timedelta(minutes=5)),
('min', datetime.timedelta(minutes=1)), ('min', datetime.timedelta(minutes=1)),
('today', datetime.datetime.now() - datetime.datetime.now().replace(hour=0, minute=0, second=0)) ('today', datetime.datetime.now() -
datetime.datetime.now().replace(hour=0, minute=0, second=0))
]) ])
END_DATE = NOW = datetime.datetime.now() END_DATE = NOW = datetime.datetime.now()
@ -88,7 +87,6 @@ def scan_files(collector):
stop_scan = False stop_scan = False
def scan_mail_log(env): def scan_mail_log(env):
""" Scan the system's mail log files and collect interesting data """ Scan the system's mail log files and collect interesting data
@ -101,7 +99,8 @@ def scan_mail_log(env):
collector = { collector = {
"scan_count": 0, # Number of lines scanned "scan_count": 0, # Number of lines scanned
"parse_count": 0, # Number of lines parsed (i.e. that had their contents examined) "parse_count":
0, # Number of lines parsed (i.e. that had their contents examined)
"scan_time": time.time(), # The time in seconds the scan took "scan_time": time.time(), # The time in seconds the scan took
"sent_mail": OrderedDict(), # Data about email sent by users "sent_mail": OrderedDict(), # Data about email sent by users
"received_mail": OrderedDict(), # Data about email received by users "received_mail": OrderedDict(), # Data about email received by users
@ -114,14 +113,14 @@ def scan_mail_log(env):
try: try:
import mailconfig import mailconfig
collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) | collector["known_addresses"] = (
set(alias[0] for alias in mailconfig.get_mail_aliases(env))) set(mailconfig.get_mail_users(env))
| set(alias[0] for alias in mailconfig.get_mail_aliases(env)))
except ImportError: except ImportError:
pass pass
print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".format( print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".
START_DATE, END_DATE) format(START_DATE, END_DATE))
)
# Scan the lines in the log files until the date goes out of range # Scan the lines in the log files until the date goes out of range
scan_files(collector) scan_files(collector)
@ -132,7 +131,8 @@ def scan_mail_log(env):
collector["scan_time"] = time.time() - collector["scan_time"] collector["scan_time"] = time.time() - collector["scan_time"]
print("{scan_count} Log lines scanned, {parse_count} lines parsed in {scan_time:.2f} " print(
"{scan_count} Log lines scanned, {parse_count} lines parsed in {scan_time:.2f} "
"seconds\n".format(**collector)) "seconds\n".format(**collector))
# Print Sent Mail report # Print Sent Mail report
@ -141,7 +141,8 @@ def scan_mail_log(env):
msg = "Sent email" msg = "Sent email"
print_header(msg) print_header(msg)
data = OrderedDict(sorted(collector["sent_mail"].items(), key=email_sort)) data = OrderedDict(
sorted(collector["sent_mail"].items(), key=email_sort))
print_user_table( print_user_table(
data.keys(), data.keys(),
@ -165,10 +166,7 @@ def scan_mail_log(env):
for h in range(24): for h in range(24):
accum[h] = sum(d["activity-by-hour"][h] for d in data) accum[h] = sum(d["activity-by-hour"][h] for d in data)
print_time_table( print_time_table(["sent"], [accum])
["sent"],
[accum]
)
# Print Received Mail report # Print Received Mail report
@ -176,7 +174,8 @@ def scan_mail_log(env):
msg = "Received email" msg = "Received email"
print_header(msg) print_header(msg)
data = OrderedDict(sorted(collector["received_mail"].items(), key=email_sort)) data = OrderedDict(
sorted(collector["received_mail"].items(), key=email_sort))
print_user_table( print_user_table(
data.keys(), data.keys(),
@ -194,10 +193,7 @@ def scan_mail_log(env):
for h in range(24): for h in range(24):
accum[h] = sum(d["activity-by-hour"][h] for d in data.values()) accum[h] = sum(d["activity-by-hour"][h] for d in data.values())
print_time_table( print_time_table(["received"], [accum])
["received"],
[accum]
)
# Print login report # Print login report
@ -212,43 +208,54 @@ def scan_mail_log(env):
for u in data.values(): for u in data.values():
for protocol_name, count in u["totals_by_protocol"].items(): for protocol_name, count in u["totals_by_protocol"].items():
all_protocols[protocol_name] += count all_protocols[protocol_name] += count
all_protocols = [k for k, v in sorted(all_protocols.items(), key=lambda kv : -kv[1])] all_protocols = [
k for k, v in sorted(all_protocols.items(), key=lambda kv: -kv[1])
]
print_user_table( print_user_table(
data.keys(), data.keys(),
data=[ data=[
(protocol_name, [ (
round(u["totals_by_protocol"][protocol_name] / (u["latest"]-u["earliest"]).total_seconds() * 60*60, 1) protocol_name,
if (u["latest"]-u["earliest"]).total_seconds() > 0 [
else 0 # prevent division by zero round(
for u in data.values()]) u["totals_by_protocol"][protocol_name] /
for protocol_name in all_protocols (u["latest"] - u["earliest"]).total_seconds() *
60 * 60, 1) if
(u["latest"] - u["earliest"]).total_seconds() > 0 else
0 # prevent division by zero
for u in data.values()
]) for protocol_name in all_protocols
], ],
sub_data=[ sub_data=[("Protocol and Source", [[
("Protocol and Source", [[
"{} {}: {} times".format(protocol_name, host, count) "{} {}: {} times".format(protocol_name, host, count)
for (protocol_name, host), count for (protocol_name, host), count in sorted(
in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1]) u["totals_by_protocol_and_host"].items(),
] for u in data.values()]) key=lambda kv: -kv[1])
], ] for u in data.values()])],
activity=[ activity=[
(protocol_name, [u["activity-by-hour"][protocol_name] for u in data.values()]) (protocol_name,
[u["activity-by-hour"][protocol_name] for u in data.values()])
for protocol_name in all_protocols for protocol_name in all_protocols
], ],
earliest=[u["earliest"] for u in data.values()], earliest=[u["earliest"] for u in data.values()],
latest=[u["latest"] for u in data.values()], latest=[u["latest"] for u in data.values()],
numstr=lambda n : str(round(n, 1)), numstr=lambda n: str(round(n, 1)),
) )
accum = { protocol_name: defaultdict(int) for protocol_name in all_protocols } accum = {
protocol_name: defaultdict(int)
for protocol_name in all_protocols
}
for h in range(24): for h in range(24):
for protocol_name in all_protocols: for protocol_name in all_protocols:
accum[protocol_name][h] = sum(d["activity-by-hour"][protocol_name][h] for d in data.values()) accum[protocol_name][h] = sum(
d["activity-by-hour"][protocol_name][h]
for d in data.values())
print_time_table( print_time_table(
all_protocols, all_protocols,
[accum[protocol_name] for protocol_name in all_protocols] [accum[protocol_name] for protocol_name in all_protocols])
)
if collector["postgrey"]: if collector["postgrey"]:
msg = "Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}" msg = "Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}"
@ -257,10 +264,13 @@ def scan_mail_log(env):
print(textwrap.fill( print(textwrap.fill(
"The following mail was greylisted, meaning the emails were temporarily rejected. " "The following mail was greylisted, meaning the emails were temporarily rejected. "
"Legitimate senders must try again after three minutes.", "Legitimate senders must try again after three minutes.",
width=80, initial_indent=" ", subsequent_indent=" " width=80,
), end='\n\n') initial_indent=" ",
subsequent_indent=" "),
end='\n\n')
data = OrderedDict(sorted(collector["postgrey"].items(), key=email_sort)) data = OrderedDict(
sorted(collector["postgrey"].items(), key=email_sort))
users = [] users = []
received = [] received = []
senders = [] senders = []
@ -268,8 +278,10 @@ def scan_mail_log(env):
delivered_dates = [] delivered_dates = []
for recipient in data: for recipient in data:
sorted_recipients = sorted(data[recipient].items(), key=lambda kv: kv[1][0] or kv[1][1]) sorted_recipients = sorted(data[recipient].items(),
for (client_address, sender), (first_date, delivered_date) in sorted_recipients: key=lambda kv: kv[1][0] or kv[1][1])
for (client_address,
sender), (first_date, delivered_date) in sorted_recipients:
if first_date: if first_date:
users.append(recipient) users.append(recipient)
received.append(first_date) received.append(first_date)
@ -279,12 +291,10 @@ def scan_mail_log(env):
print_user_table( print_user_table(
users, users,
data=[ data=[("received", received), ("sender", senders),
("received", received), ("delivered",
("sender", senders), [str(d) or "no retry yet" for d in delivered_dates]),
("delivered", [str(d) or "no retry yet" for d in delivered_dates]), ("sending host", sender_clients)],
("sending host", sender_clients)
],
delimit=True, delimit=True,
) )
@ -292,7 +302,8 @@ def scan_mail_log(env):
msg = "Blocked Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}" msg = "Blocked Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}"
print_header(msg.format(START_DATE, END_DATE)) print_header(msg.format(START_DATE, END_DATE))
data = OrderedDict(sorted(collector["rejected"].items(), key=email_sort)) data = OrderedDict(
sorted(collector["rejected"].items(), key=email_sort))
rejects = [] rejects = []
@ -327,7 +338,8 @@ def scan_mail_log(env):
def scan_mail_log_line(line, collector): def scan_mail_log_line(line, collector):
""" Scan a log line and extract interesting data """ """ Scan a log line and extract interesting data """
m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)", line) m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)",
line)
if not m: if not m:
return True return True
@ -346,10 +358,11 @@ def scan_mail_log_line(line, collector):
# strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided. # strptime fails on Feb 29 with ValueError: day is out of range for month if correct year is not provided.
# See https://bugs.python.org/issue26460 # See https://bugs.python.org/issue26460
date = datetime.datetime.strptime(str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S') date = datetime.datetime.strptime(
str(NOW.year) + ' ' + date, '%Y %b %d %H:%M:%S')
# if log date in future, step back a year # if log date in future, step back a year
if date > NOW: if date > NOW:
date = date.replace(year = NOW.year - 1) date = date.replace(year=NOW.year - 1)
#print("date:", date) #print("date:", date)
# Check if the found date is within the time span we are scanning # Check if the found date is within the time span we are scanning
@ -375,8 +388,9 @@ def scan_mail_log_line(line, collector):
elif service == "postfix/smtpd": elif service == "postfix/smtpd":
if SCAN_BLOCKED: if SCAN_BLOCKED:
scan_postfix_smtpd_line(date, log, collector) scan_postfix_smtpd_line(date, log, collector)
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
"spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp", "postfix/scache", "spampd", "postfix/anvil",
"postfix/master", "opendkim", "postfix/lmtp",
"postfix/tlsmgr", "anvil"): "postfix/tlsmgr", "anvil"):
# nothing to look at # nothing to look at
return True return True
@ -391,9 +405,9 @@ def scan_mail_log_line(line, collector):
def scan_postgrey_line(date, log, collector): def scan_postgrey_line(date, log, collector):
""" Scan a postgrey log line and extract interesting data """ """ Scan a postgrey log line and extract interesting data """
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), " m = re.match(
"client_address=(.*), sender=(.*), recipient=(.*)", "action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
log) "client_address=(.*), sender=(.*), recipient=(.*)", log)
if m: if m:
@ -409,7 +423,8 @@ def scan_postgrey_line(date, log, collector):
# if len(addr) > 2: # if len(addr) > 2:
# client_name = '.'.join(addr[1:]) # client_name = '.'.join(addr[1:])
key = (client_address if client_name == 'unknown' else client_name, sender) key = (client_address if client_name == 'unknown' else client_name,
sender)
rep = collector["postgrey"].setdefault(user, {}) rep = collector["postgrey"].setdefault(user, {})
@ -424,7 +439,8 @@ def scan_postfix_smtpd_line(date, log, collector):
# Check if the incoming mail was rejected # Check if the incoming mail was rejected
m = re.match("NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log) m = re.match(
"NOQUEUE: reject: RCPT from .*?: (.*?); from=<(.*?)> to=<(.*?)>", log)
if m: if m:
message, sender, user = m.groups() message, sender, user = m.groups()
@ -435,26 +451,24 @@ def scan_postfix_smtpd_line(date, log, collector):
# only log mail to known recipients # only log mail to known recipients
if user_match(user): if user_match(user):
if collector["known_addresses"] is None or user in collector["known_addresses"]: if collector["known_addresses"] is None or user in collector[
data = collector["rejected"].get( "known_addresses"]:
user, data = collector["rejected"].get(user, {
{
"blocked": [], "blocked": [],
"earliest": None, "earliest": None,
"latest": None, "latest": None,
} })
)
# simplify this one # simplify this one
m = re.search( m = re.search(
r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)",
) message)
if m: if m:
message = "ip blocked: " + m.group(2) message = "ip blocked: " + m.group(2)
else: else:
# simplify this one too # simplify this one too
m = re.search( m = re.search(
r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)",
) message)
if m: if m:
message = "domain blocked: " + m.group(2) message = "domain blocked: " + m.group(2)
@ -482,15 +496,13 @@ def scan_dovecot_login_line(date, log, collector, protocol_name):
def add_login(user, date, protocol_name, host, collector): def add_login(user, date, protocol_name, host, collector):
# Get the user data, or create it if the user is new # Get the user data, or create it if the user is new
data = collector["logins"].get( data = collector["logins"].get(
user, user, {
{
"earliest": None, "earliest": None,
"latest": None, "latest": None,
"totals_by_protocol": defaultdict(int), "totals_by_protocol": defaultdict(int),
"totals_by_protocol_and_host": defaultdict(int), "totals_by_protocol_and_host": defaultdict(int),
"activity-by-hour": defaultdict(lambda : defaultdict(int)), "activity-by-hour": defaultdict(lambda: defaultdict(int)),
} })
)
if data["earliest"] is None: if data["earliest"] is None:
data["earliest"] = date data["earliest"] = date
@ -521,14 +533,12 @@ def scan_postfix_lmtp_line(date, log, collector):
if user_match(user): if user_match(user):
# Get the user data, or create it if the user is new # Get the user data, or create it if the user is new
data = collector["received_mail"].get( data = collector["received_mail"].get(
user, user, {
{
"received_count": 0, "received_count": 0,
"earliest": None, "earliest": None,
"latest": None, "latest": None,
"activity-by-hour": defaultdict(int), "activity-by-hour": defaultdict(int),
} })
)
data["received_count"] += 1 data["received_count"] += 1
data["activity-by-hour"][date.hour] += 1 data["activity-by-hour"][date.hour] += 1
@ -551,7 +561,9 @@ def scan_postfix_submission_line(date, log, collector):
# Match both the 'plain' and 'login' sasl methods, since both authentication methods are # Match both the 'plain' and 'login' sasl methods, since both authentication methods are
# allowed by Dovecot. Exclude trailing comma after the username when additional fields # allowed by Dovecot. Exclude trailing comma after the username when additional fields
# follow after. # follow after.
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log) m = re.match(
"([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)",
log)
if m: if m:
_, client, method, user = m.groups() _, client, method, user = m.groups()
@ -559,15 +571,13 @@ def scan_postfix_submission_line(date, log, collector):
if user_match(user): if user_match(user):
# Get the user data, or create it if the user is new # Get the user data, or create it if the user is new
data = collector["sent_mail"].get( data = collector["sent_mail"].get(
user, user, {
{
"sent_count": 0, "sent_count": 0,
"hosts": set(), "hosts": set(),
"earliest": None, "earliest": None,
"latest": None, "latest": None,
"activity-by-hour": defaultdict(int), "activity-by-hour": defaultdict(int),
} })
)
data["sent_count"] += 1 data["sent_count"] += 1
data["hosts"].add(client) data["hosts"].add(client)
@ -582,8 +592,10 @@ def scan_postfix_submission_line(date, log, collector):
# Also log this as a login. # Also log this as a login.
add_login(user, date, "smtp", client, collector) add_login(user, date, "smtp", client, collector)
# Utility functions # Utility functions
def readline(filename): def readline(filename):
""" A generator that returns the lines of a file """ A generator that returns the lines of a file
""" """
@ -610,12 +622,14 @@ def valid_date(string):
try: try:
date = dateutil.parser.parse(string) date = dateutil.parser.parse(string)
except ValueError: except ValueError:
raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" % string) raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" %
string)
return date return date
# Print functions # Print functions
def print_time_table(labels, data, do_print=True): def print_time_table(labels, data, do_print=True):
labels.insert(0, "hour") labels.insert(0, "hour")
data.insert(0, [str(h) for h in range(24)]) data.insert(0, [str(h) for h in range(24)])
@ -642,8 +656,14 @@ def print_time_table(labels, data, do_print=True):
return lines return lines
def print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None, def print_user_table(users,
delimit=False, numstr=str): data=None,
sub_data=None,
activity=None,
latest=None,
earliest=None,
delimit=False,
numstr=str):
str_temp = "{:<32} " str_temp = "{:<32} "
lines = [] lines = []
data = data or [] data = data or []
@ -668,7 +688,8 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
for col, (l, d) in enumerate(data): for col, (l, d) in enumerate(data):
if isinstance(d[row], str): if isinstance(d[row], str):
col_str = str_temp.format(d[row][:31] + "" if len(d[row]) > 32 else d[row]) col_str = str_temp.format(d[row][:31] +
"" if len(d[row]) > 32 else d[row])
col_left[col] = True col_left[col] = True
elif isinstance(d[row], datetime.datetime): elif isinstance(d[row], datetime.datetime):
col_str = "{:<20}".format(str(d[row])) col_str = "{:<20}".format(str(d[row]))
@ -722,11 +743,10 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
lines.append("" + (max_len + 1) * "") lines.append("" + (max_len + 1) * "")
if activity is not None: if activity is not None:
lines.extend(print_time_table( lines.extend(
[label for label, _ in activity], print_time_table([label for label, _ in activity],
[data[row] for _, data in activity], [data[row] for _, data in activity],
do_print=False do_print=False))
))
except KeyError: except KeyError:
pass pass
@ -750,7 +770,8 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
if vert_pos: if vert_pos:
t_line = t_line[:vert_pos + 1] + "" + t_line[vert_pos + 2:] t_line = t_line[:vert_pos + 1] + "" + t_line[vert_pos + 2:]
b_line = b_line[:vert_pos + 1] + ("" if VERBOSE else "") + b_line[vert_pos + 2:] b_line = b_line[:vert_pos +
1] + ("" if VERBOSE else "") + b_line[vert_pos + 2:]
lines.insert(1, t_line) lines.insert(1, t_line)
lines.append(b_line) lines.append(b_line)
@ -798,43 +819,72 @@ if __name__ == "__main__":
env_vars = {} env_vars = {}
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Scan the mail log files for interesting data. By default, this script " description=
"Scan the mail log files for interesting data. By default, this script "
"shows today's incoming and outgoing mail statistics. This script was (" "shows today's incoming and outgoing mail statistics. This script was ("
"re)written for the Mail-in-a-box email server." "re)written for the Mail-in-a-box email server."
"https://github.com/mail-in-a-box/mailinabox", "https://github.com/mail-in-a-box/mailinabox",
add_help=False add_help=False)
)
# Switches to determine what to parse and what to ignore # Switches to determine what to parse and what to ignore
parser.add_argument("-r", "--received", help="Scan for received emails.", parser.add_argument("-r",
"--received",
help="Scan for received emails.",
action="store_true") action="store_true")
parser.add_argument("-s", "--sent", help="Scan for sent emails.", parser.add_argument("-s",
"--sent",
help="Scan for sent emails.",
action="store_true") action="store_true")
parser.add_argument("-l", "--logins", help="Scan for user logins to IMAP/POP3.", parser.add_argument("-l",
"--logins",
help="Scan for user logins to IMAP/POP3.",
action="store_true") action="store_true")
parser.add_argument("-g", "--grey", help="Scan for greylisted emails.", parser.add_argument("-g",
"--grey",
help="Scan for greylisted emails.",
action="store_true") action="store_true")
parser.add_argument("-b", "--blocked", help="Scan for blocked emails.", parser.add_argument("-b",
"--blocked",
help="Scan for blocked emails.",
action="store_true") action="store_true")
parser.add_argument("-t", "--timespan", choices=TIME_DELTAS.keys(), default='today', parser.add_argument(
"-t",
"--timespan",
choices=TIME_DELTAS.keys(),
default='today',
metavar='<time span>', metavar='<time span>',
help="Time span to scan, going back from the end date. Possible values: " help="Time span to scan, going back from the end date. Possible values: "
"{}. Defaults to 'today'.".format(", ".join(list(TIME_DELTAS.keys())))) "{}. Defaults to 'today'.".format(", ".join(list(TIME_DELTAS.keys()))))
# keep the --startdate arg for backward compatibility # keep the --startdate arg for backward compatibility
parser.add_argument("-d", "--enddate", "--startdate", action="store", dest="enddate", parser.add_argument(
type=valid_date, metavar='<end date>', "-d",
"--enddate",
"--startdate",
action="store",
dest="enddate",
type=valid_date,
metavar='<end date>',
help="Date and time to end scanning the log file. If no date is " help="Date and time to end scanning the log file. If no date is "
"provided, scanning will end at the current date and time. " "provided, scanning will end at the current date and time. "
"Alias --startdate is for compatibility.") "Alias --startdate is for compatibility.")
parser.add_argument("-u", "--users", action="store", dest="users", parser.add_argument(
"-u",
"--users",
action="store",
dest="users",
metavar='<email1,email2,email...>', metavar='<email1,email2,email...>',
help="Comma separated list of (partial) email addresses to filter the " help="Comma separated list of (partial) email addresses to filter the "
"output with.") "output with.")
parser.add_argument('-h', '--help', action='help', help="Print this message and exit.") parser.add_argument('-h',
parser.add_argument("-v", "--verbose", help="Output extra data where available.", '--help',
action='help',
help="Print this message and exit.")
parser.add_argument("-v",
"--verbose",
help="Output extra data where available.",
action="store_true") action="store_true")
args = parser.parse_args() args = parser.parse_args()

View file

@ -9,11 +9,16 @@
# 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, shutil, os, sqlite3, re import subprocess
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
@ -31,8 +36,7 @@ def validate_email(email, mode=None):
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
@ -45,13 +49,15 @@ 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: return False if len(email) > 255:
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 ---
@ -72,6 +78,7 @@ 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.
@ -84,13 +91,17 @@ 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", "webmaster", "abuse"): for localpart in ("admin", "administrator", "postmaster", "hostmaster",
if email.startswith(localpart+"@") or email.startswith(localpart+"+"): "webmaster", "abuse"):
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:
@ -98,15 +109,17 @@ 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)
@ -117,6 +130,7 @@ 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.
@ -150,7 +164,9 @@ def get_mail_users_ex(env, with_archived=False):
box_quota = 0 box_quota = 0
percent = '' percent = ''
try: try:
dirsize_file = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes/%s/%s/maildirsize' % (domain, user)) dirsize_file = os.path.join(
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():
@ -178,7 +194,8 @@ def get_mail_users_ex(env, with_archived=False):
"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": '%3.0f%%' % percent if type(percent) != str else percent, "percent":
'%3.0f%%' % percent if type(percent) != str else percent,
"box_count": box_count, "box_count": box_count,
"status": "active", "status": "active",
} }
@ -192,7 +209,8 @@ 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: continue if email in active_accounts:
continue
user = { user = {
"email": email, "email": email,
"privileges": [], "privileges": [],
@ -206,25 +224,26 @@ def get_mail_users_ex(env, with_archived=False):
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] = { domains[domain] = {"domain": domain, "users": []}
"domain": domain,
"users": []
}
domains[domain]["users"].append(user) domains[domain]["users"].append(user)
# Sort domains. # Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)] domains = [
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
]
# Sort 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(key = lambda user : (user["status"] != "active", user["email"])) domain["users"].sort(
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()
@ -234,16 +253,23 @@ 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('SELECT source, destination, permitted_senders, 0 as auto FROM aliases UNION SELECT source, destination, permitted_senders, 1 as auto FROM auto_aliases') c.execute(
aliases = { row[0]: row for row in c.fetchall() } # make dict '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
# 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[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] aliases = [
aliases[address]
for address in utils.sort_email_addresses(aliases.keys(), env)
]
return aliases return aliases
def get_mail_aliases_ex(env): def get_mail_aliases_ex(env):
# 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.
@ -268,7 +294,8 @@ 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("@"): continue if auto and address.startswith("@"):
continue
# get alias info # get alias info
domain = get_domain(address) domain = get_domain(address)
@ -280,28 +307,42 @@ def get_mail_aliases_ex(env):
"aliases": [], "aliases": [],
} }
domains[domain]["aliases"].append({ domains[domain]["aliases"].append({
"address": address, "address":
"address_display": prettify_idn_email_address(address), address,
"forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(",")], "address_display":
"permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None, prettify_idn_email_address(address),
"auto": bool(auto), "forwards_to": [
prettify_idn_email_address(r.strip())
for r in forwards_to.split(",")
],
"permitted_senders": [
prettify_idn_email_address(s.strip())
for s in permitted_senders.split(",")
] if permitted_senders is not None else None,
"auto":
bool(auto),
}) })
# Sort domains. # Sort domains.
domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)] domains = [
domains[domain] for domain in utils.sort_domains(domains.keys(), env)
]
# Sort aliases within each domain first by required-ness then lexicographically by address. # Sort aliases within each domain first by required-ness then lexicographically by address.
for domain in domains: for domain in domains:
domain["aliases"].sort(key = lambda alias : (alias["auto"], alias["address"])) domain["aliases"].sort(
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
@ -316,6 +357,7 @@ 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)
@ -328,18 +370,28 @@ 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([get_domain(login, as_unicode=False) for login in get_mail_users(env)]) domains.extend(
[get_domain(login, as_unicode=False) for login in get_mail_users(env)])
if not users_only: if not users_only:
domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ]) domains.extend([
domains.extend([get_domain(address, as_unicode=False) for address in get_noreply_addresses(env)]) get_domain(address, as_unicode=False)
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() == "":
@ -347,12 +399,16 @@ 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 ("User account email addresses may only use the lowercase ASCII letters a-z, the digits 0-9, underscore (_), hyphen (-), and period (.).", 400) return (
"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 ("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) 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)
# validate password # validate password
validate_password(pw) validate_password(pw)
@ -364,7 +420,8 @@ 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: return validation if validation:
return validation
if quota is None: if quota is None:
quota = get_default_quota() quota = get_default_quota()
@ -382,7 +439,8 @@ 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("INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)", c.execute(
"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)
@ -395,6 +453,7 @@ 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)
@ -410,16 +469,19 @@ 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('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip() return utils.shell(
'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)
@ -442,6 +504,7 @@ 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
@ -452,10 +515,12 @@ 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()
@ -463,28 +528,31 @@ 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("Quotas cannot contain spaces, commas, or decimal points.") raise ValueError(
"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()
@ -492,32 +560,39 @@ 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: return [] if empty_on_error:
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: return validation if 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): return privs # error if isinstance(privs, tuple):
return privs # error
# update privs set # update privs set
if action == "add": if action == "add":
@ -530,14 +605,21 @@ 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=?", ("\n".join(privs), email)) c.execute("UPDATE users SET privileges=? WHERE 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)
@ -574,17 +656,23 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
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 == "": continue if email == "":
continue
email = sanitize_idn_email_address(email) # Unicode => IDNA email = sanitize_idn_email_address(email) # Unicode => IDNA
# Strip any +tag from email alias and check privileges # 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, 400) return ("Invalid receiver email address (%s)." % email,
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): 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):
# Make domain control validation hijacking a little harder to mess up by # Make domain control validation hijacking a little harder to mess up by
# requiring aliases for email addresses typically used in DCV to forward # requiring aliases for email addresses typically used in DCV to forward
# only to accounts that are administrators on this system. # only to accounts that are administrators on this system.
return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400) return (
"This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.",
400)
validated_forwards_to.append(email) validated_forwards_to.append(email)
# validate permitted_senders # validate permitted_senders
@ -597,14 +685,19 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
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 == "": continue if login == "":
continue
if login not in valid_logins: if login not in valid_logins:
return ("Invalid permitted sender: %s is not a user on this system." % login, 400) return (
"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 ("The alias must either forward to an address or have a permitted sender.", 400) return (
"The alias must either forward to an address or have a permitted sender.",
400)
# save to db # save to db
@ -617,13 +710,17 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
try: try:
c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders)) c.execute(
"INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)",
(address, forwards_to, permitted_senders))
return_status = "alias added" return_status = "alias added"
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
if not update_if_exists: if not update_if_exists:
return ("Alias already exists (%s)." % address, 400) return ("Alias already exists (%s)." % address, 400)
else: else:
c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address)) c.execute(
"UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?",
(forwards_to, permitted_senders, address))
return_status = "alias updated" return_status = "alias updated"
conn.commit() conn.commit()
@ -632,13 +729,14 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
# 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()
@ -647,16 +745,21 @@ 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("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination)) c.execute(
"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()
@ -669,13 +772,11 @@ 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(env, real_mail_domains = get_mail_domains(
filter_aliases = lambda alias : env,
not alias.startswith("postmaster@") filter_aliases=lambda alias: not alias.startswith("postmaster@") and
and not alias.startswith("admin@") not alias.startswith("admin@") and not alias.startswith(
and not alias.startswith("abuse@") "abuse@") and not alias.startswith("@"))
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.
@ -689,6 +790,7 @@ 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
@ -708,7 +810,7 @@ def add_noreply_address(env, address, do_kick=True):
# 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:
@ -719,12 +821,13 @@ def add_noreply_address(env, address, do_kick=True):
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()
@ -733,8 +836,10 @@ 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 = []
@ -744,7 +849,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)
@ -752,14 +857,16 @@ 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: continue # don't make an alias from the administrator to itself --- this alias must be created manually if alias == administrator:
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: continue # not an IDNA/Unicode domain if domain == domain_unicode:
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
@ -778,18 +885,21 @@ def kick(env, mail_result=None):
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("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to)) results.append(
"removed alias %s (was to %s; domain no longer used for email)\n"
% (address, forwards_to))
# Update DNS and nginx in case any domains are added/removed. # Update DNS and nginx in case any domains are added/removed.
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() == "":
@ -797,6 +907,7 @@ 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":

View file

@ -7,33 +7,46 @@ 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: raise ValueError("User does not exist.") if not r:
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('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),)) c.execute(
return [ 'SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?',
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] } (get_user_id(email, c), ))
for r in c.fetchall() return [{
] "id": r[0],
"type": r[1],
"secret": r[2],
"mru_token": r[3],
"label": r[4]
} for r in c.fetchall()]
def get_public_mfa_state(email, env): 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"], "type": s["type"], "label": s["label"] } "id": s["id"],
for s in mfa_state "type": s["type"],
] "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"], "type": s["type"], "secret": s["secret"] } "id": s["id"],
for s in mfa_state "type": s["type"],
] "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":
@ -46,31 +59,39 @@ 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('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) c.execute(
'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=?', (token, get_user_id(email, c), mfa_id)) c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND 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=?', (get_user_id(email, c), mfa_id)) c.execute('DELETE FROM mfa WHERE user_id=? AND 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')
@ -79,8 +100,7 @@ def provision_totp(email, env):
# 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)
@ -88,11 +108,8 @@ 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 { return {"type": "totp", "secret": secret, "qr_code_base64": png_b64}
"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

View file

@ -1,7 +1,11 @@
#!/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, utils, datetime, shutil, tempfile import gpg
import utils
import datetime
import shutil
import tempfile
env = utils.load_environment() env = utils.load_environment()
@ -11,6 +15,8 @@ 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
@ -23,26 +29,43 @@ def key_representation(key):
} }
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]
key_rep["ids_emails"] = list({ id.email for id in key.uids }) # No duplicate email addresses in this list # 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:
@ -52,9 +75,13 @@ def contains_private_keys(imports):
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:
@ -65,15 +92,18 @@ 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, f"{tmpdir}/gnupg", ignore=dirs_files_only) shutil.copytree(context.home_dir,
kwargs["context"] = gpg.Context(armor=context.armor, home_dir=f"{tmpdir}/gnupg") 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:
@ -81,49 +111,60 @@ 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):
def get_daemon_key(context=default_context):
if daemon_key_fpr is None or daemon_key_fpr == "": if daemon_key_fpr is None or daemon_key_fpr == "":
return None return None
return context.get_key(daemon_key_fpr, secret=True) return context.get_key(daemon_key_fpr, secret=True)
def get_imported_keys(context = default_context):
def get_imported_keys(context=default_context):
# All the keys in the keyring, except for the daemon's key # All the keys in the keyring, except for the daemon's key
return list( return list(
filter( filter(lambda k: k.fpr != daemon_key_fpr,
lambda k: k.fpr != daemon_key_fpr, context.keylist(secret=False)))
context.keylist(secret=False)
)
)
def import_key(key, context = default_context):
def import_key(key, context=default_context):
data = str.encode(key) data = str.encode(key)
if contains_private_keys(data): if contains_private_keys(data):
raise ValueError("Import cannot contain private keys!") raise ValueError("Import cannot contain private keys!")
return context.key_import(data) return context.key_import(data)
def export_key(fingerprint, context = default_context):
def export_key(fingerprint, context=default_context):
if get_key(fingerprint) is None: if get_key(fingerprint) is None:
return None return None
return context.key_export(pattern=fingerprint) # Key does exist, export it! # Key does exist, export it!
return context.key_export(pattern=fingerprint)
def delete_key(fingerprint, context = default_context):
def delete_key(fingerprint, context=default_context):
key = get_key(fingerprint) key = get_key(fingerprint)
if fingerprint == daemon_key_fpr: if fingerprint == daemon_key_fpr:
raise ValueError("You cannot delete the daemon's key!") raise ValueError("You cannot delete the daemon's key!")
elif key is None: elif key is None:
return None return None
context.op_delete_ext(key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE) context.op_delete_ext(
key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
return True 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):
signed_data, _ = context.sign(data,
mode=gpg.constants.sig.mode.DETACH if
detached else gpg.constants.sig.mode.CLEAR)
return signed_data return signed_data
if __name__ == "__main__": if __name__ == "__main__":
import sys, utils import sys
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()

View file

@ -1,13 +1,19 @@
#!/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, os.path, re, shutil, subprocess, tempfile import os
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
@ -44,8 +50,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
@ -70,7 +76,7 @@ def get_ssl_certificates(env):
certificates.append(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_domains, primary_domain = get_certificate_domains(cert)
@ -87,7 +93,8 @@ def get_ssl_certificates(env):
# 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)
@ -95,10 +102,11 @@ 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.not_valid_before <= now <= cert.not_valid_after, cert.not_valid_before <= now <= cert.not_valid_after,
@ -129,8 +137,8 @@ def get_ssl_certificates(env):
# 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,
@ -141,16 +149,24 @@ def get_ssl_certificates(env):
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(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem')) ssl_private_key = os.path.join(
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.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'))
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": load_pem(load_cert_chain(ssl_certificate)[0]), "certificate_object":
load_pem(load_cert_chain(ssl_certificate)[0]),
} }
if use_main_cert: if use_main_cert:
@ -174,7 +190,10 @@ def get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=False
# 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:
@ -192,7 +211,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
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.
@ -201,7 +220,8 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# 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[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)." domains_cant_provision[
domain] = "The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn't here)."
# Check that the DNS resolves to here. # Check that the DNS resolves to here.
else: else:
@ -211,8 +231,10 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# 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"]), ("AAAA", env.get("PUBLIC_IPV6"))]: for rtype, value in [("A", env["PUBLIC_IP"]),
if not value: continue # IPv6 is not configured ("AAAA", env.get("PUBLIC_IPV6"))]:
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))
@ -226,13 +248,21 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# 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_certs, env, use_main_cert=False, allow_missing_cert=True) existing_cert = get_domain_ssl_files(domain,
existing_certs,
env,
use_main_cert=False,
allow_missing_cert=True)
if existing_cert: if existing_cert:
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'], existing_cert_check = check_certificate(
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[domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format( domains_cant_provision[
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'])
@ -242,10 +272,12 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
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(env, limit_domains=limit_domains) domains, domains_cant_provision = get_certificates_to_provision(
env, limit_domains=limit_domains)
# Build a list of what happened on each domain or domain-set. # Build a list of what happened on each domain or domain-set.
ret = [] ret = []
@ -267,7 +299,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):
@ -308,7 +340,8 @@ 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', 'ssl_private_key.pem') key_file = os.path.join(env['STORAGE_ROOT'], 'ssl',
'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
@ -326,12 +359,18 @@ 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(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ])) builder = builder.subject_name(
builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0])
]))
builder = builder.add_extension(x509.BasicConstraints(
ca=False, path_length=None),
critical=True)
builder = builder.add_extension(x509.SubjectAlternativeName( 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]), hashes.SHA256(), default_backend()) request = builder.sign(load_pem(load_cert_chain(key_file)[0]),
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))
@ -340,27 +379,40 @@ 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 " + ", ".join(domain_list) + ".") print("Provisioning TLS certificates for " +
certbotret = subprocess.check_output([ ", ".join(domain_list) + ".")
certbotret = subprocess.check_output(
[
"certbot", "certbot",
"certonly", "certonly",
#"-v", # just enough to see ACME errors # "-v", # just enough to see ACME errors
"--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup "--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup
"--agree-tos", # Automatically agrees to Let's Encrypt TOS "--agree-tos", # Automatically agrees to Let's Encrypt TOS
"--register-unsafely-without-email", # The daemon takes care of renewals "--register-unsafely-without-email", # The daemon takes care of renewals
"-d", ",".join(domain_list), # first will be main domain # first will be main domain
"-d",
",".join(domain_list),
"--csr", csr_file.name, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
"--cert-path", os.path.join(d, 'cert'), # we only use the full chain "--csr",
"--chain-path", os.path.join(d, 'chain'), # we only use the full chain csr_file.name,
"--fullchain-path", cert_file, # we only use the full chain
"--cert-path",
"--webroot", "--webroot-path", webroot, os.path.join(d, 'cert'),
# we only use the full chain
"--config-dir", account_path, "--chain-path",
#"--staging", os.path.join(d, 'chain'),
], stderr=subprocess.STDOUT).decode("utf8") "--fullchain-path",
cert_file,
"--webroot",
"--webroot-path",
webroot,
"--config-dir",
account_path,
# "--staging",
],
stderr=subprocess.STDOUT).decode("utf8")
install_cert_copy_file(cert_file, env) install_cert_copy_file(cert_file, env)
ret[-1]["log"].append(certbotret) ret[-1]["log"].append(certbotret)
@ -378,6 +430,7 @@ 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
@ -414,12 +467,13 @@ 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", "openssl", "req", "-new", "-key", ssl_key, "-sha256", "-subj",
"-key", ssl_key, "/C=%s/CN=%s" % (country_code, domain)
"-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.
@ -430,8 +484,10 @@ 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(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem')) ssl_private_key = os.path.join(
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key) os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
cert_status, cert_status_details = check_certificate(
domain, fn, ssl_private_key)
if cert_status != "OK": if cert_status != "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."
@ -445,7 +501,8 @@ 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: return ret if raw:
return ret
return "\n".join(ret) return "\n".join(ret)
@ -457,11 +514,15 @@ 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" % (
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 # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date safe_domain_name(cn),
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix cert.not_valid_after.date().isoformat().replace("-",
""), # expiration date
hexlify(cert.fingerprint(
hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
) )
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path)) 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)
@ -473,16 +534,21 @@ 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'], ssl_certificates, env, use_main_cert=False) cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'],
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 " + env['PRIMARY_HOSTNAME']) ret.append("there is no valid certificate for " +
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(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem')) system_ssl_certificate = os.path.join(
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
if cert and os.readlink(system_ssl_certificate) != cert['certificate']: if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
# Update symlink. # Update symlink.
ret.append("updating primary certificate") ret.append("updating primary certificate")
@ -501,13 +567,20 @@ 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.
@ -520,9 +593,11 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
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): raise ValueError("This is not a certificate file.") if not isinstance(cert, Certificate):
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), None) return ("There is a problem with the certificate file: %s" % str(e),
None)
# First check that the domain name is one of the names allowed by # First check that the domain name is one of the names allowed by
# the certificate. # the certificate.
@ -534,21 +609,27 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# 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 ("The certificate is for the wrong domain name. It is for %s." return (
% ", ".join(sorted(certificate_names)), None) "The certificate is for the wrong domain name. It is for %s." %
", ".join(sorted(certificate_names)), None)
# Second, check that the certificate matches the private key. # 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" % (ssl_private_key, str(e)), None) return ("The private key file %s is not a private key file: %s" %
(ssl_private_key, str(e)), None)
if not isinstance(priv_key, RSAPrivateKey): if not isinstance(priv_key, RSAPrivateKey):
return ("The private key file %s is not a private key file." % ssl_private_key, None) return ("The private key file %s is not a private key file." %
ssl_private_key, None)
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers(): if priv_key.public_key().public_numbers() != cert.public_key(
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None) ).public_numbers():
return (
"The certificate does not correspond to the private key at %s."
% ssl_private_key, None)
# We could also use the openssl command line tool to get the modulus # 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".
@ -569,8 +650,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# 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 ("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) return (
"The certificate has expired or is not yet valid. It is valid from %s to %s."
% (cert.not_valid_before, cert.not_valid_after), None)
# Next validate that the certificate is valid. This checks whether the certificate # 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
@ -579,11 +662,17 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# 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('check_output', [ retcode, verifyoutput = shell(
'check_output',
[
"openssl", "openssl",
"verify", "-verbose", "verify",
"-purpose", "sslserver", "-policy_check",] "-verbose",
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"]) "-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)
@ -594,10 +683,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
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 ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None) return (
"The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)"
% verifyoutput, None)
# There is some unknown problem. Return the `openssl verify` raw output. # There is some unknown problem. Return the `openssl verify` raw output.
return ("There is a problem with the certificate.", verifyoutput.strip()) return ("There is a problem with the certificate.",
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
@ -605,13 +697,15 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# 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." % (ndays, cert_expiration_date.date().isoformat()) expiry_info = "The certificate expires in %d days on %s." % (
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().isoformat() expiry_info = "The certificate expires on %s." % cert_expiration_date.date(
).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
@ -621,6 +715,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
# 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.
@ -632,6 +727,7 @@ def load_cert_chain(pemfile):
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.
@ -643,10 +739,14 @@ 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, password=None, backend=default_backend()) return serialization.load_pem_private_key(pem,
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: " + pem_type.decode("ascii", "replace")) raise ValueError("Unsupported PEM object type: " +
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
@ -675,7 +775,8 @@ 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(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName) sans = cert.extensions.get_extension_for_oid(
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:
@ -683,6 +784,7 @@ 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

View file

@ -1,20 +1,28 @@
<style> <style>
#alias_table .alias-auto .actions > * { display: none } #alias_table .alias-auto .actions>* {
#addalias-form .hidden { display: none; } display: none
.btn.btn-xs { }
#addalias-form .hidden {
display: none;
}
.btn.btn-xs {
padding: .1rem .3rem; padding: .1rem .3rem;
font-size: .75rem; font-size: .75rem;
border-radius: .2rem; 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 user</a> or to any email address.</p> <p>Aliases are email forwarders. An alias can forward email to a <a href="#" onclick="return show_panel('users')">mail
user</a> or to any email address.</p>
<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> <p>To use an alias or any address besides your own login username in outbound mail, the sending user must be included as
a permitted sender for the alias.</p>
<form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;"> <form id="addalias-form" class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
@ -26,7 +34,8 @@
</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 another domain, preserving the part before the @-sign.</span> <span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to
another domain, preserving the part before the @-sign.</span>
</div> </div>
</div> </div>
@ -47,8 +56,12 @@
<label for="addaliasForwardsTo">Forwards To</label> <label for="addaliasForwardsTo">Forwards To</label>
</div> </div>
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%"> <div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
<span class="domainalias text-muted">Enter just the part of an email address starting with the @-sign.</span> <span class="domainalias text-muted">Enter just the part of an email address starting with the
<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> @-sign.</span>
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded
by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other
domains, create a mail user and then log into webmail for the user and create a filter rule to forward
mail.</span>
</div> </div>
</div> </div>
@ -59,20 +72,27 @@
<div class="mb-3"> <div class="mb-3">
<div class="radio"> <div class="radio">
<label> <label>
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio" checked onclick="$('#addaliasForwardsToDiv').toggle(false)"> <input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio"
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>. checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
Any mail user listed in the Forwards To box can send mail claiming to be from <span
class="regularalias">the alias address</span><span class="catchall domainalias">any address
on the alias domain</span>.
</label> </label>
</div> </div>
<div class="radio"> <div class="radio">
<label> <label>
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)"> <input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio"
I&rsquo;ll enter the mail users that can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>. id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
I&rsquo;ll enter the mail users that can send mail claiming to be from <span
class="regularalias">the alias address</span><span class="catchall domainalias">any address
on the alias domain</span>.
</label> </label>
</div> </div>
</div> </div>
<div id="addaliasForwardsToDiv" style="display: none;"> <div id="addaliasForwardsToDiv" style="display: none;">
<div class="form-floating"> <div class="form-floating">
<textarea class="form-control" style="min-height: 8em;" id="addaliasSenders" placeholder="one user per line or separated by commas"></textarea> <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> <label for="addaliasSenders">Permitted Senders</label>
</div> </div>
<small>One user per line or separated by commas</small> <small>One user per line or separated by commas</small>
@ -97,16 +117,19 @@
</tbody> </tbody>
</table> </table>
<p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some domains.</small></p> <p style="margin-top: 1.5em"><small>hostmaster@, postmaster@, admin@ and abuse@ email addresses are required on some
domains.</small></p>
<div style="display: none"> <div style="display: none">
<table> <table>
<tr id="alias-template"> <tr id="alias-template">
<td class='actions row justify-content-evenly' style="min-width: 4em; border: none;"> <td class='actions row justify-content-evenly' style="min-width: 4em; border: none;">
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias"> <button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;"
class='edit' title="Edit Alias">
<span class="text-center text-primary fas fa-pen"></span> <span class="text-center text-primary fas fa-pen"></span>
</button> </button>
<button class="col-5 btn btn-xs btn-default" 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;"
class='remove' title="Remove Alias">
<span class="text-center text-danger fas fa-trash"></span> <span class="text-center text-danger fas fa-trash"></span>
</button> </button>
</td> </td>
@ -119,7 +142,8 @@
<h3>Mail aliases API (advanced)</h3> <h3>Mail aliases API (advanced)</h3>
<p>Use your box&rsquo;s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p> <p>Use your box&rsquo;s mail aliases API to add and remove mail aliases from the command-line or custom services you
build.</p>
<p>Usage:</p> <p>Usage:</p>
@ -132,16 +156,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> <caption></caption>
<thead><th>Verb</th> <th>Action</th><th></th></thead> <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> <th>Verb</th>
<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> <th>Action</th>
<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> <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 line argument which you must fill in with your email address and password.</p> <p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your email address and password.</p>
<pre># Gives a JSON-encoded list of all mail aliases <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
@ -155,13 +198,13 @@ 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' class='bg-light'></th></tr>");
@ -186,7 +229,7 @@ function show_aliases() {
} }
}) })
$('#alias_type_buttons button').off('click').click(function() { $('#alias_type_buttons button').off('click').click(function () {
$('#alias_type_buttons button').removeClass('active'); $('#alias_type_buttons button').removeClass('active');
$(this).addClass('active'); $(this).addClass('active');
$('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden'); $('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden');
@ -211,10 +254,10 @@ function show_aliases() {
} }
}) })
$('#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();
@ -232,19 +275,19 @@ function do_add_alias() {
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('')
@ -252,9 +295,9 @@ function aliases_reset_form() {
$('#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');
@ -280,32 +323,32 @@ function aliases_edit(elem) {
$('#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>

View file

@ -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,7 +12,8 @@
<h3>Set custom DNS records</h3> <h3>Set custom DNS records</h3>
<p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for external mail providers, or for various confirmation-of-ownership tests.</p> <p>You can set additional DNS records, such as if you have a website running on another server, to add DKIM records for
external mail providers, or for various confirmation-of-ownership tests.</p>
<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="col-lg-10 col-xl-7 mb-3">
@ -28,15 +29,29 @@
<div class="input-group"> <div class="input-group">
<label for="customdnsType" class="input-group-text">Type</label> <label for="customdnsType" class="input-group-text">Type</label>
<select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()"> <select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()">
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">A (IPv4 address)</option> <option value="A"
<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> 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.">
<option value="CAA" data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild &quot;letsencrypt.org&quot;)">CAA (Certificate Authority Authorization)</option> A (IPv4 address)</option>
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option> <option value="AAAA"
data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">
AAAA (IPv6 address)</option>
<option value="CAA"
data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild &quot;letsencrypt.org&quot;)">
CAA (Certificate Authority Authorization)</option>
<option value="CNAME"
data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">
CNAME (DNS forwarding)</option>
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option> <option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
<option value="MX" data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option> <option value="MX"
<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 record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH fingerprint record)</option> MX (mail exchanger)</option>
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS subdomain delegation)</option> <option value="SRV"
data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">
SRV (service record)</option>
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH
fingerprint record)</option>
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS
subdomain delegation)</option>
</select> </select>
</div> </div>
</div> </div>
@ -54,9 +69,12 @@
<div style="font-size: 90%;"> <div style="font-size: 90%;">
<b>Sort by:</b> <b>Sort by:</b>
<a href="#" onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain name</a> <a href="#"
onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain
name</a>
<b>|</b> <b>|</b>
<a href="#" onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">Created</a> <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 col-12" style="display: none; margin-top: 0;">
<caption></caption> <caption></caption>
@ -68,26 +86,37 @@
<th></th> <th></th>
</thead> </thead>
<tbody> <tbody>
<tr><td colspan="5">Loading...</td></tr> <tr>
<td colspan="5">Loading...</td>
</tr>
</tbody> </tbody>
</table> </table>
<h3>Using a secondary nameserver</h3> <h3>Using a secondary nameserver</h3>
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p> <p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#"
<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> onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or
use the DNS server on this box but add a secondary (aka &ldquo;slave&rdquo;) nameserver.</p>
<p>If you choose to use a 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="col-12 form-floating mb-3">
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname" placeholder="ns1.example.com"></textarea> <textarea type="text" class="form-control font-monospace" id="secondarydnsHostname"
placeholder="ns1.example.com"></textarea>
<label for="secondarydnsHostname">Secondary Nameservers</label> <label for="secondarydnsHostname">Secondary Nameservers</label>
<div> <div>
<p class="small"> <p class="small">
Multiple secondary servers can be separated with commas or spaces (i.e., <span class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>). Multiple secondary servers can be separated with commas or spaces (i.e., <span
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span class="font-monospace">xfr:10.0.0.0/8</span>. class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP
address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span
class="font-monospace">xfr:10.0.0.0/8</span>.
</p> </p>
<p id="secondarydns-clear-instructions" style="display: none" class="small"> <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. Clear the input field above and click Update to use this machine itself as secondary DNS, which is the
default/normal setup.
</p> </p>
</div> </div>
</div> </div>
@ -98,7 +127,8 @@
<h3>Custom DNS API</h3> <h3>Custom DNS API</h3>
<p>Use your box&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p> <p>Use your box&rsquo;s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own
dynamic DNS service.</p>
<p>Usage:</p> <p>Usage:</p>
@ -109,31 +139,83 @@
<h4>Verbs</h4> <h4>Verbs</h4>
<table class="table"> <table class="table">
<caption></caption> <caption></caption>
<thead><th>Verb</th> <th>Usage</th></thead> <thead>
<tr><td class="font-monospace"><b>GET</b></td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr> <th>Verb</th>
<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> <th>Usage</th>
<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> </thead>
<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> <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> <caption></caption>
<thead><th>Parameter</th> <th>Value</th></thead> <thead>
<tr><td class="font-monospace"><b>email</b></td> <td>The email address of any administrative user here.</td></tr> <th>Parameter</th>
<tr><td class="font-monospace"><b>password</b></td> <td>That user&rsquo;s password.</td></tr> <th>Value</th>
<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> </thead>
<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 &mdash; don&rsquo;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 class="font-monospace"><b>value</b></td> <td>For PUT, POST, and DELETE, the record&rsquo;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&rsquo;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 &mdash; don&rsquo;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&rsquo;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 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> <p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a
href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be
added to all custom domains unless you override them.</p>
<h4>Examples:</h4> <h4>Examples:</h4>
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p> <p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your email address and password.</p>
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on <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
@ -155,12 +237,12 @@ 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);
}); });
@ -168,8 +250,8 @@ function show_custom_dns() {
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]));
@ -178,14 +260,14 @@ function show_custom_dns() {
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
@ -193,13 +275,13 @@ function show_current_custom_dns() {
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('');
@ -227,34 +309,34 @@ function show_current_custom_dns_update_after_sort() {
} }
tr.append($('<td class="col-1">[<a href="#" onclick="return delete_custom_dns_record(this)">delete</a>]</td>')); tr.append($('<td class="col-1">[<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();
@ -276,17 +358,17 @@ function do_set_custom_dns(qname, rtype, value, method) {
"/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>

View file

@ -1,38 +1,47 @@
<style> <style>
#external_dns_settings .domain-header { #external_dns_settings .domain-header {
margin-top: 0.5em; margin-top: 0.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
#external_dns_settings .values td {
#external_dns_settings .values td {
border: 0; border: 0;
padding-top: .75em; padding-top: .75em;
padding-bottom: 0; padding-bottom: 0;
} }
#external_dns_settings .value {
#external_dns_settings .value {
word-break: break-all; word-break: break-all;
} }
#external_dns_settings .explanation td {
#external_dns_settings .explanation td {
padding-top: .5em; padding-top: .5em;
padding-bottom: .75em; padding-bottom: .75em;
font-style: italic; font-style: italic;
color: #777; 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 &mdash; such as in the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS zone information shown in the table below into your external DNS server&rsquo;s control panel.</p> <p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere &mdash; such as in
the DNS control panel provided by your domain name registrar or virtual cloud provider &mdash; by copying the DNS
zone information shown in the table below into your external DNS server&rsquo;s control panel.</p>
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p> <p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your
domain name by setting a DS record at your registrar, you will likely have to turn it off before changing
nameservers.</p>
<p class="alert" role="alert"> <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 multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length. <a href="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file. multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to
validate your zone file.
</p> </p>
<h3>Download zonefile</h3> <h3>Download zonefile</h3>
@ -64,12 +73,12 @@
</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++) {
@ -81,8 +90,8 @@ function show_external_dns() {
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><td colspan='3' class='bg-light'><h4 class='domain-header'/></td></tr>");
@ -103,20 +112,20 @@ function show_external_dns() {
} }
} }
}) })
} }
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>

View file

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta charset="utf-8"> <meta charset="utf-8">
@ -23,7 +24,10 @@
margin-bottom: 1.25em; margin-bottom: 1.25em;
} }
h1, h2, h3, h4 { h1,
h2,
h3,
h4 {
font-family: sans-serif; font-family: sans-serif;
font-weight: bold; font-weight: bold;
} }
@ -39,6 +43,7 @@
margin-bottom: 13px; margin-bottom: 13px;
margin-top: 30px; margin-top: 30px;
} }
.panel-heading h3 { .panel-heading h3 {
border: none; border: none;
padding: 0; padding: 0;
@ -50,6 +55,7 @@
margin-bottom: 13px; margin-bottom: 13px;
margin-top: 18px; margin-top: 18px;
} }
h4:first-child { h4:first-child {
margin-top: 6px; margin-top: 6px;
} }
@ -66,16 +72,23 @@
margin-bottom: 1em; margin-bottom: 1em;
} }
.if-logged-in { display: none; } .if-logged-in {
.if-logged-in-admin { display: none; } display: none;
}
.if-logged-in-admin {
display: none;
}
</style> </style>
</head> </head>
<body class=""> <body class="">
<div class="navbar navbar-expand-lg navbar-light" role="navigation"> <div class="navbar navbar-expand-lg navbar-light" role="navigation">
<div class="container bg-light pt-2 pb-2"> <div class="container bg-light pt-2 pb-2">
<div class="if-logged-in"> <div class="if-logged-in">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="#navbarContent" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
aria-controls="#navbarContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
</div> </div>
@ -93,41 +106,60 @@
<div class="collapse navbar-collapse" id="navbarContent"> <div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin"> <li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">System</button> <button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">System</button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="#system_status" onclick="return show_panel(this);">Status Checks</a></li> <li><a class="dropdown-item" href="#system_status" onclick="return show_panel(this);">Status
<li><a class="dropdown-item" href="#tls" onclick="return show_panel(this);">TLS (SSL) Certificates</a></li> Checks</a></li>
<li><a class="dropdown-item" href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li> <li><a class="dropdown-item" href="#tls" onclick="return show_panel(this);">TLS (SSL)
<li><a class="dropdown-item" href="#smtp_relays" onclick="return show_panel(this);">SMTP Relays</a></li> Certificates</a></li>
<li><a class="dropdown-item" href="#system_backup" onclick="return show_panel(this);">Backup
Status</a></li>
<li><a class="dropdown-item" href="#smtp_relays" onclick="return show_panel(this);">SMTP
Relays</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin"> <li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Advanced</button> <button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">Advanced</button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li> <li><a class="dropdown-item" href="#custom_dns" onclick="return show_panel(this);">Custom
<li><a class="dropdown-item" href="#external_dns" onclick="return show_panel(this);">External DNS</a></li> DNS</a></li>
<li><a class="dropdown-item" href="#pgp_keyring" onclick="return show_panel(this);">PGP Keyring Management</a></li> <li><a class="dropdown-item" href="#external_dns"
<li><a class="dropdown-item" href="#wkd" onclick="return show_panel(this);">WKD Management</a></li> onclick="return show_panel(this);">External DNS</a></li>
<li><a class="dropdown-item" href="#munin" onclick="return show_panel(this);">Munin Monitoring</a></li> <li><a class="dropdown-item" href="#pgp_keyring" onclick="return show_panel(this);">PGP
Keyring Management</a></li>
<li><a class="dropdown-item" href="#wkd" onclick="return show_panel(this);">WKD
Management</a></li>
<li><a class="dropdown-item" href="#munin" onclick="return show_panel(this);">Munin
Monitoring</a></li>
</ul> </ul>
</li> </li>
<li class="nav-item me-1 me-xl-4 btn if-logged-in-not-admin" type="button" href="#mail-guide" onclick="return show_panel(this);"> <li class="nav-item me-1 me-xl-4 btn if-logged-in-not-admin" type="button" href="#mail-guide"
onclick="return show_panel(this);">
Mail Guide Mail Guide
</li> </li>
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin"> <li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Mail</button> <button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">Mail</button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="#mail-guide" onclick="return show_panel(this);">Mail Guide</a></li> <li><a class="dropdown-item" href="#mail-guide" onclick="return show_panel(this);">Mail
Guide</a></li>
<li><a class="dropdown-item" href="#users" onclick="return show_panel(this);">Users</a></li> <li><a class="dropdown-item" href="#users" onclick="return show_panel(this);">Users</a></li>
<li><a class="dropdown-item" href="#aliases" onclick="return show_panel(this);">Aliases</a></li> <li><a class="dropdown-item" href="#aliases" onclick="return show_panel(this);">Aliases</a>
</li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Your Account</li> <li class="dropdown-header">Your Account</li>
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li> <li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor
Authentication</a></li>
</ul> </ul>
</li> </li>
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in" type="button" href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</button></li> <li><button class="nav-item me-1 me-xl-4 btn if-logged-in" type="button" href="#sync_guide"
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in-admin" type="button" href="#web" onclick="return show_panel(this);">Web</button></li> onclick="return show_panel(this);">Contacts/Calendar</button></li>
<li><button class="nav-item btn btn-secondary if-logged-in" type="button" onclick="do_logout(); return false;"><b>Logout</b></button></li> <li><button class="nav-item me-1 me-xl-4 btn if-logged-in-admin" type="button" href="#web"
onclick="return show_panel(this);">Web</button></li>
<li><button class="nav-item btn btn-secondary if-logged-in" type="button"
onclick="do_logout(); return false;"><b>Logout</b></button></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -209,14 +241,16 @@
</footer> </footer>
</div> <!-- /container --> </div> <!-- /container -->
<div id="ajax_loading_indicator" style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(0,0,0,.8)"> <div id="ajax_loading_indicator"
style="display: none; position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 100000; text-align: center; background-color: rgba(0,0,0,.8)">
<div class="justify-content-center" style="margin: 20%;"> <div class="justify-content-center" style="margin: 20%;">
<div class="spinner-border text-light" role="status" style="width: 4rem; height: 4rem;"></div> <div class="spinner-border text-light" role="status" style="width: 4rem; height: 4rem;"></div>
<div class="text-light display-5">Loading... please wait!</div> <div class="text-light display-5">Loading... please wait!</div>
</div> </div>
</div> </div>
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle" aria-hidden="true"> <div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle"
aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -234,7 +268,7 @@
</div> </div>
</div> </div>
<style> <style>
.darkmode, .darkmode,
.darkmode .form-control, .darkmode .form-control,
.darkmode .form-select { .darkmode .form-select {
@ -285,7 +319,11 @@
} }
.darkmode .btn, .darkmode .btn,
.darkmode h1, .darkmode h2, .darkmode h3, .darkmode h4, .darkmode h5, .darkmode h1,
.darkmode h2,
.darkmode h3,
.darkmode h4,
.darkmode h5,
.darkmode .navbar-brand, .darkmode .navbar-brand,
.darkmode .dropdown-menu, .darkmode .dropdown-menu,
.darkmode th, .darkmode th,
@ -317,25 +355,25 @@
.darkmode .status-na .status-text { .darkmode .status-na .status-text {
color: rgb(155, 155, 155); color: rgb(155, 155, 155);
} }
</style> </style>
<script src="/admin/assets/jquery.min.js"></script> <script src="/admin/assets/jquery.min.js"></script>
<script src="/admin/assets/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/admin/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
<script> <script>
var global_modal_state = null; var global_modal_state = null;
var global_modal_funcs = null; var global_modal_funcs = null;
const toggle = document.getElementById("toggle-theme") const toggle = document.getElementById("toggle-theme")
function set_dark_mode(isdark) { function set_dark_mode(isdark) {
if (isdark) { if (isdark) {
$("body").addClass("darkmode") $("body").addClass("darkmode")
} else { } else {
$("body").removeClass("darkmode") $("body").removeClass("darkmode")
} }
} }
if (typeof localStorage != 'undefined' && localStorage.getItem("miab-theme-preference")) { if (typeof localStorage != 'undefined' && localStorage.getItem("miab-theme-preference")) {
let themepref = localStorage.getItem("miab-theme-preference") let themepref = localStorage.getItem("miab-theme-preference")
if (themepref === "dark") { if (themepref === "dark") {
toggle.checked = true toggle.checked = true
@ -343,32 +381,32 @@ if (typeof localStorage != 'undefined' && localStorage.getItem("miab-theme-prefe
} else if (themepref === "light") { } else if (themepref === "light") {
set_dark_mode(false) set_dark_mode(false)
} }
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// Toggle dark mode right now // Toggle dark mode right now
toggle.checked = true toggle.checked = true
set_dark_mode(true) set_dark_mode(true)
} }
$("#toggle-theme").change(() => { $("#toggle-theme").change(() => {
if (typeof localStorage != 'undefined') { if (typeof localStorage != 'undefined') {
localStorage.setItem("miab-theme-preference", toggle.checked ? "dark" : "light") localStorage.setItem("miab-theme-preference", toggle.checked ? "dark" : "light")
} }
set_dark_mode(toggle.checked) set_dark_mode(toggle.checked)
}) })
$(function() { $(function () {
$('#global_modal').on('shown.bs.modal', function (e) { $('#global_modal').on('shown.bs.modal', function (e) {
// set focus to first input in the global modal's body // set focus to first input in the global modal's body
var input = $('#global_modal .modal-body input'); var input = $('#global_modal .modal-body input');
if (input.length > 0) $(input[0]).focus(); if (input.length > 0) $(input[0]).focus();
}) })
$('#global_modal .btn-danger').click(function() { $('#global_modal .btn-danger').click(function () {
// Don't take action now. Wait for the modal to be totally hidden // Don't take action now. Wait for the modal to be totally hidden
// so that we don't attempt to show another modal while this one // so that we don't attempt to show another modal while this one
// is closing. // is closing.
global_modal_state = 0; // OK global_modal_state = 0; // OK
}) })
$('#global_modal .btn-default').click(function() { $('#global_modal .btn-default').click(function () {
global_modal_state = 1; // Cancel global_modal_state = 1; // Cancel
}) })
$('#global_modal').on('hidden.bs.modal', function (e) { $('#global_modal').on('hidden.bs.modal', function (e) {
@ -377,11 +415,11 @@ $(function() {
if (global_modal_funcs && global_modal_funcs[global_modal_state]) if (global_modal_funcs && global_modal_funcs[global_modal_state])
global_modal_funcs[global_modal_state](); global_modal_funcs[global_modal_state]();
}) })
}) })
const global_modal = new bootstrap.Modal($("#global_modal")) const global_modal = new bootstrap.Modal($("#global_modal"))
function show_modal_error(title, message, callback) { function show_modal_error(title, message, callback) {
$('#global_modal h4').text(title); $('#global_modal h4').text(title);
$('#global_modal .modal-body').html("<p/>"); $('#global_modal .modal-body').html("<p/>");
if (typeof question == 'string') { if (typeof question == 'string') {
@ -395,9 +433,9 @@ function show_modal_error(title, message, callback) {
global_modal_state = null; global_modal_state = null;
global_modal.show(); global_modal.show();
return false; // handy when called from onclick return false; // handy when called from onclick
} }
function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) { function show_modal_confirm(title, question, verb, yes_callback, cancel_callback) {
$('#global_modal h4').text(title); $('#global_modal h4').text(title);
if (typeof question == 'string') { if (typeof question == 'string') {
$('#global_modal .modal-body').html("<p/>"); $('#global_modal .modal-body').html("<p/>");
@ -416,10 +454,10 @@ function show_modal_confirm(title, question, verb, yes_callback, cancel_callback
global_modal_state = null; global_modal_state = null;
global_modal.show(); global_modal.show();
return false; // handy when called from onclick return false; // handy when called from onclick
} }
var ajax_num_executing_requests = 0; var ajax_num_executing_requests = 0;
function ajax_with_indicator(options) { function ajax_with_indicator(options) {
setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100); setTimeout("if (ajax_num_executing_requests > 0) $('#ajax_loading_indicator').fadeIn()", 100);
function hide_loading_indicator() { function hide_loading_indicator() {
ajax_num_executing_requests--; ajax_num_executing_requests--;
@ -428,14 +466,14 @@ function ajax_with_indicator(options) {
} }
var old_success = options.success; var old_success = options.success;
var old_error = options.error; var old_error = options.error;
options.success = function(data) { options.success = function (data) {
hide_loading_indicator(); hide_loading_indicator();
if (data.status == "error") if (data.status == "error")
show_modal_error("Error", data.message); show_modal_error("Error", data.message);
else if (old_success) else if (old_success)
old_success(data); old_success(data);
}; };
options.error = function(jqxhr) { options.error = function (jqxhr) {
hide_loading_indicator(); hide_loading_indicator();
if (!old_error) if (!old_error)
show_modal_error("Error", "Something went wrong, sorry.") show_modal_error("Error", "Something went wrong, sorry.")
@ -445,10 +483,10 @@ function ajax_with_indicator(options) {
ajax_num_executing_requests++; ajax_num_executing_requests++;
$.ajax(options); $.ajax(options);
return false; // handy when called from onclick return false; // handy when called from onclick
} }
var api_credentials = null; var api_credentials = null;
function api(url, method, data, callback, callback_error, headers) { function api(url, method, data, callback, callback_error, headers) {
// from http://www.webtoolkit.info/javascript-base64.html // from http://www.webtoolkit.info/javascript-base64.html
function base64encode(input) { function base64encode(input) {
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
@ -491,7 +529,7 @@ function api(url, method, data, callback, callback_error, headers) {
processData: typeof data != "string", processData: typeof data != "string",
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null, mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
beforeSend: function(xhr) { beforeSend: function (xhr) {
// We don't store user credentials in a cookie to avoid the hassle of CSRF // We don't store user credentials in a cookie to avoid the hassle of CSRF
// attacks. The Authorization header only gets set in our AJAX calls triggered // attacks. The Authorization header only gets set in our AJAX calls triggered
// by user actions. // by user actions.
@ -503,19 +541,19 @@ function api(url, method, data, callback, callback_error, headers) {
success: callback, success: callback,
error: callback_error || default_error, error: callback_error || default_error,
statusCode: { statusCode: {
403: function(xhr) { 403: function (xhr) {
// Credentials are no longer valid. Try to login again. // Credentials are no longer valid. Try to login again.
do_logout(); do_logout();
switch_back_to_panel = null; switch_back_to_panel = null;
} }
} }
}) })
} }
var current_panel = null; var current_panel = null;
var switch_back_to_panel = null; var switch_back_to_panel = null;
function do_logout() { function do_logout() {
// Clear the session from the backend. // Clear the session from the backend.
api("/logout", "POST"); api("/logout", "POST");
@ -531,9 +569,9 @@ function do_logout() {
// Reset menus. // Reset menus.
show_hide_menus(); show_hide_menus();
} }
function show_panel(panelid) { function show_panel(panelid) {
if (panelid.getAttribute) if (panelid.getAttribute)
// we might be passed an HTMLElement <a>. // we might be passed an HTMLElement <a>.
panelid = panelid.getAttribute('href').substring(1); panelid = panelid.getAttribute('href').substring(1);
@ -549,9 +587,9 @@ function show_panel(panelid) {
switch_back_to_panel = null; switch_back_to_panel = null;
return false; // when called from onclick, cancel navigation return false; // when called from onclick, cancel navigation
} }
$(function() { $(function () {
// Recall saved user credentials. // Recall saved user credentials.
try { try {
if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials")) if (typeof sessionStorage != 'undefined' && sessionStorage.getItem("miab-cp-credentials"))
@ -572,7 +610,7 @@ $(function() {
} else { } else {
show_panel('login'); show_panel('login');
} }
}) })
</script> </script>
</body> </body>

View file

@ -27,7 +27,7 @@
{% 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-12">
<hr> <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,
@ -42,7 +42,7 @@ sudo management/cli.py user make-admin me@{{hostname}}</pre>
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 %}
@ -61,7 +61,8 @@ sudo management/cli.py user make-admin me@{{hostname}}</pre>
<div class="form-floating mb-3" id="loginOtp"> <div class="form-floating mb-3" id="loginOtp">
<input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off"> <input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off">
<label for="loginOtpInput">TOTP Code</label> <label for="loginOtpInput">TOTP Code</label>
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div> <div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two
factor authentication app.</div>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<div class="ms-auto"> <div class="ms-auto">
@ -78,16 +79,16 @@ sudo management/cli.py user make-admin me@{{hostname}}</pre>
</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;
@ -100,7 +101,7 @@ function do_login() {
"/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') {
@ -133,9 +134,11 @@ function do_login() {
// Login succeeded. // Login succeeded.
// Save the new credentials. // Save the new credentials.
api_credentials = { username: response.email, api_credentials = {
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('');
@ -160,39 +163,39 @@ function do_login() {
// Open the next panel the user wants to go to. Do this after the XHR response // Open the next panel the user wants to go to. Do this after the XHR response
// is over so that we don't start a new XHR request while this one is finishing, // is over so that we don't start a new XHR request while this one is finishing,
// which confuses the loading indicator. // which confuses the loading indicator.
setTimeout(function() { show_panel(!switch_back_to_panel || 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>

View file

@ -1,4 +1,9 @@
<style>#panel_mail-guide table.table { width: auto; margin-left: .5em; }</style> <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>
@ -8,7 +13,8 @@
<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 href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p> <p style="margin-left: 2em"><strong><a
href="https://{{hostname}}/mail">https://{{hostname}}/mail</a></strong></p>
<p>Your username is your whole email address.</p> <p>Your username is your whole email address.</p>
@ -16,7 +22,10 @@
<h4>Automatic configuration</h4> <h4>Automatic configuration</h4>
<p>iOS and OS X only: Open <a style="font-weight: bold" href="https://{{hostname}}/mailinabox.mobileconfig">this configuration link</a> on your iOS device or on your Mac desktop to easily set up mail (IMAP/SMTP), Contacts, and Calendar. Your username is your whole email address.</p> <p>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>
<h4>Manual configuration</h4> <h4>Manual configuration</h4>
@ -25,32 +34,71 @@
<table class="table"> <table class="table">
<caption></caption> <caption></caption>
<thead> <thead>
<tr><th>Option</th> <th>Value</th></tr> <tr>
<th>Option</th>
<th>Value</th>
</tr>
</thead> </thead>
<tr><th>Protocol/Method</th> <td>IMAP</td></tr> <tr>
<tr><th>Mail server</th> <td>{{hostname}}</td> <th>Protocol/Method</th>
<tr><th>IMAP Port</th> <td>993</td></tr> <td>IMAP</td>
<tr><th>IMAP Security</th> <td>SSL or TLS</td></tr> </tr>
<tr><th>SMTP Port</th> <td>465</td></tr> <tr>
<tr><th>SMTP Security</td> <td>SSL or TLS</td></tr> <th>Mail server</th>
<tr><th>Username:</th> <td>Your whole email address.</td></tr> <td>{{hostname}}</td>
<tr><th>Password:</th> <td>Your mail password.</td></tr> <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>
<p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p> <p>In addition to setting up your email, you&rsquo;ll also need to set up <a href="#sync_guide"
onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p> <p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and
SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same.
However, we recommend you use IMAP instead.</p>
<h4>Exchange/ActiveSync settings</h4> <h4>Exchange/ActiveSync settings</h4>
<p>On iOS devices, devices on this <a href="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&rsquo;ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p> <p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility
list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an
Exchange or ActiveSync server. However, we&rsquo;ve found this to be more buggy than using IMAP as
described above. If you encounter any problems, please use the manual settings above.</p>
<table class="table"> <table class="table">
<tr><th>Server</th> <td>{{hostname}}</td></tr> <tr>
<tr><th>Options</th> <td>Secure Connection</td></tr> <th>Server</th>
<td>{{hostname}}</td>
</tr>
<tr>
<th>Options</th>
<td>Secure Connection</td>
</tr>
</table> </table>
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p> <p>Your device should also provide a contacts list and calendar that syncs to this box when you use this
method.</p>
</div> </div>
<div class="col-sm-5"> <div class="col-sm-5">
@ -60,13 +108,24 @@
</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 rejecting mail from people you haven&rsquo;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> <p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially
rejecting mail from people you haven&rsquo;t received mail from before. Legitimate mail servers
will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this.
If you are waiting for an email from someone new, such as if you are registering on a new
website and are waiting for an email confirmation, please be aware there will be a minimum of 3
minutes delay, depending how soon the remote server attempts redelivery.</p>
<h4>+tag addresses</h4> <h4>+tag addresses</h4>
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you&rsquo;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> <p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email
address is <code>you@yourdomain.com</code>, you&rsquo;ll also automatically get mail sent to
<code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail
for your own filtering rules without having to create aliases in this control panel.</p>
<h4>Use only this box to send as you</h4> <h4>Use only this box to send as you</h4>
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p> <p>Your box sets strict email sending policies for your domain names to make it harder for spam and
other fraudulent mail to claim to be you. Only this machine is authorized to send email on
behalf of your domain names. If you use any other service to send email as you, it will likely
get spam filtered by recipients.</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -34,20 +34,21 @@
<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 mb-4">
<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 websites allow you to Enabling two-factor authentication on this page only limits access to this control panel. Remember that most
reset your password by checking your email, so anyone with access to your email can typically take over websites allow you to
your other accounts. Additionally, if your email address or any alias that forwards to your email reset your password by checking your email, so anyone with access to your email can typically take over
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@, your other accounts. Additionally, if your email address or any alias that forwards to your email
webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password, address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
and ensure every administrator account for this control panel does the same.</strong> webmaster@, abuse@), extra care should be taken to protect the account. <strong>Always use a strong password,
</div> and ensure every administrator account for this control panel does the same.</strong>
</div>
</div> </div>
<div class="twofactor"> <div class="twofactor">
@ -58,16 +59,18 @@ and ensure every administrator account for this control panel does the same.</st
<div class="row gx-5"> <div class="row gx-5">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any <p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a
href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
other two-factor authentication app</a> that supports TOTP.</p> other two-factor authentication app</a> that supports TOTP.</p>
<div class="mb-3"> <div class="mb-3">
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into the app:</p> <p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into
the app:</p>
<div id="totp-setup-qr"> <div id="totp-setup-qr">
<img class="mt-3 mb-3 ms-auto me-auto" id="twofactor-qrimg"> <img class="mt-3 mb-3 ms-auto me-auto" id="twofactor-qrimg">
<div class="input-group"> <div class="input-group">
<label class="input-group-text" for="totp-setup-secret"><b>Secret</b></label> <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/> <input type="text" class="form-control font-monospace" id="totp-setup-secret" disabled />
</div> </div>
</div> </div>
</div> </div>
@ -75,12 +78,14 @@ and ensure every administrator account for this control panel does the same.</st
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="mb-3"> <div class="mb-3">
<label for="otp-label" style="font-weight: normal"><b>3.</b> Optionally, give your device a label so that you can remember what device you set it up on:</label> <label for="otp-label" style="font-weight: normal"><b>3.</b> Optionally, give your device a label so
that you can remember what device you set it up on:</label>
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" /> <input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit code and enter it here:</label> <label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit
code and enter it here:</label>
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" /> <input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
</div> </div>
</div> </div>
@ -88,16 +93,19 @@ and ensure every administrator account for this control panel does the same.</st
<div class="form-group"> <div class="form-group">
<div> <div>
<button id="totp-setup-submit" disabled type="submit" class="btn btn-primary">Enable Two-Factor Authentication</button> <button id="totp-setup-submit" disabled type="submit" class="btn btn-primary">Enable Two-Factor
Authentication</button>
</div> </div>
<small>When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in <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> again, now using your two-factor authentication app.</small>
</div> </div>
</form> </form>
<form id="disable-2fa"> <form id="disable-2fa">
<div> <div>
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span class="font-monospace"></span></span>.</p> <p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span
class="font-monospace"></span></span>.</p>
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button> <button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</div> </div>
<small>You will have to log into the admin panel again after disabling two-factor authentication.</small> <small>You will have to log into the admin panel again after disabling two-factor authentication.</small>
@ -180,11 +188,11 @@ and ensure every administrator account for this control panel does the same.</st
'/mfa/status', '/mfa/status',
'POST', 'POST',
{}, {},
function(res) { function (res) {
el.wrapper.classList.add('loaded'); el.wrapper.classList.add('loaded');
var has_mfa = false; var has_mfa = false;
res.enabled_mfa.forEach(function(mfa) { res.enabled_mfa.forEach(function (mfa) {
if (mfa.type == "totp") { if (mfa.type == "totp") {
render_disable(mfa); render_disable(mfa);
has_mfa = true; has_mfa = true;
@ -204,7 +212,7 @@ and ensure every administrator account for this control panel does the same.</st
'/mfa/disable', '/mfa/disable',
'POST', 'POST',
{ type: 'totp' }, { type: 'totp' },
function() { function () {
do_logout(); do_logout();
} }
); );
@ -223,8 +231,8 @@ and ensure every administrator account for this control panel does the same.</st
secret: $(el.totpSetupSecret).val(), secret: $(el.totpSetupSecret).val(),
label: $(el.totpSetupLabel).val() label: $(el.totpSetupLabel).val()
}, },
function(res) { do_logout(); }, function (res) { do_logout(); },
function(res) { show_modal_error("Two-Factor Authentication Setup", res); } function (res) { show_modal_error("Two-Factor Authentication Setup", res); }
); );
return false; return false;

View file

@ -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>

View file

@ -88,7 +88,8 @@ copy and paste this block in the area below
</pre> </pre>
</p> </p>
<div class="form-floating col-12 col-xl-6 mb-3"> <div class="form-floating col-12 col-xl-6 mb-3">
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#xA;stuff here&#xA;-----END PGP PUBLIC KEY BLOCK-----"></textarea> <textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#xA;stuff here&#xA;-----END PGP PUBLIC KEY BLOCK-----"></textarea>
<label for="pgp_paste_key">Paste your PGP public key here</label> <label for="pgp_paste_key">Paste your PGP public key here</label>
</div> </div>
<button class="btn btn-primary" onclick="importkey()">Import Key</button> <button class="btn btn-primary" onclick="importkey()">Import Key</button>
@ -214,7 +215,7 @@ copy and paste this block in the area below
"/system/pgp/", "/system/pgp/",
"GET", "GET",
{}, {},
function(r) { function (r) {
$('#privatekey').html("") $('#privatekey').html("")
$('#pubkeys').html("") $('#pubkeys').html("")
key_html(r.daemon, true, true).appendTo("#privatekey") key_html(r.daemon, true, true).appendTo("#privatekey")
@ -232,10 +233,10 @@ copy and paste this block in the area below
`/system/pgp/${fpr}/export`, `/system/pgp/${fpr}/export`,
"GET", "GET",
{}, {},
function(r) { function (r) {
show_modal_error("PGP Key", `Key export for <b>${fpr}</b>:<br><br><pre>${r}</pre>`) show_modal_error("PGP Key", `Key export for <b>${fpr}</b>:<br><br><pre>${r}</pre>`)
}, },
function(_ ,xhr) { function (_, xhr) {
if (xhr.status == 404) { if (xhr.status == 404) {
show_modal_error("Error", `The key you asked for (<b>${fpr}</b>) does not exist!`) show_modal_error("Error", `The key you asked for (<b>${fpr}</b>) does not exist!`)
} else { } else {
@ -252,7 +253,7 @@ copy and paste this block in the area below
`/system/pgp/${fpr}`, `/system/pgp/${fpr}`,
"DELETE", "DELETE",
{}, {},
function(r) { function (r) {
if (r.length == 0) { if (r.length == 0) {
show_modal_error("Delete key", "OK", show_pgp_keyring) show_modal_error("Delete key", "OK", show_pgp_keyring)
} else { } else {
@ -265,11 +266,11 @@ copy and paste this block in the area below
show_modal_error("Delete key", wkd_info, show_pgp_keyring) show_modal_error("Delete key", wkd_info, show_pgp_keyring)
} }
}, },
function(r) { function (r) {
show_modal_error("Key deletion error", r) show_modal_error("Key deletion error", r)
} }
) )
}, ()=>{}) }, () => { })
} }
function importkey() { function importkey() {
@ -279,7 +280,7 @@ copy and paste this block in the area below
{ {
key: $("#pgp_paste_key").val() key: $("#pgp_paste_key").val()
}, },
function(r) { function (r) {
show_modal_error("Import Results", `<ul> 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>
@ -289,7 +290,7 @@ copy and paste this block in the area below
<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)
} }
) )

View file

@ -21,7 +21,8 @@
<label for="use_relay" class="input-group-text"><b>Use Relay?</b></label> <label for="use_relay" class="input-group-text"><b>Use Relay?</b></label>
<div class="input-group-text"> <div class="input-group-text">
<div class="form-switch"> <div class="form-switch">
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false onclick="checkfields();"> <input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false
onclick="checkfields();">
</div> </div>
</div> </div>
</div> </div>
@ -45,7 +46,8 @@
<label class="input-group-text">Password/Key</label> <label class="input-group-text">Password/Key</label>
<input type="password" class="form-control" id="relay_auth_pass" placeholder=""> <input type="password" class="form-control" id="relay_auth_pass" placeholder="">
</div> </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> <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> </div>

View file

@ -1,9 +1,9 @@
<style> <style>
.code-white > code { .code-white>code {
color: #ffffff color: #ffffff
} }
#ssl_provision_result > div > div { #ssl_provision_result>div>div {
white-space: pre; white-space: pre;
} }
@ -14,20 +14,25 @@
<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 that the connection is secure between you and the owner of that address.</p> <p>A TLS (formerly called SSL) certificate is a cryptographic file that proves to anyone connecting to a web address
that the connection is secure between you and the owner of that address.</p>
<p>You need a TLS certificate for this box&rsquo;s hostname ({{hostname}}) and every other domain name and subdomain that this box is hosting a website for (see the list below).</p> <p>You need a TLS certificate for this box&rsquo;s hostname ({{hostname}}) and every other domain name and subdomain
that this box is hosting a website for (see the list below).</p>
<div id="ssl_provision"> <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/" target="_blank">Let&rsquo;s Encrypt</a>, a free TLS certificate provider, for:</p> <p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/"
target="_blank">Let&rsquo;s Encrypt</a>, a free TLS certificate provider, for:</p>
<ul class="text-primary"></ul> <ul class="text-primary"></ul>
<div class="container input-group mt-3" style="overflow-x: auto;"> <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> <button id="ssl_provision_button" class="btn btn-primary"
<label class="input-group-text" for=""><b>By provisioning the certificates, you&rsquo;re agreeing to the <a href="https://letsencrypt.org/repository">Let&rsquo;s Encrypt Subscriber Agreement</a>.</b></label> onclick="return provision_tls_cert();">Provision</button>
<label class="input-group-text" for=""><b>By provisioning the certificates, you&rsquo;re agreeing to the <a
href="https://letsencrypt.org/repository">Let&rsquo;s Encrypt Subscriber Agreement</a>.</b></label>
</div> </div>
</div> </div>
</div> </div>
@ -36,7 +41,8 @@
<h3>Certificate status</h3> <h3>Certificate status</h3>
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed through <a href="https://letsencrypt.org/" target="_blank">Let&rsquo;s Encrypt</a> 14 days prior to expiration.</p> <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&rsquo;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 align-middle col-12" style="margin-bottom: 2em; display: none">
<caption></caption> <caption></caption>
@ -54,7 +60,8 @@
<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. You can generate the needed CSR below.</p> <p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try.
You can generate the needed CSR below.</p>
<div class="col-lg-10 col-xl-8"> <div class="col-lg-10 col-xl-8">
<div class="input-group"> <div class="input-group">
@ -62,7 +69,8 @@
<select id="ssldomain" onchange="show_csr()" class="form-select"></select> <select id="ssldomain" onchange="show_csr()" class="form-select"></select>
</div> </div>
</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> <p><small>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides
the one you choose above.</small></p>
<div class="col-lg-10 col-xl-8"> <div class="col-lg-10 col-xl-8">
<div class="input-group"> <div class="input-group">
@ -75,7 +83,8 @@
</select> </select>
</div> </div>
</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> <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;"> <div id="csr_info" style="display: none;">
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p> <p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
@ -88,16 +97,19 @@
<hr> <hr>
<small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small> <small>The CSR is safe to share. It can only be used in combination with a secret key stored on this machine.</small>
<p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an intermediate chain. Paste each separately into the boxes below:</p> <p>The certificate provider will then provide you with a TLS/SSL certificate. They may also provide you with an
intermediate chain. Paste each separately into the boxes below:</p>
<div class="row g-4"> <div class="row g-4">
<div class="form-floating col-12 col-xl-6"> <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-----&#xA;stuff here&#xA;-----END CERTIFICATE-----"></textarea> <textarea id="ssl_paste_cert" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----"></textarea>
<label for="ssl_paste_cert" class="ms-3">TLS/SSL Certificate</label> <label for="ssl_paste_cert" class="ms-3">TLS/SSL Certificate</label>
</div> </div>
<div class="form-floating col-12 col-xl-6"> <div class="form-floating col-12 col-xl-6">
<textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em" placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----END CERTIFICATE-----"></textarea> <textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
placeholder="-----BEGIN CERTIFICATE-----&#xA;stuff here&#xA;-----END CERTIFICATE-----&#xA;-----BEGIN CERTIFICATE-----&#xA;more stuff here&#xA;-----END CERTIFICATE-----"></textarea>
<label for="ssl_paste_chain" class="ms-3">TLS/SSL intermediate Chain (if provided)</label> <label for="ssl_paste_chain" class="ms-3">TLS/SSL intermediate Chain (if provided)</label>
</div> </div>
</div> </div>
@ -108,13 +120,13 @@
</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("") $("#ssl_provision_p ul").html("")
@ -174,17 +186,17 @@ function show_tls(keep_provisioning_shown) {
$('#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;
@ -198,12 +210,12 @@ function show_csr() {
{ {
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",
@ -212,24 +224,24 @@ function install_cert() {
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').html("");
may_reenable_provision_button = true; may_reenable_provision_button = true;
@ -265,7 +277,7 @@ function provision_tls_cert() {
$('#ssl_provision_result').append(n); $('#ssl_provision_result').append(n);
if (status.requests.length > 0) { if (status.requests.length > 0) {
n.find(".card-header").html(`Logs for ${r.domains.map((h) => {return `<code>${h}</code>`}).join(", ")}`); n.find(".card-header").html(`Logs for ${r.domains.map((h) => { return `<code>${h}</code>` }).join(", ")}`);
} }
if (r.result == "error") { if (r.result == "error") {
@ -293,5 +305,5 @@ function provision_tls_cert() {
$('#ssl_provision_p .btn').removeAttr("disabled"); $('#ssl_provision_p .btn').removeAttr("disabled");
} }
}); });
} }
</script> </script>

View file

@ -13,38 +13,83 @@
<table class="table"> <table class="table">
<caption></caption> <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>: your <p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>:
your
complete email address and your mail password.</p> complete email address and your mail password.</p>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-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 Exchange/ActiveSync, <p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using
Exchange/ActiveSync,
your contacts and calendar may already appear on your device.</p> 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> <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><td>Account Type</td> <td>CardDAV or CalDAV</td></tr> <tr>
<tr><td>Server Name</td> <td>{{hostname}}</td></tr> <td>Account Type</td>
<tr><td>Use SSL</td> <td>Yes</td></tr> <td>CardDAV or CalDAV</td>
<tr><td>Username</td> <td>Your complete email address.</td></tr> </tr>
<tr><td>Password</td> <td>Your mail password.</td></tr> <tr>
<td>Server Name</td>
<td>{{hostname}}</td>
</tr>
<tr>
<td>Use SSL</td>
<td>Yes</td>
</tr>
<tr>
<td>Username</td>
<td>Your complete email address.</td>
</tr>
<tr>
<td>Password</td>
<td>Your mail password.</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View file

@ -1,10 +1,13 @@
<style> <style>
#backup-status tr.full-backup td { 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 also store it on S3-compatible services like Amazon Web Services (AWS).</p> <p>The box makes an incremental backup each night. By default the backup is stored on the machine itself, but you can
also store it on S3-compatible services like Amazon Web Services (AWS).</p>
<h3>Configuration</h3> <h3>Configuration</h3>
@ -25,8 +28,11 @@
<!-- LOCAL BACKUP --> <!-- LOCAL BACKUP -->
<div class="form-group backup-target-local"> <div class="form-group backup-target-local">
<div class="col-lg-10 col-xl-8 mb-3"> <div class="col-lg-10 col-xl-8 mb-3">
<p>Backups are stored on this machine&rsquo;s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p> <p>Backups are stored on this machine&rsquo;s own hard disk. You are responsible for periodically using SFTP (FTP
<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> 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>
</div> </div>
<!-- RSYNC BACKUP --> <!-- RSYNC BACKUP -->
@ -35,7 +41,8 @@
<p>Backups synced to a remote machine using rsync over SSH, with local <p>Backups synced to a remote machine using rsync over SSH, with local
copies in <tt class="backup-location"></tt>. These files are encrypted, so copies in <tt class="backup-location"></tt>. These files are encrypted, so
they are safe to store anywhere.</p> <p>Separately copy the encryption they are safe to store anywhere.</p>
<p>Separately copy the encryption
password from <tt class="backup-encpassword-file"></tt> to a safe and password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files.</p> secure location. You will need this file to decrypt backup files.</p>
@ -64,7 +71,9 @@
</div> </div>
</div> </div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
<label for="ssh-pub-key" class="col-sm-2 control-label"><h4>Public SSH Key</h4></label> <label for="ssh-pub-key" class="col-sm-2 control-label">
<h4>Public SSH Key</h4>
</label>
<textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea> <textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea>
<div class="small" style="margin-top: 2px"> <div class="small" style="margin-top: 2px">
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt> Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
@ -75,7 +84,8 @@
<!-- S3 BACKUP --> <!-- S3 BACKUP -->
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p> <p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p> <p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
</div> </div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group"> <div class="input-group">
@ -97,7 +107,8 @@
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
<div class="input-group"> <div class="input-group">
<label for="backup-target-s3-path" class="input-group-text">S3 Path</label> <label for="backup-target-s3-path" class="input-group-text">S3 Path</label>
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" id="backup-target-s3-path"> <input type="text" placeholder="your-bucket-name/backup-directory" class="form-control"
id="backup-target-s3-path">
</div> </div>
</div> </div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
@ -114,8 +125,11 @@
</div> </div>
<!-- Backblaze --> <!-- Backblaze -->
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2 bucket. You must have a Backblaze account already.</p> <p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> 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> bucket. You must have a Backblaze account already.</p>
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.
</p>
</div> </div>
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2"> <div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
<div class="input-group"> <div class="input-group">
@ -143,7 +157,10 @@
<label class="input-group-text" for="backup-min-age">Retention Days</label> <label class="input-group-text" for="backup-min-age">Retention Days</label>
<input type="number" class="form-control" id="backup-min-age"> <input type="number" class="form-control" id="backup-min-age">
</div> </div>
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div> <div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an
incremental backup most nights, which requires that previous backups back to the most recent full backup be
preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when
the incremental backup data size exceeds a limit.</div>
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -153,7 +170,8 @@
<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 id="backup-total-size"></span>.</p> <p>The backup location currently contains the backups listed below. The total size of the backups is currently <span
id="backup-total-size"></span>.</p>
<table id="backup-status" class="table align-middle col-12"> <table id="backup-status" class="table align-middle col-12">
<caption></caption> <caption></caption>
@ -169,21 +187,23 @@
<!-- 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"> <div class="row justify-content-evenly">
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)" style="display: none;">Create Full Backup Now</button> <button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)"
<button id="create-incremental-backup-button" class="btn btn-primary col-3" 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> </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;
@ -195,19 +215,19 @@ function nice_size(bytes) {
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='5' 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;
@ -219,8 +239,8 @@ function show_system_backup() {
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="5">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="5">No backups have been made yet.</td></tr>');
@ -228,20 +248,20 @@ function show_system_backup() {
} }
// 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/>').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;
@ -250,15 +270,15 @@ function show_system_backup() {
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); $("#backup-min-age").val(r.min_age_in_days);
@ -298,9 +318,9 @@ function show_custom_backup() {
} }
toggle_form() 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();
@ -333,34 +353,34 @@ function set_custom_backup() {
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) { function set_host(host) {
if(host !== 'other') { if (host !== 'other') {
$("#backup-target-s3-host").val(host); $("#backup-target-s3-host").val(host);
} else { } 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()); set_host($('#backup-target-s3-host-select').val());
}); });
set_host($('#backup-target-s3-host-select').val()); 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."
@ -372,16 +392,16 @@ function do_backup(is_full) {
{ {
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>

View file

@ -4,9 +4,11 @@
#system-checks .message { #system-checks .message {
display: inline; display: inline;
} }
#system-checks .icon { #system-checks .icon {
min-width: 2em; min-width: 2em;
} }
#system-checks .heading { #system-checks .heading {
font-weight: bold; font-weight: bold;
font-size: 180%; font-size: 180%;
@ -21,9 +23,11 @@
.status-error .fas { .status-error .fas {
color: rgb(190, 0, 0); color: rgb(190, 0, 0);
} }
.status-error.sym { .status-error.sym {
color: rgb(190, 0, 0); color: rgb(190, 0, 0);
} }
.status-error .status-text { .status-error .status-text {
color: rgb(70, 0, 0); color: rgb(70, 0, 0);
} }
@ -31,9 +35,11 @@
.status-warning .fas { .status-warning .fas {
color: rgb(191, 150, 0); color: rgb(191, 150, 0);
} }
.status-warning.sym { .status-warning.sym {
color: rgb(191, 150, 0); color: rgb(191, 150, 0);
} }
.status-warning .status-text { .status-warning .status-text {
color: rgb(69, 54, 0); color: rgb(69, 54, 0);
} }
@ -41,9 +47,11 @@
.status-ok .fas { .status-ok .fas {
color: rgb(0, 190, 0); color: rgb(0, 190, 0);
} }
.status-ok.sym { .status-ok.sym {
color: rgb(0, 190, 0); color: rgb(0, 190, 0);
} }
.status-ok .status-text { .status-ok .status-text {
color: rgb(0, 70, 0); color: rgb(0, 70, 0);
} }
@ -51,9 +59,11 @@
.status-na .fas { .status-na .fas {
color: rgb(100, 100, 100); color: rgb(100, 100, 100);
} }
.status-na.sym { .status-na.sym {
color: rgb(100, 100, 100); color: rgb(100, 100, 100);
} }
.status-na .status-text { .status-na .status-text {
color: rgb(100, 100, 100); color: rgb(100, 100, 100);
} }

View file

@ -1,11 +1,27 @@
<h2>Users</h2> <h2>Users</h2>
<style> <style>
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; } #user_table tr.account_inactive td.address {
#user_table .actions { margin-top: .33em; font-size: 95%; } color: #888;
#user_table .account_inactive .if_active { display: none; } text-decoration: line-through;
#user_table .account_active .if_inactive { display: none; } }
#user_table .account_active.if_inactive { display: none; }
#user_table .actions {
margin-top: .33em;
font-size: 95%;
}
#user_table .account_inactive .if_active {
display: none;
}
#user_table .account_active .if_inactive {
display: none;
}
#user_table .account_active.if_inactive {
display: none;
}
</style> </style>
<h3>Add a mail user</h3> <h3>Add a mail user</h3>
@ -18,13 +34,15 @@
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="input-group"> <div class="input-group">
<label class="input-group-text" for="adduserEmail">Email address</label> <label class="input-group-text" for="adduserEmail">Email address</label>
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail" placeholder="me@example.com"> <input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail"
placeholder="me@example.com">
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="input-group"> <div class="input-group">
<label class="input-group-text" for="adduserPassword">Password</label> <label class="input-group-text" for="adduserPassword">Password</label>
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword" placeholder="Password"> <input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword"
placeholder="Password">
</div> </div>
</div> </div>
</div> </div>
@ -48,11 +66,15 @@
</div> </div>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;"> <ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li> <li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#"
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li> 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>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>User accounts cannot contain any international (non-ASCII) characters, but <a href="#"
<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> 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> </ul>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
@ -113,7 +135,8 @@
</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 email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div> <div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this
email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
</td> </td>
</tr> </tr>
</table> </table>
@ -134,36 +157,57 @@
<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> <caption></caption>
<thead><th>Verb</th> <th>Action</th><th></th></thead> <thead>
<tr><td><b>GET</b</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr> <th>Verb</th>
<tr> <th>Action</th>
<td><b>POST</b</td> <th></th>
</thead>
<tr>
<td><b>GET</b< /td>
<td><i>(none)</i></td>
<td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded
results.</td>
</tr>
<tr>
<td><b>POST</b< /td>
<td class="font-monospace">/add</td> <td class="font-monospace">/add</td>
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional parameters: <code>privilege=admin</code> and <code>quota</code></td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional
</tr> parameters: <code>privilege=admin</code> and <code>quota</code></td>
<tr> </tr>
<tr>
<td><b>POST</b></td> <td><b>POST</b></td>
<td class="font-monospace">/remove</td> <td class="font-monospace">/remove</td>
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td> <td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
</tr> </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>
<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> <td><b>POST</b></td>
<tr> <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><b>GET</b></td>
<td class="font-monospace">/quota</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> <td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result
</tr> </td>
<tr> </tr>
<tr>
<td><b>POST</b></td> <td><b>POST</b></td>
<td class="font-monospace">/quota</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> <td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>
</tr> </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 line argument which you must fill in with your administrative email address and password.</p> <p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
line argument which you must fill in with your administrative email address and password.</p>
<pre># Gives a JSON-encoded list of all mail users <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
@ -182,12 +226,12 @@ 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']);
} }
); );
@ -197,7 +241,7 @@ function show_users() {
"/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' class='bg-light'></th></tr>");
@ -252,9 +296,9 @@ function show_users() {
} }
} }
}) })
} }
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();
@ -268,18 +312,18 @@ function do_add_user() {
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 = "";
@ -290,7 +334,7 @@ function users_set_password(elem) {
"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",
@ -298,17 +342,17 @@ function users_set_password(elem) {
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');
@ -321,7 +365,7 @@ function users_set_quota(elem) {
"<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",
@ -329,16 +373,16 @@ function users_set_quota(elem) {
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
@ -351,25 +395,25 @@ function users_remove(elem) {
"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();
@ -384,7 +428,7 @@ function mod_priv(elem, add_remove) {
"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",
@ -392,18 +436,18 @@ function mod_priv(elem, add_remove) {
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>

View file

@ -3,24 +3,35 @@
<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 all domain names that you set up an email user or alias for.</p> <p>This machine is serving a simple, static website at <a href="https://{{hostname}}">https://{{hostname}}</a> and at
all domain names that you set up an email user or alias for.</p>
<h3>Uploading web files</h3> <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&rsquo;t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p> <p>You can replace the default website with your own HTML pages and other static files. This control panel won&rsquo;t
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" onclick="return show_panel(this);">Status Checks</a> page.</li> <li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status"
onclick="return show_panel(this);">Status Checks</a> page.</li>
<li>On your personal computer, install an SSH file transfer program such as <a href="https://filezilla-project.org/">FileZilla</a> or <a href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li> <li>On your personal computer, install an SSH file transfer program such as <a
href="https://filezilla-project.org/">FileZilla</a> or <a
href="http://linuxcommand.org/man_pages/scp1.html">scp</a>.</li>
<li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this control panel. Your SSH credentials probably involves a private key file.</li> <li>Log in to this machine with the file transfer program. The server is <strong>{{hostname}}</strong>, the protocol
is SSH or SFTP, and use the <strong>SSH login credentials</strong> that you used when you originally created this
machine at your cloud host provider. This is <strong>not</strong> what you use to log in either for email or this
control panel. Your SSH credentials probably involves a private key file.</li>
<li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine. They will appear directly and immediately on the web.</li> <li>Upload your <tt>.html</tt> or other files to the directory <tt>{{storage_root}}/www/default</tt> on this machine.
They will appear directly and immediately on the web.</li>
<li>The websites set up on this machine are listed in the table below with where to put the files for each website.</li> <li>The websites set up on this machine are listed in the table below with where to put the files for each website.
</li>
<table id="web_domains_existing" class="table col-12" style="margin-bottom: 1em;"> <table id="web_domains_existing" class="table col-12" style="margin-bottom: 1em;">
<caption></caption> <caption></caption>
<thead> <thead>
<tr> <tr>
@ -30,20 +41,23 @@
</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 <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> <p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or
<a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a
href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records
to the new domain at your registrar (but <i>not</i> glue records).</p>
</ol> </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++) {
@ -60,30 +74,30 @@ function show_web() {
} }
}); });
} }
function do_web_update() { function do_web_update() {
api( api(
"/web/update", "/web/update",
"POST", "POST",
{ {
}, },
function(data) { function (data) {
if (data == "") if (data == "")
data = "Nothing changed."; data = "Nothing changed.";
else else
data = $("<pre/>").text(data); data = $("<pre/>").text(data);
show_modal_error("Web Update", data, function() { show_web() }); show_modal_error("Web Update", data, function () { show_web() });
}); });
} }
function show_change_web_root(elem) { function show_change_web_root(elem) {
var domain = $(elem).parents('tr').attr('data-domain'); var domain = $(elem).parents('tr').attr('data-domain');
var root = $(elem).parents('tr').attr('data-custom-web-root'); var root = $(elem).parents('tr').attr('data-custom-web-root');
show_modal_confirm( show_modal_confirm(
'Change Root Directory for ' + domain, 'Change Root Directory for ' + domain,
$('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'), $('<p>You can change the static directory for <tt>' + domain + '</tt> to:</p> <p><tt>' + root + '</tt></p> <p>First create this directory on the server. Then click Update to scan for the directory and update web settings.</p>'),
'Update', 'Update',
function() { do_web_update(); }); function () { do_web_update(); });
} }
</script> </script>

View file

@ -59,7 +59,8 @@
<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.</p> that allows users to authoratively publish their public PGP keys on the web, via HTTPS.
</p>
<p>Unlike other solutions (like public keyservers), WKD has the advantage that the owner <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>
@ -70,7 +71,8 @@
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 records. you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's
records.
<br><br> <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
@ -89,7 +91,8 @@
<label for="wkd-show-all-entries" class="input-group-text"><b>Show all email addresses</b></label> <label for="wkd-show-all-entries" class="input-group-text"><b>Show all email addresses</b></label>
<div class="input-group-text"> <div class="input-group-text">
<div class="form-switch"> <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();"> <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> </div>
</div> </div>
@ -151,7 +154,7 @@
let fpr = option.find("#fingerprint") let fpr = option.find("#fingerprint")
fpr.find("#fpr").text(pretty_long_id(key)) fpr.find("#fpr").text(pretty_long_id(key))
fpr.find("#subkeys").text(` (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`) fpr.find("#subkeys").text(` (${keyinfo.subkeys.length} subkey${keyinfo.subkeys.length == 1 ? "" : "s"})`)
option.click(()=>{ 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())
@ -233,7 +236,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())
@ -285,7 +288,7 @@
"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))

View file

@ -6,47 +6,58 @@ 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): env.setdefault(*line.strip().split("=", 1)) for line in open(fn):
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): raise ValueError() # caught below if not isinstance(config, dict):
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.
@ -55,8 +66,8 @@ def sort_domains(domain_names, env):
# 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.
@ -68,18 +79,21 @@ def sort_domains(domain_names, env):
zones[domain] = domain zones[domain] = domain
# Sort the zones. # Sort the zones.
zone_domains = sorted(zones.values(), zone_domains = sorted(
key = lambda d : ( zones.values(),
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, domain_names = sorted(
key = lambda d : ( domain_names,
key=lambda d: (
# First by zone. # First by zone.
zone_domains.index(zones[d]), zone_domains.index(zones[d]),
@ -95,23 +109,33 @@ def sort_domains(domain_names, env):
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(email.split("@", 1)[1] for email in email_addresses if "@" in email) domains = set(
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
if email.endswith("@" + domain))
ret.extend(sorted(domain_emails)) ret.extend(sorted(domain_emails))
email_addresses -= domain_emails email_addresses -= domain_emails
ret.extend(sorted(email_addresses)) # whatever is left ret.extend(sorted(email_addresses)) # whatever is left
return ret return ret
def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
def shell(method,
cmd_args,
env={},
capture_stderr=False,
return_bytes=False,
trap=False,
input=None):
# A safe way to execute processes. # A safe way to execute processes.
# Some processes like apt-get require being given a sane PATH. # Some processes like apt-get require being given a sane PATH.
import subprocess import subprocess
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" }) env.update({"PATH": "/sbin:/bin:/usr/sbin:/usr/bin"})
kwargs = { kwargs = {
'env': env, 'env': env,
'stderr': None if not capture_stderr else subprocess.STDOUT, 'stderr': None if not capture_stderr else subprocess.STDOUT,
@ -128,18 +152,21 @@ def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, tr
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
ret = e.output ret = e.output
code = e.returncode code = e.returncode
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8") if not return_bytes and isinstance(ret, bytes):
ret = ret.decode("utf8")
if not trap: if not trap:
return ret return ret
else: else:
return code, ret 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
@ -159,21 +186,24 @@ def du(path):
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, time import socket
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(): def fix_boto():
# Google Compute Engine instances install some Python-2-only boto plugins that # Google Compute Engine instances install some Python-2-only boto plugins that
@ -182,12 +212,15 @@ def fix_boto():
import os import os
os.environ["BOTO_CONFIG"] = "/etc/boto3.cfg" 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"} 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()
@ -204,6 +237,7 @@ def get_os_code():
return None 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()

View file

@ -2,14 +2,20 @@
# domains for which a mail account has been set up. # domains for which a mail account has been set up.
######################################################################## ########################################################################
import os.path, re, rtyaml import os.path
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()
@ -28,14 +34,20 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
# 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('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True)) domains |= set(
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True)) 'autoconfig.' + maildomain
for maildomain in get_mail_domains(env, users_only=True))
domains |= set(
'autodiscover.' + maildomain
for maildomain in get_mail_domains(env, users_only=True))
# 'mta-sts.' for MTA-STS support for all domains that have email addresses. # 'mta-sts.' for MTA-STS support for all domains that have email addresses.
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env)) domains |= set('mta-sts.' + maildomain
for maildomain in get_mail_domains(env))
# 'openpgpkey.' for WKD support # 'openpgpkey.' for WKD support
domains |= set('openpgpkey.' + maildomain for maildomain in get_mail_domains(env)) domains |= set('openpgpkey.' + maildomain
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
@ -52,34 +64,41 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
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") and value not in ("local", env['PUBLIC_IP'])): if rtype == "CNAME" or (rtype in ("A", "AAAA")
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', {}).get('/')), for type, value in [('redirect', settings.get('redirects',
('proxy', settings.get('proxies', {}).get('/'))]: {}).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)
@ -108,19 +127,24 @@ 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:
flags[d] = DOMAIN_EXTERNAL # -1 = All bits set to 1, assuming twos-complement # -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(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read() nginx_conf = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-top.conf")).read()
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", ".upstream.conf") nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www",
".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([
@ -133,20 +157,29 @@ 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(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read() template0 = open(
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-alldomains.conf")).read() os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read() template1 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-alldomains.conf")).read()
template2 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-primaryonly.conf")).read()
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n" template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
template4 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-openpgpkey.conf")).read() template4 = open(
os.path.join(os.path.dirname(__file__),
"../conf/nginx-openpgpkey.conf")).read()
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server. # Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env) nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'],
[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.
@ -154,14 +187,20 @@ 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(domain, [template0, template1, template4], ssl_certificates, env) nginx_conf += make_domain_config(
domain, [template0, template1, template4],
ssl_certificates, env)
elif flags & DOMAIN_REDIRECT == 0: elif flags & DOMAIN_REDIRECT == 0:
nginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env) nginx_conf += make_domain_config(domain,
[template0, template1],
ssl_certificates, env)
else: else:
nginx_conf += make_domain_config(domain, [template0], ssl_certificates, env) nginx_conf += make_domain_config(domain, [template0],
ssl_certificates, env)
else: else:
# Add default 'www.' redirect. # Add default 'www.' redirect.
nginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env) nginx_conf += make_domain_config(domain, [template0, template3],
ssl_certificates, env)
# Did the file change? If not, don't bother writing & restarting nginx. # Did the file change? If not, don't bother writing & restarting nginx.
nginx_conf_fn = "/etc/nginx/conf.d/local.conf" nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
@ -182,6 +221,7 @@ 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
@ -206,7 +246,9 @@ 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"
@ -251,7 +293,8 @@ 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, url) nginx_conf_extra += "\trewrite %s %s permanent;\n" % (path,
url)
# override the HSTS directive type # override the HSTS directive type
hsts = yaml.get("hsts", hsts) hsts = yaml.get("hsts", hsts)
@ -263,7 +306,9 @@ 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(env["STORAGE_ROOT"], "www", safe_domain_name(domain) + ".conf") nginx_conf_custom_include = os.path.join(
env["STORAGE_ROOT"], "www",
safe_domain_name(domain) + ".conf")
if 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([
@ -280,57 +325,75 @@ 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) nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t,
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", tls_cert["certificate"]) nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE",
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain tls_cert["certificate"])
nginx_conf = nginx_conf.replace(
"$REDIRECT_DOMAIN",
re.sub(r"^www\.", "",
domain)) # for default www redirects to parent domain
return nginx_conf return nginx_conf
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", safe_domain_name(test_domain)) root = os.path.join(env["STORAGE_ROOT"], "www",
if os.path.exists(root) or not test_exists: break safe_domain_name(test_domain))
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)) - set(get_web_domains(env, include_www_redirects=False)) www_redirects = set(get_web_domains(env)) - \
set(get_web_domains(env, include_www_redirects=False))
has_root_proxy_or_redirect = set(get_web_domains_with_root_overrides(env)) 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, ssl_certificates, env, allow_missing_cert=True) tls_cert = get_domain_ssl_files(domain,
ssl_certificates,
env,
allow_missing_cert=True)
except OSError: # PRIMARY_HOSTNAME cert is missing except OSError: # PRIMARY_HOSTNAME cert is missing
tls_cert = None tls_cert = None
if tls_cert is None: return ("danger", "No certificate installed.") if tls_cert is None:
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"]) return ("danger", "No certificate installed.")
cert_status, cert_status_details = check_certificate(
domain, 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", "Self-signed. Get a signed certificate to stop warnings.") return ("warning",
"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": get_web_root(domain, env), "root":
"custom_root": get_web_root(domain, env, test_exists=False), get_web_root(domain, env),
"ssl_certificate": check_cert(domain), "custom_root":
"static_enabled": domain not in (www_redirects | has_root_proxy_or_redirect), get_web_root(domain, env, test_exists=False),
} "ssl_certificate":
for domain in get_web_domains(env) check_cert(domain),
] "static_enabled":
domain not in (www_redirects | has_root_proxy_or_redirect),
} for domain in get_web_domains(env)]

View file

@ -2,7 +2,14 @@
# 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, utils, rtyaml, mailconfig, copy, shutil, os, re import pgp
import utils
import rtyaml
import mailconfig
import copy
import shutil
import os
import re
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -10,21 +17,25 @@ 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:
@ -32,7 +43,7 @@ def zbase32(digest):
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
@ -57,11 +68,7 @@ def strip_and_export(fpr, target_email, buffer=None, context=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 = { statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
"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]),.*"
@ -85,7 +92,6 @@ 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
@ -108,6 +114,7 @@ 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):
@ -121,13 +128,18 @@ 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(f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!") raise WKDError(
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):
@ -141,7 +153,10 @@ 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:
@ -159,9 +174,12 @@ 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():
@ -177,8 +195,11 @@ 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 = []
@ -198,7 +219,8 @@ def parse_wkd_list():
key = email_compatible_with_key(u, k) key = email_compatible_with_key(u, k)
# Key is compatible # Key is compatible
writeable[u] = key.fpr # Swap with the full-length fingerprint (if somehow this was changed by hand) # 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)
@ -208,8 +230,10 @@ 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: