Add some level of style guidance, reformat files
This commit is contained in:
parent
14534fd0cb
commit
a8f5a78518
35 changed files with 6396 additions and 4609 deletions
|
@ -4,30 +4,23 @@
|
|||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
[Vagrantfile]
|
||||
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
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = 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
3
.style.yapf
Normal file
|
@ -0,0 +1,3 @@
|
|||
[style]
|
||||
based_on_style = pep8
|
||||
use_tabs = True
|
|
@ -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 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_AUTH_REALM = 'Mail-in-a-Box Management Server'
|
||||
|
||||
|
||||
class AuthService:
|
||||
|
||||
def __init__(self):
|
||||
self.auth_realm = DEFAULT_AUTH_REALM
|
||||
self.key_path = DEFAULT_KEY_PATH
|
||||
self.max_session_duration = timedelta(days=2)
|
||||
|
||||
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):
|
||||
"""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
|
||||
old_umask = os.umask(0)
|
||||
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:
|
||||
os.umask(old_umask)
|
||||
|
||||
|
@ -46,8 +56,10 @@ class AuthService:
|
|||
this key is not associated with a user."""
|
||||
|
||||
def parse_http_authorization_basic(header):
|
||||
|
||||
def decode(s):
|
||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||
|
||||
if " " not in header:
|
||||
return None, None
|
||||
scheme, credentials = header.split(maxsplit=1)
|
||||
|
@ -59,12 +71,15 @@ class AuthService:
|
|||
username, password = credentials.split(':', maxsplit=1)
|
||||
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, ""):
|
||||
raise ValueError("Authorization header invalid.")
|
||||
|
||||
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
|
||||
# is not associated with a user.
|
||||
|
@ -72,7 +87,8 @@ class AuthService:
|
|||
return (None, ["admin"])
|
||||
|
||||
# If the password corresponds with a session token for the user, grant access for that user.
|
||||
if self.get_session(username, password, "login", env) and not login_only:
|
||||
if self.get_session(username, password, "login",
|
||||
env) and not login_only:
|
||||
sessionid = password
|
||||
session = self.sessions[sessionid]
|
||||
if logout:
|
||||
|
@ -96,7 +112,8 @@ class AuthService:
|
|||
# deleted after the session was granted. On error the call will return a tuple
|
||||
# of an error message and an HTTP status code.
|
||||
privs = get_mail_user_privileges(username, env)
|
||||
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||
if isinstance(privs, tuple):
|
||||
raise ValueError(privs[0])
|
||||
|
||||
# Return the authorization information.
|
||||
return (username, privs)
|
||||
|
@ -120,9 +137,12 @@ class AuthService:
|
|||
# a non-zero exit status if the credentials are no good,
|
||||
# and check_call will raise an exception in that case.
|
||||
utils.shell('check_call', [
|
||||
"/usr/bin/doveadm", "pw",
|
||||
"-p", pw,
|
||||
"-t", pw_hash,
|
||||
"/usr/bin/doveadm",
|
||||
"pw",
|
||||
"-p",
|
||||
pw,
|
||||
"-t",
|
||||
pw_hash,
|
||||
])
|
||||
except:
|
||||
# Login failed.
|
||||
|
@ -141,7 +161,8 @@ class AuthService:
|
|||
|
||||
# Add to the message the current MFA state, which is a list of MFA information.
|
||||
# Turn it into a string stably.
|
||||
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
|
||||
msg += b" " + json.dumps(get_hash_mfa_state(email, env),
|
||||
sort_keys=True).encode("utf8")
|
||||
|
||||
# Make a HMAC using the system API key as a hash key.
|
||||
hash_key = self.key.encode('ascii')
|
||||
|
@ -152,15 +173,21 @@ class AuthService:
|
|||
token = secrets.token_hex(32)
|
||||
self.sessions[token] = {
|
||||
"email": username,
|
||||
"password_token": self.create_user_password_state_token(username, env),
|
||||
"password_token":
|
||||
self.create_user_password_state_token(username, env),
|
||||
"type": type,
|
||||
}
|
||||
return token
|
||||
|
||||
def get_session(self, user_email, session_key, session_type, env):
|
||||
if session_key not in self.sessions: return None
|
||||
if session_key not in self.sessions:
|
||||
return None
|
||||
session = self.sessions[session_key]
|
||||
if session_type == "login" and session["email"] != user_email: return None
|
||||
if session["type"] != session_type: return None
|
||||
if session["password_token"] != self.create_user_password_state_token(session["email"], env): return None
|
||||
if session_type == "login" and session["email"] != user_email:
|
||||
return None
|
||||
if session["type"] != session_type:
|
||||
return None
|
||||
if session["password_token"] != self.create_user_password_state_token(
|
||||
session["email"], env):
|
||||
return None
|
||||
return session
|
||||
|
|
|
@ -7,13 +7,22 @@
|
|||
# 4) The stopped services are restarted.
|
||||
# 5) STORAGE_ROOT/backup/after-backup is executed if it exists.
|
||||
|
||||
import os, os.path, shutil, glob, re, datetime, sys
|
||||
import dateutil.parser, dateutil.relativedelta, dateutil.tz
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import glob
|
||||
import re
|
||||
import datetime
|
||||
import sys
|
||||
import dateutil.parser
|
||||
import dateutil.relativedelta
|
||||
import dateutil.tz
|
||||
import rtyaml
|
||||
from exclusiveprocess import Lock, CannotAcquireLock
|
||||
|
||||
from utils import load_environment, shell, wait_for_service, fix_boto, get_php_version, get_os_code
|
||||
|
||||
|
||||
def rsync_ssh_options(port=22, direct=False):
|
||||
# Just in case we pass a string
|
||||
try:
|
||||
|
@ -29,6 +38,7 @@ 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\"",
|
||||
]
|
||||
|
||||
|
||||
def backup_status(env):
|
||||
# If backups are disabled, return no status.
|
||||
config = get_backup_config(env)
|
||||
|
@ -44,15 +54,23 @@ def backup_status(env):
|
|||
backup_cache_dir = os.path.join(backup_root, 'cache')
|
||||
|
||||
def reldate(date, ref, clip):
|
||||
if ref < date: return clip
|
||||
if ref < date:
|
||||
return clip
|
||||
rd = dateutil.relativedelta.relativedelta(ref, date)
|
||||
if rd.years > 1: return "%d years, %d months" % (rd.years, rd.months)
|
||||
if rd.years == 1: return "%d year, %d months" % (rd.years, rd.months)
|
||||
if rd.months > 1: return "%d months, %d days" % (rd.months, rd.days)
|
||||
if rd.months == 1: return "%d 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)
|
||||
if rd.years > 1:
|
||||
return "%d years, %d months" % (rd.years, rd.months)
|
||||
if rd.years == 1:
|
||||
return "%d year, %d months" % (rd.years, rd.months)
|
||||
if rd.months > 1:
|
||||
return "%d months, %d days" % (rd.months, rd.days)
|
||||
if rd.months == 1:
|
||||
return "%d month, %d days" % (rd.months, rd.days)
|
||||
if rd.days >= 7:
|
||||
return "%d days" % rd.days
|
||||
if rd.days > 1:
|
||||
return "%d days, %d hours" % (rd.days, rd.hours)
|
||||
if rd.days == 1:
|
||||
return "%d day, %d hours" % (rd.days, rd.hours)
|
||||
return "%d hours, %d minutes" % (rd.hours, rd.minutes)
|
||||
|
||||
# Get duplicity collection status and parse for a list of backups.
|
||||
|
@ -65,15 +83,21 @@ def backup_status(env):
|
|||
"date_delta": reldate(date, now, "the future?"),
|
||||
"full": keys[0] == "full",
|
||||
"size": 0, # collection-status doesn't give us the size
|
||||
"volumes": int(keys[2]), # number of archive volumes for this backup (not really helpful)
|
||||
# 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",
|
||||
"collection-status",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--gpg-options", "--cipher-algo=AES256",
|
||||
"--log-fd", "1",
|
||||
"--archive-dir",
|
||||
backup_cache_dir,
|
||||
"--gpg-options",
|
||||
"--cipher-algo=AES256",
|
||||
"--log-fd",
|
||||
"1",
|
||||
config["target"],
|
||||
] + rsync_ssh_options(port=config["target_rsync_port"]),
|
||||
get_env(env),
|
||||
|
@ -81,7 +105,8 @@ def backup_status(env):
|
|||
if code != 0:
|
||||
# Command failed. This is likely due to an improperly configured remote
|
||||
# destination for the backups or the last backup job terminated unexpectedly.
|
||||
raise Exception("Something is wrong with the backup: " + collection_status)
|
||||
raise Exception("Something is wrong with the backup: " +
|
||||
collection_status)
|
||||
for line in collection_status.split('\n'):
|
||||
if line.startswith(" full") or line.startswith(" inc"):
|
||||
backup = parse_line(line)
|
||||
|
@ -94,8 +119,11 @@ def backup_status(env):
|
|||
# space is used for those.
|
||||
unmatched_file_size = 0
|
||||
for fn, size in list_target_files(config):
|
||||
m = re.match(r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.", fn)
|
||||
if not m: continue # not a part of a current backup chain
|
||||
m = re.match(
|
||||
r"duplicity-(full|full-signatures|(inc|new-signatures)\.(?P<incbase>\d+T\d+Z)\.to)\.(?P<date>\d+T\d+Z)\.",
|
||||
fn)
|
||||
if not m:
|
||||
continue # not a part of a current backup chain
|
||||
key = m.group("date")
|
||||
if key in backups:
|
||||
backups[key]["size"] += size
|
||||
|
@ -133,16 +161,23 @@ def backup_status(env):
|
|||
if incremental_count > 0 and incremental_size > 0 and first_full_size is not None:
|
||||
# How many days until the next incremental backup? First, the part of
|
||||
# the algorithm based on increment sizes:
|
||||
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (incremental_size/incremental_count)
|
||||
est_time_of_next_full = first_date + datetime.timedelta(days=est_days_to_next_full)
|
||||
est_days_to_next_full = (.5 * first_full_size - incremental_size) / (
|
||||
incremental_size / incremental_count)
|
||||
est_time_of_next_full = first_date + \
|
||||
datetime.timedelta(days=est_days_to_next_full)
|
||||
|
||||
# ...And then the part of the algorithm based on full backup age:
|
||||
est_time_of_next_full = min(est_time_of_next_full, first_full_date + datetime.timedelta(days=config["min_age_in_days"]*10+1))
|
||||
est_time_of_next_full = min(
|
||||
est_time_of_next_full, first_full_date +
|
||||
datetime.timedelta(days=config["min_age_in_days"] * 10 + 1))
|
||||
|
||||
# It still can't be deleted until it's old enough.
|
||||
est_deleted_on = max(est_time_of_next_full, first_date + datetime.timedelta(days=config["min_age_in_days"]))
|
||||
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.
|
||||
saw_full = False
|
||||
|
@ -158,7 +193,11 @@ def backup_status(env):
|
|||
elif saw_full and not deleted_in:
|
||||
# We're now on backups prior to the most recent full backup. These are
|
||||
# free to be deleted as soon as they are min_age_in_days old.
|
||||
deleted_in = reldate(now, dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]), "on next daily backup")
|
||||
deleted_in = reldate(
|
||||
now,
|
||||
dateutil.parser.parse(bak["date"]) +
|
||||
datetime.timedelta(days=config["min_age_in_days"]),
|
||||
"on next daily backup")
|
||||
bak["deleted_in"] = deleted_in
|
||||
|
||||
return {
|
||||
|
@ -166,6 +205,7 @@ def backup_status(env):
|
|||
"unmatched_file_size": unmatched_file_size,
|
||||
}
|
||||
|
||||
|
||||
def should_force_full(config, env):
|
||||
# Force a full backup when the total size of the increments
|
||||
# since the last full backup is greater than half the size
|
||||
|
@ -183,7 +223,9 @@ def should_force_full(config, env):
|
|||
# backup, as well as the age of the full backup.
|
||||
if inc_size > .5 * bak["size"]:
|
||||
return True
|
||||
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(days=config["min_age_in_days"]*10+1) < datetime.datetime.now(dateutil.tz.tzlocal()):
|
||||
if dateutil.parser.parse(bak["date"]) + datetime.timedelta(
|
||||
days=config["min_age_in_days"] * 10 +
|
||||
1) < datetime.datetime.now(dateutil.tz.tzlocal()):
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
|
@ -191,6 +233,7 @@ def should_force_full(config, env):
|
|||
# (I love for/else blocks. Here it's just to show off.)
|
||||
return True
|
||||
|
||||
|
||||
def get_passphrase(env):
|
||||
# Get the encryption passphrase. secret_key.txt is 2048 random
|
||||
# bits base64-encoded and with line breaks every 65 characters.
|
||||
|
@ -201,10 +244,12 @@ def get_passphrase(env):
|
|||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
with open(os.path.join(backup_root, 'secret_key.txt')) as f:
|
||||
passphrase = f.readline().strip()
|
||||
if len(passphrase) < 43: raise Exception("secret_key.txt's first line is too short!")
|
||||
if len(passphrase) < 43:
|
||||
raise Exception("secret_key.txt's first line is too short!")
|
||||
|
||||
return passphrase
|
||||
|
||||
|
||||
def get_env(env):
|
||||
config = get_backup_config(env)
|
||||
|
||||
|
@ -216,10 +261,12 @@ def get_env(env):
|
|||
|
||||
return env
|
||||
|
||||
|
||||
def get_target_type(config):
|
||||
protocol = config["target"].split(":")[0]
|
||||
return protocol
|
||||
|
||||
|
||||
def perform_backup(full_backup, user_initiated=False):
|
||||
env = load_environment()
|
||||
php_fpm = f"php{get_php_version()}-fpm"
|
||||
|
@ -260,7 +307,10 @@ def perform_backup(full_backup, user_initiated=False):
|
|||
# Stop services.
|
||||
def service_command(service, command, quit=None):
|
||||
# Execute silently, but if there is an error then display the output & exit.
|
||||
code, ret = shell('check_output', ["/usr/sbin/service", service, command], capture_stderr=True, trap=True)
|
||||
code, ret = shell('check_output',
|
||||
["/usr/sbin/service", service, command],
|
||||
capture_stderr=True,
|
||||
trap=True)
|
||||
if code != 0:
|
||||
print(ret)
|
||||
if quit:
|
||||
|
@ -284,18 +334,12 @@ def perform_backup(full_backup, user_initiated=False):
|
|||
# after the first backup. See #396.
|
||||
try:
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"full" if full_backup else "incr",
|
||||
"--verbosity", "warning", "--no-print-statistics",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--exclude", backup_root,
|
||||
"--volsize", "250",
|
||||
"--gpg-options", "--cipher-algo=AES256",
|
||||
env["STORAGE_ROOT"],
|
||||
config["target"],
|
||||
"--allow-source-mismatch"
|
||||
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||
get_env(env))
|
||||
"/usr/bin/duplicity", "full" if full_backup else "incr",
|
||||
"--verbosity", "warning", "--no-print-statistics", "--archive-dir",
|
||||
backup_cache_dir, "--exclude", backup_root, "--volsize", "250",
|
||||
"--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:
|
||||
# Start services again.
|
||||
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
|
||||
# from more than 3 days ago.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"remove-older-than",
|
||||
"%dD" % config["min_age_in_days"],
|
||||
"--verbosity", "error",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--force",
|
||||
config["target"]
|
||||
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||
get_env(env))
|
||||
"/usr/bin/duplicity", "remove-older-than",
|
||||
"%dD" % config["min_age_in_days"], "--verbosity", "error",
|
||||
"--archive-dir", backup_cache_dir, "--force", config["target"]
|
||||
] + rsync_ssh_options(port=config["target_rsync_port"]), get_env(env))
|
||||
|
||||
# From duplicity's manual:
|
||||
# "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 does happen - it might just have been a poorly timed reboot.
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"cleanup",
|
||||
"--verbosity", "error",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--force",
|
||||
config["target"]
|
||||
] + rsync_ssh_options(port = config["target_rsync_port"]),
|
||||
get_env(env))
|
||||
"/usr/bin/duplicity", "cleanup", "--verbosity", "error",
|
||||
"--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
|
||||
# script can access them.
|
||||
if get_target_type(config) == 'file':
|
||||
shell('check_call', ["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||
shell('check_call',
|
||||
["/bin/chown", "-R", env["STORAGE_USER"], backup_dir])
|
||||
|
||||
# Execute a post-backup script that does the copying to a remote server.
|
||||
# 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(993, True, env, 10)
|
||||
|
||||
|
||||
def run_duplicity_verification():
|
||||
env = load_environment()
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
|
@ -364,15 +400,19 @@ def run_duplicity_verification():
|
|||
|
||||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"--verbosity", "info",
|
||||
"--verbosity",
|
||||
"info",
|
||||
"verify",
|
||||
"--compare-data",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--exclude", backup_root,
|
||||
"--archive-dir",
|
||||
backup_cache_dir,
|
||||
"--exclude",
|
||||
backup_root,
|
||||
config["target"],
|
||||
env["STORAGE_ROOT"],
|
||||
] + rsync_ssh_options(port=config["target_rsync_port"]), get_env(env))
|
||||
|
||||
|
||||
def run_duplicity_restore(args):
|
||||
env = load_environment()
|
||||
config = get_backup_config(env)
|
||||
|
@ -380,11 +420,13 @@ def run_duplicity_restore(args):
|
|||
shell('check_call', [
|
||||
"/usr/bin/duplicity",
|
||||
"restore",
|
||||
"--archive-dir", backup_cache_dir,
|
||||
"--archive-dir",
|
||||
backup_cache_dir,
|
||||
config["target"],
|
||||
] + rsync_ssh_options(port=config["target_rsync_port"]) + args,
|
||||
get_env(env))
|
||||
|
||||
|
||||
def list_target_files(config):
|
||||
import urllib.parse
|
||||
try:
|
||||
|
@ -393,7 +435,8 @@ def list_target_files(config):
|
|||
return "invalid target"
|
||||
|
||||
if target.scheme == "file":
|
||||
return [(fn, os.path.getsize(os.path.join(target.path, fn))) for fn in os.listdir(target.path)]
|
||||
return [(fn, os.path.getsize(os.path.join(target.path, fn)))
|
||||
for fn in os.listdir(target.path)]
|
||||
|
||||
elif target.scheme == "rsync":
|
||||
rsync_fn_size_re = re.compile(r'.* ([^ ]*) [^ ]* [^ ]* (.*)')
|
||||
|
@ -405,23 +448,24 @@ def list_target_files(config):
|
|||
if target_path.startswith('/'):
|
||||
target_path = target_path[1:]
|
||||
|
||||
rsync_command = [ 'rsync',
|
||||
'-e',
|
||||
rsync_command = [
|
||||
'rsync', '-e',
|
||||
rsync_ssh_options(config["target_rsync_port"], direct=True),
|
||||
'--list-only',
|
||||
'-r',
|
||||
rsync_target.format(
|
||||
host=target.netloc,
|
||||
path=target_path)
|
||||
'--list-only', '-r',
|
||||
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:
|
||||
ret = []
|
||||
for l in listing.split('\n'):
|
||||
match = rsync_fn_size_re.match(l)
|
||||
if match:
|
||||
ret.append( (match.groups()[1], int(match.groups()[0].replace(',',''))) )
|
||||
ret.append((match.groups()[1],
|
||||
int(match.groups()[0].replace(',', ''))))
|
||||
return ret
|
||||
else:
|
||||
if 'Permission denied (publickey).' in listing:
|
||||
|
@ -429,14 +473,17 @@ def list_target_files(config):
|
|||
elif 'No such file or directory' in listing:
|
||||
reason = "Provided path {} is invalid.".format(target_path)
|
||||
elif 'Network is unreachable' in listing:
|
||||
reason = "The IP address {} is unreachable.".format(target.hostname)
|
||||
reason = "The IP address {} is unreachable.".format(
|
||||
target.hostname)
|
||||
elif 'Could not resolve hostname' in listing:
|
||||
reason = "The hostname {} cannot be resolved.".format(target.hostname)
|
||||
reason = "The hostname {} cannot be resolved.".format(
|
||||
target.hostname)
|
||||
else:
|
||||
reason = "Unknown error. " \
|
||||
"Please check running 'management/backup.py --verify' " \
|
||||
"from mailinabox sources to debug the issue."
|
||||
raise ValueError("Connection to rsync host failed: {}".format(reason))
|
||||
raise ValueError(
|
||||
"Connection to rsync host failed: {}".format(reason))
|
||||
|
||||
elif target.scheme == "s3":
|
||||
# match to a Region
|
||||
|
@ -457,7 +504,9 @@ def list_target_files(config):
|
|||
# Create a custom region with custom endpoint
|
||||
if custom_region:
|
||||
from boto.s3.connection import S3Connection
|
||||
region = boto.s3.S3RegionInfo(name=bucket, endpoint=target.hostname, connection_cls=S3Connection)
|
||||
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 path == '/':
|
||||
|
@ -468,7 +517,8 @@ def list_target_files(config):
|
|||
|
||||
# connect to the region & bucket
|
||||
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)
|
||||
except BotoServerError as e:
|
||||
if e.status == 403:
|
||||
|
@ -479,7 +529,8 @@ def list_target_files(config):
|
|||
raise ValueError("Incorrect region for this bucket.")
|
||||
raise ValueError(e.reason)
|
||||
|
||||
return [(key.name[len(path):], key.size) for key in bucket.list(prefix=path)]
|
||||
return [(key.name[len(path):], key.size)
|
||||
for key in bucket.list(prefix=path)]
|
||||
elif target.scheme == 'b2':
|
||||
InMemoryAccountInfo = None
|
||||
B2Api = None
|
||||
|
@ -500,21 +551,26 @@ def list_target_files(config):
|
|||
|
||||
# Extract information from target
|
||||
b2_application_keyid = target.netloc[:target.netloc.index(':')]
|
||||
b2_application_key = target.netloc[target.netloc.index(':')+1:target.netloc.index('@')]
|
||||
b2_application_key = target.netloc[target.netloc.index(':') +
|
||||
1:target.netloc.index('@')]
|
||||
b2_bucket = target.netloc[target.netloc.index('@') + 1:]
|
||||
|
||||
try:
|
||||
b2_api.authorize_account("production", b2_application_keyid, b2_application_key)
|
||||
b2_api.authorize_account("production", b2_application_keyid,
|
||||
b2_application_key)
|
||||
bucket = b2_api.get_bucket_by_name(b2_bucket)
|
||||
except NonExistentBucket as e:
|
||||
raise ValueError("B2 Bucket does not exist. Please double check your information!")
|
||||
raise ValueError(
|
||||
"B2 Bucket does not exist. Please double check your information!"
|
||||
)
|
||||
return [(key.file_name, key.size) for key, _ in bucket.ls()]
|
||||
|
||||
else:
|
||||
raise ValueError(config["target"])
|
||||
|
||||
|
||||
def backup_set_custom(env, target, target_user, target_pass, target_rsync_port, min_age):
|
||||
def backup_set_custom(env, target, target_user, target_pass, target_rsync_port,
|
||||
min_age):
|
||||
config = get_backup_config(env, for_save=True)
|
||||
|
||||
# min_age must be an int
|
||||
|
@ -546,20 +602,19 @@ def backup_set_custom(env, target, target_user, target_pass, target_rsync_port,
|
|||
|
||||
return "OK"
|
||||
|
||||
|
||||
def get_backup_config(env, for_save=False, for_ui=False):
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
|
||||
# Defaults.
|
||||
config = {
|
||||
"min_age_in_days": 3,
|
||||
"target": "local",
|
||||
"target_rsync_port": 22
|
||||
}
|
||||
config = {"min_age_in_days": 3, "target": "local", "target_rsync_port": 22}
|
||||
|
||||
# Merge in anything written to custom.yaml.
|
||||
try:
|
||||
custom_config = rtyaml.load(open(os.path.join(backup_root, 'custom.yaml')))
|
||||
if not isinstance(custom_config, dict): raise ValueError() # caught below
|
||||
custom_config = rtyaml.load(
|
||||
open(os.path.join(backup_root, 'custom.yaml')))
|
||||
if not isinstance(custom_config, dict):
|
||||
raise ValueError() # caught below
|
||||
config.update(custom_config)
|
||||
except:
|
||||
pass
|
||||
|
@ -587,11 +642,13 @@ def get_backup_config(env, for_save=False, for_ui=False):
|
|||
|
||||
return config
|
||||
|
||||
|
||||
def write_backup_config(env, newconfig):
|
||||
backup_root = os.path.join(env["STORAGE_ROOT"], 'backup')
|
||||
with open(os.path.join(backup_root, 'custom.yaml'), "w") as f:
|
||||
f.write(rtyaml.dump(newconfig))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if sys.argv[-1] == "--verify":
|
||||
|
@ -601,7 +658,8 @@ if __name__ == "__main__":
|
|||
|
||||
elif sys.argv[-1] == "--list":
|
||||
# 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))
|
||||
|
||||
elif sys.argv[-1] == "--status":
|
||||
|
|
|
@ -6,7 +6,14 @@
|
|||
# root API key. This file is readable only by root, so this
|
||||
# tool can only be used as root.
|
||||
|
||||
import sys, getpass, urllib.request, urllib.error, json, re, csv
|
||||
import sys
|
||||
import getpass
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json
|
||||
import re
|
||||
import csv
|
||||
|
||||
|
||||
def mgmt(cmd, data=None, is_json=False):
|
||||
# The base URL for the management daemon. (Listens on IPv4 only.)
|
||||
|
@ -14,7 +21,9 @@ def mgmt(cmd, data=None, is_json=False):
|
|||
|
||||
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:
|
||||
response = urllib.request.urlopen(req)
|
||||
except urllib.error.HTTPError as e:
|
||||
|
@ -23,16 +32,20 @@ def mgmt(cmd, data=None, is_json=False):
|
|||
print(e.read().decode("utf8"))
|
||||
except:
|
||||
pass
|
||||
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
|
||||
print(
|
||||
"The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.",
|
||||
file=sys.stderr)
|
||||
elif hasattr(e, 'read'):
|
||||
print(e.read().decode('utf8'), file=sys.stderr)
|
||||
else:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
resp = response.read().decode('utf8')
|
||||
if is_json: resp = json.loads(resp)
|
||||
if is_json:
|
||||
resp = json.loads(resp)
|
||||
return resp
|
||||
|
||||
|
||||
def read_password():
|
||||
while True:
|
||||
first = getpass.getpass('password: ')
|
||||
|
@ -46,18 +59,19 @@ def read_password():
|
|||
break
|
||||
return first
|
||||
|
||||
|
||||
def setup_key_auth(mgmt_uri):
|
||||
key = open('/var/lib/mailinabox/api.key').read().strip()
|
||||
|
||||
auth_handler = urllib.request.HTTPBasicAuthHandler()
|
||||
auth_handler.add_password(
|
||||
realm='Mail-in-a-Box Management Server',
|
||||
auth_handler.add_password(realm='Mail-in-a-Box Management Server',
|
||||
uri=mgmt_uri,
|
||||
user=key,
|
||||
passwd='')
|
||||
opener = urllib.request.build_opener(auth_handler)
|
||||
urllib.request.install_opener(opener)
|
||||
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("""Usage:
|
||||
{cli} system default-quota [new default] (set default quota for system)
|
||||
|
@ -77,16 +91,15 @@ if len(sys.argv) < 2:
|
|||
{cli} alias remove incoming.name@domain.com
|
||||
|
||||
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
|
||||
""".format(
|
||||
cli="management/cli.py"
|
||||
))
|
||||
""".format(cli="management/cli.py"))
|
||||
|
||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||
# Dump a list of users, one per line. Mark admins with an asterisk.
|
||||
users = mgmt("/mail/users?format=json", is_json=True)
|
||||
for domain in users:
|
||||
for user in domain["users"]:
|
||||
if user['status'] == 'inactive': continue
|
||||
if user['status'] == 'inactive':
|
||||
continue
|
||||
print(user['email'], end='')
|
||||
if "admin" in user['privileges']:
|
||||
print("*", end='')
|
||||
|
@ -114,12 +127,18 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
|||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
print(mgmt("/mail/users/remove", {"email": sys.argv[3]}))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
|
||||
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin",
|
||||
"remove-admin") and len(
|
||||
sys.argv) == 4:
|
||||
if sys.argv[2] == "make-admin":
|
||||
action = "add"
|
||||
else:
|
||||
action = "remove"
|
||||
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
|
||||
print(
|
||||
mgmt("/mail/users/privileges/" + action, {
|
||||
"email": sys.argv[3],
|
||||
"privilege": "admin"
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
|
||||
# Dump a list of admin users.
|
||||
|
@ -135,9 +154,13 @@ 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:
|
||||
# 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.
|
||||
status = mgmt("/mfa/status", {"user": sys.argv[4]}, is_json=True)
|
||||
W = csv.writer(sys.stdout)
|
||||
|
@ -145,26 +168,37 @@ elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "
|
|||
for mfa in status["enabled_mfa"]:
|
||||
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
|
||||
|
||||
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||
elif sys.argv[1] == "user" and len(
|
||||
sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
|
||||
# Disable MFA (all or a particular device) for a user.
|
||||
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
|
||||
print(
|
||||
mgmt(
|
||||
"/mfa/disable", {
|
||||
"user": sys.argv[4],
|
||||
"mfa-id": sys.argv[5] if len(sys.argv) == 6 else None
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||
print(mgmt("/mail/aliases"))
|
||||
|
||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
|
||||
print(
|
||||
mgmt("/mail/aliases/add", {
|
||||
"address": sys.argv[3],
|
||||
"forwards_to": sys.argv[4]
|
||||
}))
|
||||
|
||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||
print(mgmt("/mail/aliases/remove", {"address": sys.argv[3]}))
|
||||
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 3:
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
||||
sys.argv) == 3:
|
||||
print(mgmt("/system/default-quota?text=1"))
|
||||
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 4:
|
||||
elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(
|
||||
sys.argv) == 4:
|
||||
print(mgmt("/system/default-quota", {"default_quota": sys.argv[3]}))
|
||||
|
||||
else:
|
||||
print("Invalid command-line arguments.")
|
||||
sys.exit(1)
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -4,7 +4,14 @@
|
|||
# and mail aliases and restarts nsd.
|
||||
########################################################################
|
||||
|
||||
import sys, os, os.path, urllib.parse, datetime, re, hashlib, base64
|
||||
import sys
|
||||
import os
|
||||
import os.path
|
||||
import urllib.parse
|
||||
import datetime
|
||||
import re
|
||||
import hashlib
|
||||
import base64
|
||||
import ipaddress
|
||||
import rtyaml
|
||||
import idna
|
||||
|
@ -19,10 +26,13 @@ from ssl_certificates import get_ssl_certificates, check_certificate
|
|||
# DNS but not in URLs), which are common in certain record types like for DKIM.
|
||||
DOMAIN_RE = "^(?!\-)(?:[*][.])?(?:[a-zA-Z\d\-_]{0,62}[a-zA-Z\d_]\.){1,126}(?!\d+)[a-zA-Z\d_]{1,63}(\.?)$"
|
||||
|
||||
DEFAULT_TTL = 86400 # 24 hours; MIAB-generated records and all custom records without a specified TTL will use this one.
|
||||
TTL_MIN = 30 # 30 seconds; Most resolvers will not honor TTL values below this one. Some have an higher min TTL.
|
||||
# 24 hours; MIAB-generated records and all custom records without a specified TTL will use this one.
|
||||
DEFAULT_TTL = 86400
|
||||
# 30 seconds; Most resolvers will not honor TTL values below this one. Some have an higher min TTL.
|
||||
TTL_MIN = 30
|
||||
TTL_MAX = 2592000 # 30 days; some DNS services have lower caps (7 days)
|
||||
|
||||
|
||||
def get_dns_domains(env):
|
||||
# Add all domain names in use by email users and mail aliases, any
|
||||
# domains we serve web for (except www redirects because that would
|
||||
|
@ -35,6 +45,7 @@ def get_dns_domains(env):
|
|||
domains.add(env['PRIMARY_HOSTNAME'])
|
||||
return domains
|
||||
|
||||
|
||||
def get_dns_zones(env):
|
||||
# What domains should we create DNS zones for? Never create a zone for
|
||||
# a domain & a subdomain of that domain.
|
||||
|
@ -65,6 +76,7 @@ def get_dns_zones(env):
|
|||
|
||||
return zonefiles
|
||||
|
||||
|
||||
def do_dns_update(env, force=False):
|
||||
# Write zone files.
|
||||
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
||||
|
@ -76,7 +88,8 @@ def do_dns_update(env, force=False):
|
|||
|
||||
# See if the zone has changed, and if so update the serial number
|
||||
# and write the zone file.
|
||||
if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records, env, force):
|
||||
if not write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, records,
|
||||
env, force):
|
||||
# Zone was not updated. There were no changes.
|
||||
continue
|
||||
|
||||
|
@ -124,8 +137,10 @@ def do_dns_update(env, force=False):
|
|||
else:
|
||||
return "updated DNS: " + ",".join(updated_domains) + "\n"
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def build_zones(env):
|
||||
# What domains (and their zone filenames) should we build?
|
||||
domains = get_dns_domains(env)
|
||||
|
@ -136,7 +151,8 @@ def build_zones(env):
|
|||
from mailconfig import get_mail_domains
|
||||
from web_update import get_web_domains
|
||||
mail_domains = set(get_mail_domains(env))
|
||||
mail_user_domains = set(get_mail_domains(env, users_only=True)) # i.e. will log in for mail, Nextcloud
|
||||
# i.e. will log in for mail, Nextcloud
|
||||
mail_user_domains = set(get_mail_domains(env, users_only=True))
|
||||
web_domains = set(get_web_domains(env))
|
||||
auto_domains = web_domains - set(get_web_domains(env, include_auto=False))
|
||||
domains |= auto_domains # www redirects not included in the initial list, see above
|
||||
|
@ -160,7 +176,9 @@ def build_zones(env):
|
|||
|
||||
# For MTA-STS, we'll need to check if the PRIMARY_HOSTNAME certificate is
|
||||
# singned and valid. Check that now rather than repeatedly for each domain.
|
||||
domains[env["PRIMARY_HOSTNAME"]]["certificate-is-valid"] = is_domain_cert_signed_and_valid(env["PRIMARY_HOSTNAME"], env)
|
||||
domains[env["PRIMARY_HOSTNAME"]][
|
||||
"certificate-is-valid"] = is_domain_cert_signed_and_valid(
|
||||
env["PRIMARY_HOSTNAME"], env)
|
||||
|
||||
# Load custom records to add to zones.
|
||||
additional_records = list(get_custom_dns_config(env))
|
||||
|
@ -171,7 +189,12 @@ def build_zones(env):
|
|||
records = build_zone(domain, domains, additional_records, env)
|
||||
yield (domain, zonefile, records)
|
||||
|
||||
def build_zone(domain, domain_properties, additional_records, env, is_zone=True):
|
||||
|
||||
def build_zone(domain,
|
||||
domain_properties,
|
||||
additional_records,
|
||||
env,
|
||||
is_zone=True):
|
||||
records = []
|
||||
|
||||
# Are there any other authorized servers for this domain?
|
||||
|
@ -199,7 +222,8 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
elif idna.encode(r):
|
||||
sr = "a:" + idna.encode(r).decode()
|
||||
else:
|
||||
raise ValueError(f"Unexpected entry on authorized servers: {r}")
|
||||
raise ValueError(
|
||||
f"Unexpected entry on authorized servers: {r}")
|
||||
spf_extra += f"{sr} "
|
||||
if spf_extra.strip() == "":
|
||||
spf_extra = None
|
||||
|
@ -214,7 +238,8 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
# is managed outside of the box.
|
||||
if is_zone:
|
||||
# Obligatory NS record to ns1.PRIMARY_HOSTNAME.
|
||||
records.append((None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False, None))
|
||||
records.append(
|
||||
(None, "NS", "ns1.%s." % env["PRIMARY_HOSTNAME"], False, None))
|
||||
|
||||
# NS record to ns2.PRIMARY_HOSTNAME or whatever the user overrides.
|
||||
# User may provide one or more additional nameservers
|
||||
|
@ -223,23 +248,35 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
for secondary_ns in secondary_ns_list:
|
||||
records.append((None, "NS", secondary_ns + '.', False, None))
|
||||
|
||||
|
||||
# In PRIMARY_HOSTNAME...
|
||||
if domain == env["PRIMARY_HOSTNAME"]:
|
||||
# Set the A/AAAA records. Do this early for the PRIMARY_HOSTNAME so that the user cannot override them
|
||||
# and we can provide different explanatory text.
|
||||
records.append((None, "A", env["PUBLIC_IP"], "Required. Sets the IP address of the box.", None))
|
||||
if env.get("PUBLIC_IPV6"): records.append((None, "AAAA", env["PUBLIC_IPV6"], "Required. Sets the IPv6 address of the box.", None))
|
||||
records.append((None, "A", env["PUBLIC_IP"],
|
||||
"Required. Sets the IP address of the box.", None))
|
||||
if env.get("PUBLIC_IPV6"):
|
||||
records.append(
|
||||
(None, "AAAA", env["PUBLIC_IPV6"],
|
||||
"Required. Sets the IPv6 address of the box.", None))
|
||||
|
||||
# Add a DANE TLSA record for SMTP.
|
||||
records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.", None))
|
||||
records.append(("_25._tcp", "TLSA", build_tlsa_record(
|
||||
env
|
||||
), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.",
|
||||
None))
|
||||
|
||||
# Add a DANE TLSA record for HTTPS, which some browser extensions might make use of.
|
||||
records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it.", None))
|
||||
records.append(("_443._tcp", "TLSA", build_tlsa_record(
|
||||
env
|
||||
), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it.",
|
||||
None))
|
||||
|
||||
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
|
||||
for value in build_sshfp_records():
|
||||
records.append((None, "SSHFP", value, "Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.", None))
|
||||
records.append((
|
||||
None, "SSHFP", value,
|
||||
"Optional. Provides an out-of-band method for verifying an SSH key before connecting. Use 'VerifyHostKeyDNS yes' (or 'VerifyHostKeyDNS ask') when connecting with ssh.",
|
||||
None))
|
||||
|
||||
# Add DNS records for any subdomains of this domain. We should not have a zone for
|
||||
# both a domain and one of its subdomains.
|
||||
|
@ -247,27 +284,36 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
subdomains = [d for d in domain_properties if d.endswith("." + domain)]
|
||||
for subdomain in subdomains:
|
||||
subdomain_qname = subdomain[0:-len("." + domain)]
|
||||
subzone = build_zone(subdomain, domain_properties, additional_records, env, is_zone=False)
|
||||
subzone = build_zone(subdomain,
|
||||
domain_properties,
|
||||
additional_records,
|
||||
env,
|
||||
is_zone=False)
|
||||
for child_qname, child_rtype, child_value, child_explanation, child_ttl in subzone:
|
||||
if child_qname == None:
|
||||
child_qname = subdomain_qname
|
||||
else:
|
||||
child_qname += "." + subdomain_qname
|
||||
records.append((child_qname, child_rtype, child_value, child_explanation, child_ttl))
|
||||
records.append((child_qname, child_rtype, child_value,
|
||||
child_explanation, child_ttl))
|
||||
|
||||
has_rec_base = list(records) # clone current state
|
||||
|
||||
def has_rec(qname, rtype, prefix=None):
|
||||
for rec in has_rec_base:
|
||||
if rec[0] == qname and rec[1] == rtype and (prefix is None or rec[2].startswith(prefix)):
|
||||
if rec[0] == qname and rec[1] == rtype and (
|
||||
prefix is None or rec[2].startswith(prefix)):
|
||||
return True
|
||||
return False
|
||||
|
||||
# The user may set other records that don't conflict with our settings.
|
||||
# Don't put any TXT records above this line, or it'll prevent any custom TXT records.
|
||||
for qname, rtype, value, ttl in filter_custom_records(domain, additional_records):
|
||||
for qname, rtype, value, ttl in filter_custom_records(
|
||||
domain, additional_records):
|
||||
# Don't allow custom records for record types that override anything above.
|
||||
# But allow multiple custom records for the same rtype --- see how has_rec_base is used.
|
||||
if has_rec(qname, rtype): continue
|
||||
if has_rec(qname, rtype):
|
||||
continue
|
||||
|
||||
# The "local" keyword on A/AAAA records are short-hand for our own IP.
|
||||
# This also flags for web configuration that the user wants a website here.
|
||||
|
@ -288,23 +334,33 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
has_rec_base = list(records)
|
||||
a_expl = "Required. May have a different value. Sets the IP address that %s resolves to for web hosting and other services besides mail. The A record must be present but its value does not affect mail delivery." % domain
|
||||
if domain_properties[domain]["auto"]:
|
||||
if domain.startswith("ns1.") or domain.startswith("ns2."): a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server
|
||||
if domain.startswith("www."): a_expl = "Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain." % domain
|
||||
if domain.startswith("mta-sts."): a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."
|
||||
if domain.startswith("autoconfig."): a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig."
|
||||
if domain.startswith("autodiscover."): a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."
|
||||
if domain.startswith("ns1.") or domain.startswith("ns2."):
|
||||
a_expl = False # omit from 'External DNS' page since this only applies if box is its own DNS server
|
||||
if domain.startswith("www."):
|
||||
a_expl = "Optional. Sets the IP address that %s resolves to so that the box can provide a redirect to the parent domain." % domain
|
||||
if domain.startswith("mta-sts."):
|
||||
a_expl = "Optional. MTA-STS Policy Host serving /.well-known/mta-sts.txt."
|
||||
if domain.startswith("autoconfig."):
|
||||
a_expl = "Provides email configuration autodiscovery support for Thunderbird Autoconfig."
|
||||
if domain.startswith("autodiscover."):
|
||||
a_expl = "Provides email configuration autodiscovery support for Z-Push ActiveSync Autodiscover."
|
||||
defaults = [
|
||||
(None, "A", env["PUBLIC_IP"], a_expl),
|
||||
(None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain),
|
||||
(None, "AAAA", env.get('PUBLIC_IPV6'),
|
||||
"Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)"
|
||||
% domain),
|
||||
]
|
||||
for qname, rtype, value, explanation in defaults:
|
||||
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||
if not is_zone and qname == "www": continue # don't create any default 'www' subdomains on what are themselves subdomains
|
||||
if value is None or value.strip() == "":
|
||||
continue # skip IPV6 if not set
|
||||
if not is_zone and qname == "www":
|
||||
continue # don't create any default 'www' subdomains on what are themselves subdomains
|
||||
# Set the default record, but not if:
|
||||
# (1) there is not a user-set record of the same type already
|
||||
# (2) there is not a CNAME record already, since you can't set both and who knows what takes precedence
|
||||
# (2) there is not an A record already (if this is an A record this is a dup of (1), and if this is an AAAA record then don't set a default AAAA record if the user sets a custom A record, since the default wouldn't make sense and it should not resolve if the user doesn't provide a new AAAA record)
|
||||
if not has_rec(qname, rtype) and not has_rec(qname, "CNAME") and not has_rec(qname, "A"):
|
||||
if not has_rec(qname, rtype) and not has_rec(
|
||||
qname, "CNAME") and not has_rec(qname, "A"):
|
||||
records.append((qname, rtype, value, explanation, None))
|
||||
|
||||
# Don't pin the list of records that has_rec checks against anymore.
|
||||
|
@ -313,17 +369,26 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
if domain_properties[domain]["mail"]:
|
||||
# The MX record says where email for the domain should be delivered: Here!
|
||||
if not has_rec(None, "MX", prefix="10 "):
|
||||
records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain, None))
|
||||
records.append((
|
||||
None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"],
|
||||
"Required. Specifies the hostname (and priority) of the machine that handles @%s mail."
|
||||
% domain, None))
|
||||
|
||||
# Append a WKD record.
|
||||
# Skip if the user has set a WKD record already.
|
||||
if not has_rec("openpgpkey", "CNAME") and not has_rec("openpgpkey", "A") and not has_rec("openpgpkey", "AAAA"):
|
||||
if not has_rec("openpgpkey", "CNAME") and not has_rec(
|
||||
"openpgpkey", "A") and not has_rec("openpgpkey", "AAAA"):
|
||||
wkd_records = [
|
||||
("openpgpkey", "A", env["PUBLIC_IP"], "Optional. Specifies that this machine is an authoritative public key source for @%s user-id's." % domain),
|
||||
("openpgpkey", "AAAA", env["PUBLIC_IPV6"], "Optional. Specifies that this machine is an authoritative public key source for @%s user-id's." % domain)
|
||||
("openpgpkey", "A", env["PUBLIC_IP"],
|
||||
"Optional. Specifies that this machine is an authoritative public key source for @%s user-id's."
|
||||
% domain),
|
||||
("openpgpkey", "AAAA", env["PUBLIC_IPV6"],
|
||||
"Optional. Specifies that this machine is an authoritative public key source for @%s user-id's."
|
||||
% domain)
|
||||
]
|
||||
for qname, rtype, value, explanation in wkd_records:
|
||||
if value is None or value.strip() == "": continue # skip IPV6 if not set
|
||||
if value is None or value.strip() == "":
|
||||
continue # skip IPV6 if not set
|
||||
if not has_rec(qname, rtype):
|
||||
records.append((qname, rtype, value, explanation, None))
|
||||
|
||||
|
@ -332,40 +397,60 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
# Skip if the user has set a custom SPF record.
|
||||
if not has_rec(None, "TXT", prefix="v=spf1 "):
|
||||
if spf_extra is None:
|
||||
records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain, None))
|
||||
records.append((
|
||||
None, "TXT", 'v=spf1 mx -all',
|
||||
"Recommended. Specifies that only the box is permitted to send @%s mail."
|
||||
% domain, None))
|
||||
else:
|
||||
records.append((None, "TXT", f'v=spf1 mx {spf_extra}-all', "Recommended. Specifies that only the box and the server(s) you authorized are permitted to send @%s mail." % domain, None))
|
||||
records.append((
|
||||
None, "TXT", f'v=spf1 mx {spf_extra}-all',
|
||||
"Recommended. Specifies that only the box and the server(s) you authorized are permitted to send @%s mail."
|
||||
% domain, None))
|
||||
|
||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
||||
# Skip if the user has set a DKIM record already.
|
||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||
opendkim_record_file = os.path.join(env['STORAGE_ROOT'],
|
||||
'mail/dkim/mail.txt')
|
||||
with open(opendkim_record_file) as orf:
|
||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S)
|
||||
m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)',
|
||||
orf.read(), re.S)
|
||||
val = "".join(re.findall(r'"([^"]+)"', m.group(2)))
|
||||
rname = f"{settings.get('local_dkim_selector', 'mail')}._domainkey"
|
||||
|
||||
if not has_rec(rname, "TXT", prefix="v=DKIM1; "):
|
||||
records.append((rname, "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain, None))
|
||||
records.append((
|
||||
rname, "TXT", val,
|
||||
"Recommended. Provides a way for recipients to verify that this machine sent @%s mail."
|
||||
% domain, None))
|
||||
|
||||
# Append the DKIM TXT record relative to the SMTP relay, if applicable.
|
||||
# Skip if manually set by the user.
|
||||
relay_ds = settings.get("SMTP_RELAY_DKIM_SELECTOR")
|
||||
rr = settings.get("SMTP_RELAY_DKIM_RR", {})
|
||||
if relay_ds is not None and not has_rec(f"{relay_ds}._domainkey", "TXT", prefix="v=DKIM1; ") and rr.get("p") is not None:
|
||||
if relay_ds is not None and not has_rec(
|
||||
f"{relay_ds}._domainkey", "TXT",
|
||||
prefix="v=DKIM1; ") and rr.get("p") is not None:
|
||||
dkim_rrtxt = ""
|
||||
for c, d in (("v", "DKIM1"), ("h", None), ("k", "rsa"), ("n", None), ("s", None), ("t", None)):
|
||||
for c, d in (("v", "DKIM1"), ("h", None), ("k", "rsa"),
|
||||
("n", None), ("s", None), ("t", None)):
|
||||
txt = rr.get(c, d)
|
||||
if txt is None:
|
||||
continue
|
||||
else:
|
||||
dkim_rrtxt += f"{c}={txt}; "
|
||||
dkim_rrtxt += f"p={rr.get('p')}"
|
||||
records.append((f"{relay_ds}._domainkey", "TXT", dkim_rrtxt, "Recommended. Provides a way for recipients to verify that the SMTP relay you set up sent @%s mail." % domain, None))
|
||||
records.append((
|
||||
f"{relay_ds}._domainkey", "TXT", dkim_rrtxt,
|
||||
"Recommended. Provides a way for recipients to verify that the SMTP relay you set up sent @%s mail."
|
||||
% domain, None))
|
||||
|
||||
# Append a DMARC record.
|
||||
# Skip if the user has set a DMARC record already.
|
||||
if not has_rec("_dmarc", "TXT", prefix="v=DMARC1; "):
|
||||
records.append(("_dmarc", "TXT", 'v=DMARC1; p=quarantine', "Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system." % domain, None))
|
||||
records.append((
|
||||
"_dmarc", "TXT", 'v=DMARC1; p=quarantine',
|
||||
"Recommended. Specifies that mail that does not originate from the box but claims to be from @%s or which does not have a valid DKIM signature is suspect and should be quarantined by the recipient's mail system."
|
||||
% domain, None))
|
||||
|
||||
if domain_properties[domain]["user"]:
|
||||
# Add CardDAV/CalDAV SRV records on the non-primary hostname that points to the primary hostname
|
||||
|
@ -375,7 +460,11 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
for dav in ("card", "cal"):
|
||||
qname = "_" + dav + "davs._tcp"
|
||||
if not has_rec(qname, "SRV"):
|
||||
records.append((qname, "SRV", "0 0 443 " + env["PRIMARY_HOSTNAME"] + ".", "Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain.", None))
|
||||
records.append((
|
||||
qname, "SRV",
|
||||
"0 0 443 " + env["PRIMARY_HOSTNAME"] + ".",
|
||||
"Recommended. Specifies the hostname of the server that handles CardDAV/CalDAV services for email addresses on this domain.",
|
||||
None))
|
||||
|
||||
# If this is a domain name that there are email addresses configured for, i.e. "something@"
|
||||
# this domain name, then the domain name is a MTA-STS (https://tools.ietf.org/html/rfc8461)
|
||||
|
@ -404,15 +493,22 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
# first 20 characters, which is more than sufficient to change whenever the policy file changes
|
||||
# (and ensures any '=' padding at the end of the base64 encoding is dropped).
|
||||
with open("/var/lib/mailinabox/mta-sts.txt", "rb") as f:
|
||||
mta_sts_policy_id = base64.b64encode(hashlib.sha1(f.read()).digest(), altchars=b"AA").decode("ascii")[0:20]
|
||||
mta_sts_records.extend([
|
||||
("_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id, "Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published.")
|
||||
])
|
||||
mta_sts_policy_id = base64.b64encode(
|
||||
hashlib.sha1(f.read()).digest(),
|
||||
altchars=b"AA").decode("ascii")[0:20]
|
||||
mta_sts_records.extend([(
|
||||
"_mta-sts", "TXT", "v=STSv1; id=" + mta_sts_policy_id,
|
||||
"Optional. Part of the MTA-STS policy for incoming mail. If set, a MTA-STS policy must also be published."
|
||||
)])
|
||||
|
||||
# Enable SMTP TLS reporting (https://tools.ietf.org/html/rfc8460) if the user has set a config option.
|
||||
# Skip if the rules below if the user has set a custom _smtp._tls record.
|
||||
if env.get("MTA_STS_TLSRPT_RUA") and not has_rec("_smtp._tls", "TXT", prefix="v=TLSRPTv1;"):
|
||||
mta_sts_records.append(("_smtp._tls", "TXT", "v=TLSRPTv1; rua=" + env["MTA_STS_TLSRPT_RUA"], "Optional. Enables MTA-STS reporting.", None))
|
||||
if env.get("MTA_STS_TLSRPT_RUA") and not has_rec(
|
||||
"_smtp._tls", "TXT", prefix="v=TLSRPTv1;"):
|
||||
mta_sts_records.append(
|
||||
("_smtp._tls", "TXT",
|
||||
"v=TLSRPTv1; rua=" + env["MTA_STS_TLSRPT_RUA"],
|
||||
"Optional. Enables MTA-STS reporting.", None))
|
||||
for qname, rtype, value, explanation in mta_sts_records:
|
||||
if not has_rec(qname, rtype):
|
||||
records.append((qname, rtype, value, explanation, None))
|
||||
|
@ -422,52 +518,88 @@ def build_zone(domain, domain_properties, additional_records, env, is_zone=True)
|
|||
# non-mail domain and also may include qnames from custom DNS records.
|
||||
# Do this once at the end of generating a zone.
|
||||
if is_zone:
|
||||
qnames_with_a = set(qname for (qname, rtype, value, explanation, ttl) in records if rtype in ("A", "AAAA"))
|
||||
qnames_with_mx = set(qname for (qname, rtype, value, explanation, ttl) in records if rtype == "MX")
|
||||
qnames_with_a = set(qname for (qname, rtype, value, explanation,
|
||||
ttl) in records
|
||||
if rtype in ("A", "AAAA"))
|
||||
qnames_with_mx = set(qname for (qname, rtype, value, explanation,
|
||||
ttl) in records if rtype == "MX")
|
||||
for qname in qnames_with_a - qnames_with_mx:
|
||||
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
|
||||
d = (qname + "." if qname else "") + domain
|
||||
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
||||
records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % d, None))
|
||||
if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "):
|
||||
records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d, None))
|
||||
records.append((
|
||||
qname, "TXT", 'v=spf1 -all',
|
||||
"Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)."
|
||||
% d, None))
|
||||
if not has_rec("_dmarc" + ("." + qname if qname else ""),
|
||||
"TXT",
|
||||
prefix="v=DMARC1; "):
|
||||
records.append((
|
||||
"_dmarc" + ("." + qname if qname else ""), "TXT",
|
||||
'v=DMARC1; p=reject',
|
||||
"Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s."
|
||||
% d, None))
|
||||
|
||||
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
|
||||
if not has_rec(qname, "MX"):
|
||||
records.append((qname, "MX", '0 .', "Recommended. Prevents use of this domain name for incoming mail.", None))
|
||||
records.append((
|
||||
qname, "MX", '0 .',
|
||||
"Recommended. Prevents use of this domain name for incoming mail.",
|
||||
None))
|
||||
|
||||
# Add no-mail-here records for any qname that has an A or AAAA record
|
||||
# but no MX record. This would include domain itself if domain is a
|
||||
# non-mail domain and also may include qnames from custom DNS records.
|
||||
# Do this once at the end of generating a zone.
|
||||
if is_zone:
|
||||
qnames_with_a = set(qname for (qname, rtype, value, explanation, ttl) in records if rtype in ("A", "AAAA"))
|
||||
qnames_with_mx = set(qname for (qname, rtype, value, explanation, ttl) in records if rtype == "MX")
|
||||
qnames_with_a = set(qname for (qname, rtype, value, explanation,
|
||||
ttl) in records
|
||||
if rtype in ("A", "AAAA"))
|
||||
qnames_with_mx = set(qname for (qname, rtype, value, explanation,
|
||||
ttl) in records if rtype == "MX")
|
||||
for qname in qnames_with_a - qnames_with_mx:
|
||||
# Mark this domain as not sending mail with hard-fail SPF and DMARC records.
|
||||
d = (qname + "." if qname else "") + domain
|
||||
if not has_rec(qname, "TXT", prefix="v=spf1 "):
|
||||
records.append((qname, "TXT", 'v=spf1 -all', "Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)." % d, None))
|
||||
if not has_rec("_dmarc" + ("."+qname if qname else ""), "TXT", prefix="v=DMARC1; "):
|
||||
records.append(("_dmarc" + ("."+qname if qname else ""), "TXT", 'v=DMARC1; p=reject', "Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s." % d, None))
|
||||
records.append((
|
||||
qname, "TXT", 'v=spf1 -all',
|
||||
"Recommended. Prevents use of this domain name for outbound mail by specifying that no servers are valid sources for mail from @%s. If you do send email from this domain name you should either override this record such that the SPF rule does allow the originating server, or, take the recommended approach and have the box handle mail for this domain (simply add any receiving alias at this domain name to make this machine treat the domain name as one of its mail domains)."
|
||||
% d, None))
|
||||
if not has_rec("_dmarc" + ("." + qname if qname else ""),
|
||||
"TXT",
|
||||
prefix="v=DMARC1; "):
|
||||
records.append((
|
||||
"_dmarc" + ("." + qname if qname else ""), "TXT",
|
||||
'v=DMARC1; p=reject',
|
||||
"Recommended. Prevents use of this domain name for outbound mail by specifying that the SPF rule should be honoured for mail from @%s."
|
||||
% d, None))
|
||||
|
||||
# And with a null MX record (https://explained-from-first-principles.com/email/#null-mx-record)
|
||||
if not has_rec(qname, "MX"):
|
||||
records.append((qname, "MX", '0 .', "Recommended. Prevents use of this domain name for incoming mail.", None))
|
||||
records.append((
|
||||
qname, "MX", '0 .',
|
||||
"Recommended. Prevents use of this domain name for incoming mail.",
|
||||
None))
|
||||
|
||||
# Sort the records. The None records *must* go first in the nsd zone file. Otherwise it doesn't matter.
|
||||
records.sort(key = lambda rec : list(reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||
records.sort(key=lambda rec: list(
|
||||
reversed(rec[0].split(".")) if rec[0] is not None else ""))
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def is_domain_cert_signed_and_valid(domain, env):
|
||||
cert = get_ssl_certificates(env).get(domain)
|
||||
if not cert: return False # no certificate provisioned
|
||||
cert_status = check_certificate(domain, cert['certificate'], cert['private-key'])
|
||||
if not cert:
|
||||
return False # no certificate provisioned
|
||||
cert_status = check_certificate(domain, cert['certificate'],
|
||||
cert['private-key'])
|
||||
return cert_status[0] == 'OK'
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def build_tlsa_record(env):
|
||||
# A DANE TLSA record in DNS specifies that connections on a port
|
||||
# must use TLS and the certificate must match a particular criteria.
|
||||
|
@ -494,7 +626,8 @@ def build_tlsa_record(env):
|
|||
fn = os.path.join(env["STORAGE_ROOT"], "ssl", "ssl_certificate.pem")
|
||||
cert = load_pem(load_cert_chain(fn)[0])
|
||||
|
||||
subject_public_key = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
||||
subject_public_key = cert.public_key().public_bytes(
|
||||
Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
|
||||
# We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...)
|
||||
|
||||
pk_hash = hashlib.sha256(subject_public_key).hexdigest()
|
||||
|
@ -505,6 +638,7 @@ def build_tlsa_record(env):
|
|||
# 1: Use SHA256.
|
||||
return "3 1 1 " + pk_hash
|
||||
|
||||
|
||||
def build_sshfp_records():
|
||||
# The SSHFP record is a way for us to embed this server's SSH public
|
||||
# key fingerprint into the DNS so that remote hosts have an out-of-band
|
||||
|
@ -542,11 +676,15 @@ def build_sshfp_records():
|
|||
pass
|
||||
break
|
||||
|
||||
keys = shell("check_output", ["ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p", str(port), "localhost"])
|
||||
keys = shell("check_output", [
|
||||
"ssh-keyscan", "-t", "rsa,dsa,ecdsa,ed25519", "-p",
|
||||
str(port), "localhost"
|
||||
])
|
||||
keys = sorted(keys.split("\n"))
|
||||
|
||||
for key in keys:
|
||||
if key.strip() == "" or key[0] == "#": continue
|
||||
if key.strip() == "" or key[0] == "#":
|
||||
continue
|
||||
try:
|
||||
host, keytype, pubkey = key.split(" ")
|
||||
yield "%d %d ( %s )" % (
|
||||
|
@ -559,8 +697,10 @@ def build_sshfp_records():
|
|||
# zone.
|
||||
pass
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def write_nsd_zone(domain, zonefile, records, env, force):
|
||||
# On the $ORIGIN line, there's typically a ';' comment at the end explaining
|
||||
# what the $ORIGIN line does. Any further data after the domain confuses
|
||||
|
@ -590,7 +730,9 @@ $TTL {ttl} ; default time to live
|
|||
"""
|
||||
|
||||
# Replace replacement strings.
|
||||
zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"], ttl=DEFAULT_TTL)
|
||||
zone = zone.format(domain=domain,
|
||||
primary_domain=env["PRIMARY_HOSTNAME"],
|
||||
ttl=DEFAULT_TTL)
|
||||
|
||||
# Add records.
|
||||
for subdomain, querytype, value, explanation, ttl in records:
|
||||
|
@ -613,7 +755,8 @@ $TTL {ttl} ; default time to live
|
|||
zone += value + "\n"
|
||||
|
||||
# Append a stable hash of DNSSEC signing keys in a comment.
|
||||
zone += "\n; DNSSEC signing keys hash: {}\n".format(hash_dnssec_keys(domain, env))
|
||||
zone += "\n; DNSSEC signing keys hash: {}\n".format(
|
||||
hash_dnssec_keys(domain, env))
|
||||
|
||||
# DNSSEC requires re-signing a zone periodically. That requires
|
||||
# bumping the serial number even if no other records have changed.
|
||||
|
@ -631,15 +774,18 @@ $TTL {ttl} ; default time to live
|
|||
# number so we can re-sign it.
|
||||
with open(zonefile + ".signed") as f:
|
||||
signed_zone = f.read()
|
||||
expiration_times = re.findall(r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone)
|
||||
expiration_times = re.findall(
|
||||
r"\sRRSIG\s+SOA\s+\d+\s+\d+\s\d+\s+(\d{14})", signed_zone)
|
||||
if len(expiration_times) == 0:
|
||||
# weird
|
||||
force_bump = True
|
||||
else:
|
||||
# All of the times should be the same, but if not choose the soonest.
|
||||
expiration_time = min(expiration_times)
|
||||
expiration_time = datetime.datetime.strptime(expiration_time, "%Y%m%d%H%M%S")
|
||||
if expiration_time - datetime.datetime.now() < datetime.timedelta(days=3):
|
||||
expiration_time = datetime.datetime.strptime(
|
||||
expiration_time, "%Y%m%d%H%M%S")
|
||||
if expiration_time - datetime.datetime.now() < datetime.timedelta(
|
||||
days=3):
|
||||
# We're within three days of the expiration, so bump serial & resign.
|
||||
force_bump = True
|
||||
|
||||
|
@ -655,7 +801,8 @@ $TTL {ttl} ; default time to live
|
|||
# Clear out the serial number in the existing zone file for the
|
||||
# purposes of seeing if anything *else* in the zone has changed.
|
||||
existing_serial = m.group(1)
|
||||
existing_zone = existing_zone.replace(m.group(0), "__SERIAL__ ; serial number")
|
||||
existing_zone = existing_zone.replace(
|
||||
m.group(0), "__SERIAL__ ; serial number")
|
||||
|
||||
# If the existing zone is the same as the new zone (modulo the serial number),
|
||||
# there is no need to update the file. Unless we're forcing a bump.
|
||||
|
@ -677,19 +824,23 @@ $TTL {ttl} ; default time to live
|
|||
|
||||
return True # file is updated
|
||||
|
||||
|
||||
def get_dns_zonefile(zone, env):
|
||||
for domain, fn in get_dns_zones(env):
|
||||
if zone == domain:
|
||||
break
|
||||
else:
|
||||
raise ValueError("%s is not a domain name that corresponds to a zone." % zone)
|
||||
raise ValueError(
|
||||
"%s is not a domain name that corresponds to a zone." % zone)
|
||||
|
||||
nsd_zonefile = "/etc/nsd/zones/" + fn
|
||||
with open(nsd_zonefile, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def write_nsd_conf(zonefiles, additional_records, env):
|
||||
# Write the list of zones to a configuration file.
|
||||
nsd_conf_file = "/etc/nsd/nsd.conf.d/zones.conf"
|
||||
|
@ -723,8 +874,10 @@ zone:
|
|||
f.write(nsdconf)
|
||||
return True
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def find_dnssec_signing_keys(domain, env):
|
||||
# For key that we generated (one per algorithm)...
|
||||
d = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec')
|
||||
|
@ -741,18 +894,22 @@ def find_dnssec_signing_keys(domain, env):
|
|||
# deleting the key's .conf file, which might result in the key
|
||||
# being regenerated next upgrade. Keys should be disabled if
|
||||
# they are not needed to reduce the DNSSEC query response size.
|
||||
if "DOMAINS" in keyinfo and domain not in [dd.strip() for dd in keyinfo["DOMAINS"].split(",")]:
|
||||
if "DOMAINS" in keyinfo and domain not in [
|
||||
dd.strip() for dd in keyinfo["DOMAINS"].split(",")
|
||||
]:
|
||||
continue
|
||||
|
||||
for keytype in ("KSK", "ZSK"):
|
||||
yield keytype, keyinfo[keytype]
|
||||
|
||||
|
||||
def hash_dnssec_keys(domain, env):
|
||||
# Create a stable (by sorting the items) hash of all of the private keys
|
||||
# that will be used to sign this domain.
|
||||
keydata = []
|
||||
for keytype, keyfn in sorted(find_dnssec_signing_keys(domain, env)):
|
||||
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ".private")
|
||||
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec',
|
||||
keyfn + ".private")
|
||||
keydata.append(keytype)
|
||||
keydata.append(keyfn)
|
||||
with open(oldkeyfn, "r") as fr:
|
||||
|
@ -760,6 +917,7 @@ def hash_dnssec_keys(domain, env):
|
|||
keydata = "".join(keydata).encode("utf8")
|
||||
return hashlib.sha1(keydata).hexdigest()
|
||||
|
||||
|
||||
def sign_zone(domain, zonefile, env):
|
||||
# Sign the zone with all of the keys that were generated during
|
||||
# setup so that the user can choose which to use in their DS record at
|
||||
|
@ -782,26 +940,35 @@ def sign_zone(domain, zonefile, env):
|
|||
#
|
||||
# Use os.umask and open().write() to securely create a copy that only
|
||||
# we (root) can read.
|
||||
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec', keyfn + ext)
|
||||
oldkeyfn = os.path.join(env['STORAGE_ROOT'], 'dns/dnssec',
|
||||
keyfn + ext)
|
||||
with open(oldkeyfn, "r") as fr:
|
||||
keydata = fr.read()
|
||||
keydata = keydata.replace("_domain_", domain)
|
||||
prev_umask = os.umask(0o77) # ensure written file is not world-readable
|
||||
# ensure written file is not world-readable
|
||||
prev_umask = os.umask(0o77)
|
||||
try:
|
||||
with open(newkeyfn + ext, "w") as fw:
|
||||
fw.write(keydata)
|
||||
finally:
|
||||
os.umask(prev_umask) # other files we write should be world-readable
|
||||
# other files we write should be world-readable
|
||||
os.umask(prev_umask)
|
||||
|
||||
# Put the patched key filename base (without extension) into the list of keys we'll sign with.
|
||||
all_keys.append(newkeyfn)
|
||||
if keytype == "KSK": ksk_keys.append(newkeyfn)
|
||||
if keytype == "KSK":
|
||||
ksk_keys.append(newkeyfn)
|
||||
|
||||
# Do the signing.
|
||||
expiry_date = (datetime.datetime.now() + datetime.timedelta(days=30)).strftime("%Y%m%d")
|
||||
shell('check_call', ["/usr/bin/ldns-signzone",
|
||||
expiry_date = (datetime.datetime.now() +
|
||||
datetime.timedelta(days=30)).strftime("%Y%m%d")
|
||||
shell(
|
||||
'check_call',
|
||||
[
|
||||
"/usr/bin/ldns-signzone",
|
||||
# expire the zone after 30 days
|
||||
"-e", expiry_date,
|
||||
"-e",
|
||||
expiry_date,
|
||||
|
||||
# use NSEC3
|
||||
"-n",
|
||||
|
@ -810,8 +977,7 @@ def sign_zone(domain, zonefile, env):
|
|||
"/etc/nsd/zones/" + zonefile,
|
||||
]
|
||||
# keys to sign with (order doesn't matter -- it'll figure it out)
|
||||
+ all_keys
|
||||
)
|
||||
+ all_keys)
|
||||
|
||||
# Create a DS record based on the patched-up key files. The DS record is specific to the
|
||||
# zone being signed, so we can't use the .ds files generated when we created the keys.
|
||||
|
@ -825,7 +991,10 @@ def sign_zone(domain, zonefile, env):
|
|||
with open("/etc/nsd/zones/" + zonefile + ".ds", "w") as f:
|
||||
for key in ksk_keys:
|
||||
for digest_type in ('1', '2', '4'):
|
||||
rr_ds = shell('check_output', ["/usr/bin/ldns-key2ds",
|
||||
rr_ds = shell(
|
||||
'check_output',
|
||||
[
|
||||
"/usr/bin/ldns-key2ds",
|
||||
"-n", # output to stdout
|
||||
"-" + digest_type, # 1=SHA1, 2=SHA256, 4=SHA384
|
||||
key + ".key"
|
||||
|
@ -837,13 +1006,16 @@ def sign_zone(domain, zonefile, env):
|
|||
os.unlink(fn + ".private")
|
||||
os.unlink(fn + ".key")
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def write_opendkim_tables(domains, env):
|
||||
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain
|
||||
# that we send mail from (zones and all subdomains).
|
||||
|
||||
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
|
||||
opendkim_key_file = os.path.join(env['STORAGE_ROOT'],
|
||||
'mail/dkim/mail.private')
|
||||
config = load_settings(env)
|
||||
|
||||
if not os.path.exists(opendkim_key_file):
|
||||
|
@ -859,22 +1031,18 @@ def write_opendkim_tables(domains, env):
|
|||
# to be From: the domain must be signed with a DKIM key on the same domain.
|
||||
# So we must have a separate KeyTable entry for each domain.
|
||||
"SigningTable":
|
||||
"".join(
|
||||
"*@{domain} {domain}\n".format(domain=domain)
|
||||
for domain in domains
|
||||
),
|
||||
"".join("*@{domain} {domain}\n".format(domain=domain)
|
||||
for domain in domains),
|
||||
|
||||
# The KeyTable specifies the signing domain, the DKIM selector, and the
|
||||
# path to the private key to use for signing some mail. Per DMARC, the
|
||||
# signing domain must match the sender's From: domain.
|
||||
"KeyTable":
|
||||
"".join(
|
||||
"{domain} {domain}:{selector}:{key_file}\n".format(
|
||||
"".join("{domain} {domain}:{selector}:{key_file}\n".format(
|
||||
domain=domain,
|
||||
key_file=opendkim_key_file,
|
||||
selector = config.get("local_dkim_selector", "mail")
|
||||
) for domain in domains
|
||||
),
|
||||
selector=config.get("local_dkim_selector", "mail"))
|
||||
for domain in domains),
|
||||
}
|
||||
|
||||
did_update = False
|
||||
|
@ -894,17 +1062,22 @@ def write_opendkim_tables(domains, env):
|
|||
# no need to kick the opendkim process.
|
||||
return did_update
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def get_custom_dns_config(env, only_real_records=False):
|
||||
try:
|
||||
custom_dns = rtyaml.load(open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
||||
if not isinstance(custom_dns, dict): raise ValueError() # caught below
|
||||
custom_dns = rtyaml.load(
|
||||
open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml')))
|
||||
if not isinstance(custom_dns, dict):
|
||||
raise ValueError() # caught below
|
||||
except:
|
||||
return []
|
||||
|
||||
for qname, entry in custom_dns.items():
|
||||
if qname == "_secondary_nameserver" and only_real_records: continue # skip fake record
|
||||
if qname == "_secondary_nameserver" and only_real_records:
|
||||
continue # skip fake record
|
||||
|
||||
# Short form. Mapping a domain name to a string is short-hand
|
||||
# for creating A records.
|
||||
|
@ -937,15 +1110,19 @@ def get_custom_dns_config(env, only_real_records=False):
|
|||
# No other type of data is allowed.
|
||||
raise ValueError()
|
||||
|
||||
|
||||
def filter_custom_records(domain, custom_dns_iter):
|
||||
for qname, rtype, value, ttl in custom_dns_iter:
|
||||
# We don't count the secondary nameserver config (if present) as a record - that would just be
|
||||
# confusing to users. Instead it is accessed/manipulated directly via (get/set)_custom_dns_config.
|
||||
if qname == "_secondary_nameserver": continue
|
||||
if qname == "_secondary_nameserver":
|
||||
continue
|
||||
|
||||
# Is this record for the domain or one of its subdomains?
|
||||
# If `domain` is None, return records for all domains.
|
||||
if domain is not None and qname != domain and not qname.endswith("." + domain): continue
|
||||
if domain is not None and qname != domain and not qname.endswith(
|
||||
"." + domain):
|
||||
continue
|
||||
|
||||
# Turn the fully qualified domain name in the YAML file into
|
||||
# our short form (None => domain, or a relative QNAME) if
|
||||
|
@ -958,6 +1135,7 @@ def filter_custom_records(domain, custom_dns_iter):
|
|||
|
||||
yield (qname, rtype, value, ttl)
|
||||
|
||||
|
||||
def write_custom_dns_config(config, env):
|
||||
# We get a list of (qname, rtype, value, ttl) triples. Convert this into a
|
||||
# nice dictionary format for storage on disk.
|
||||
|
@ -968,11 +1146,14 @@ def write_custom_dns_config(config, env):
|
|||
|
||||
# Process the qnames in the order we see them.
|
||||
for qname in [rec[0] for rec in config]:
|
||||
if qname in seen_qnames: continue
|
||||
if qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(qname)
|
||||
|
||||
records = [(rec[1], rec[2], rec[3]) for rec in config if rec[0] == qname]
|
||||
if len(records) == 1 and records[0][0] == "A" and records[0][2] is None:
|
||||
records = [(rec[1], rec[2], rec[3]) for rec in config
|
||||
if rec[0] == qname]
|
||||
if len(
|
||||
records) == 1 and records[0][0] == "A" and records[0][2] is None:
|
||||
dns[qname] = records[0][1]
|
||||
else:
|
||||
dns[qname] = OrderedDict()
|
||||
|
@ -980,10 +1161,14 @@ def write_custom_dns_config(config, env):
|
|||
|
||||
# Process the rtypes in the order we see them.
|
||||
for rtype in [rec[0] for rec in records]:
|
||||
if rtype in seen_rtypes: continue
|
||||
if rtype in seen_rtypes:
|
||||
continue
|
||||
seen_rtypes.add(rtype)
|
||||
|
||||
values = [(rec[1] if rec[2] is None else {"value": rec[1], "ttl": min(max(TTL_MIN, rec[2]), TTL_MAX)}) for rec in records if rec[0] == rtype]
|
||||
values = [(rec[1] if rec[2] is None else {
|
||||
"value": rec[1],
|
||||
"ttl": min(max(TTL_MIN, rec[2]), TTL_MAX)
|
||||
}) for rec in records if rec[0] == rtype]
|
||||
if len(values) == 1:
|
||||
values = values[0]
|
||||
dns[qname][rtype] = values
|
||||
|
@ -993,6 +1178,7 @@ def write_custom_dns_config(config, env):
|
|||
with open(os.path.join(env['STORAGE_ROOT'], 'dns/custom.yaml'), "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
|
||||
def set_custom_dns_record(qname, rtype, value, action, env, ttl=None):
|
||||
# validate qname
|
||||
for zone, fn in get_dns_zones(env):
|
||||
|
@ -1003,7 +1189,9 @@ def set_custom_dns_record(qname, rtype, value, action, env, ttl=None):
|
|||
else:
|
||||
# No match.
|
||||
if qname != "_secondary_nameserver":
|
||||
raise ValueError("%s is not a domain name or a subdomain of a domain name managed by this box." % qname)
|
||||
raise ValueError(
|
||||
"%s is not a domain name or a subdomain of a domain name managed by this box."
|
||||
% qname)
|
||||
|
||||
# validate rtype
|
||||
rtype = rtype.upper()
|
||||
|
@ -1013,9 +1201,13 @@ def set_custom_dns_record(qname, rtype, value, action, env, ttl=None):
|
|||
|
||||
if rtype in ("A", "AAAA"):
|
||||
if value != "local": # "local" is a special flag for us
|
||||
v = ipaddress.ip_address(value) # raises a ValueError if there's a problem
|
||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address): raise ValueError("That's an IPv6 address.")
|
||||
if rtype == "AAAA" and not isinstance(v, ipaddress.IPv6Address): raise ValueError("That's an IPv4 address.")
|
||||
# raises a ValueError if there's a problem
|
||||
v = ipaddress.ip_address(value)
|
||||
if rtype == "A" and not isinstance(v, ipaddress.IPv4Address):
|
||||
raise ValueError("That's an IPv6 address.")
|
||||
if rtype == "AAAA" and not isinstance(v,
|
||||
ipaddress.IPv6Address):
|
||||
raise ValueError("That's an IPv4 address.")
|
||||
elif rtype in ("CNAME", "NS"):
|
||||
if rtype == "NS" and qname == zone:
|
||||
raise ValueError("NS records can only be set for subdomains.")
|
||||
|
@ -1078,15 +1270,18 @@ def set_custom_dns_record(qname, rtype, value, action, env, ttl=None):
|
|||
write_custom_dns_config(newconfig, env)
|
||||
return made_change
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def get_secondary_dns(custom_dns, mode=None):
|
||||
resolver = dns.resolver.get_default_resolver()
|
||||
resolver.timeout = 10
|
||||
|
||||
values = []
|
||||
for qname, rtype, value, ttl in custom_dns:
|
||||
if qname != '_secondary_nameserver': continue
|
||||
if qname != '_secondary_nameserver':
|
||||
continue
|
||||
for hostname in value.split(" "):
|
||||
hostname = hostname.strip()
|
||||
if mode == None:
|
||||
|
@ -1100,9 +1295,13 @@ def get_secondary_dns(custom_dns, mode=None):
|
|||
# doesn't.
|
||||
if not hostname.startswith("xfr:"):
|
||||
if mode == "xfr":
|
||||
response = dns.resolver.resolve(hostname+'.', "A", raise_on_no_answer=False)
|
||||
response = dns.resolver.resolve(hostname + '.',
|
||||
"A",
|
||||
raise_on_no_answer=False)
|
||||
values.extend(map(str, response))
|
||||
response = dns.resolver.resolve(hostname+'.', "AAAA", raise_on_no_answer=False)
|
||||
response = dns.resolver.resolve(hostname + '.',
|
||||
"AAAA",
|
||||
raise_on_no_answer=False)
|
||||
values.extend(map(str, response))
|
||||
continue
|
||||
values.append(hostname)
|
||||
|
@ -1116,6 +1315,7 @@ def get_secondary_dns(custom_dns, mode=None):
|
|||
|
||||
return values
|
||||
|
||||
|
||||
def set_secondary_dns(hostnames, env):
|
||||
if len(hostnames) > 0:
|
||||
# Validate that all hostnames are valid and that all zone-xfer IP addresses are valid.
|
||||
|
@ -1126,23 +1326,31 @@ def set_secondary_dns(hostnames, env):
|
|||
# Resolve hostname.
|
||||
try:
|
||||
response = resolver.resolve(item, "A")
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer):
|
||||
try:
|
||||
response = resolver.query(item, "AAAA")
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
raise ValueError("Could not resolve the IP address of %s." % item)
|
||||
except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN,
|
||||
dns.resolver.NoAnswer):
|
||||
raise ValueError(
|
||||
"Could not resolve the IP address of %s." % item)
|
||||
else:
|
||||
# Validate IP address.
|
||||
try:
|
||||
if "/" in item[4:]:
|
||||
v = ipaddress.ip_network(item[4:]) # raises a ValueError if there's a problem
|
||||
# raises a ValueError if there's a problem
|
||||
v = ipaddress.ip_network(item[4:])
|
||||
else:
|
||||
v = ipaddress.ip_address(item[4:]) # raises a ValueError if there's a problem
|
||||
# raises a ValueError if there's a problem
|
||||
v = ipaddress.ip_address(item[4:])
|
||||
except ValueError:
|
||||
raise ValueError("'%s' is not an IPv4 or IPv6 address or subnet." % item[4:])
|
||||
raise ValueError(
|
||||
"'%s' is not an IPv4 or IPv6 address or subnet." %
|
||||
item[4:])
|
||||
|
||||
# Set.
|
||||
set_custom_dns_record("_secondary_nameserver", "A", " ".join(hostnames), "set", env)
|
||||
set_custom_dns_record("_secondary_nameserver", "A",
|
||||
" ".join(hostnames), "set", env)
|
||||
else:
|
||||
# Clear.
|
||||
set_custom_dns_record("_secondary_nameserver", "A", None, "set", env)
|
||||
|
@ -1157,8 +1365,10 @@ def get_custom_dns_records(custom_dns, qname, rtype):
|
|||
yield value
|
||||
return None
|
||||
|
||||
|
||||
########################################################################
|
||||
|
||||
|
||||
def build_recommended_dns(env):
|
||||
ret = []
|
||||
for (domain, zonefile, records) in build_zones(env):
|
||||
|
@ -1166,7 +1376,8 @@ def build_recommended_dns(env):
|
|||
records = [r for r in records if r[3] is not False]
|
||||
|
||||
# put Required at the top, then Recommended, then everythiing else
|
||||
records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2))
|
||||
records.sort(key=lambda r: 0 if r[3].startswith("Required.") else
|
||||
(1 if r[3].startswith("Recommended.") else 2))
|
||||
|
||||
# expand qnames
|
||||
for i in range(len(records)):
|
||||
|
@ -1186,6 +1397,7 @@ def build_recommended_dns(env):
|
|||
ret.append((domain, records))
|
||||
return ret
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from utils import load_environment
|
||||
env = load_environment()
|
||||
|
@ -1195,5 +1407,8 @@ if __name__ == "__main__":
|
|||
for zone, records in build_recommended_dns(env):
|
||||
for record in records:
|
||||
print("; " + record['explanation'])
|
||||
print(record['qname'], record['rtype'], record['value'], sep="\t")
|
||||
print(record['qname'],
|
||||
record['rtype'],
|
||||
record['value'],
|
||||
sep="\t")
|
||||
print()
|
||||
|
|
|
@ -24,9 +24,17 @@
|
|||
|
||||
# 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()
|
||||
buf = ""
|
||||
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.
|
||||
name, val = settings[i].split("=", 1)
|
||||
m = re.match(
|
||||
"(\s*)"
|
||||
+ "(" + re.escape(comment_char) + "\s*)?"
|
||||
+ re.escape(name) + delimiter_re + "(.*?)\s*$",
|
||||
line, re.S)
|
||||
if not m: continue
|
||||
"(\s*)" + "(" + re.escape(comment_char) + "\s*)?" +
|
||||
re.escape(name) + delimiter_re + "(.*?)\s*$", line, re.S)
|
||||
if not m:
|
||||
continue
|
||||
indent, is_comment, existing_val = m.groups()
|
||||
|
||||
# If this is already the setting, do nothing.
|
||||
if is_comment is None and existing_val == val:
|
||||
# It may be that we've already inserted this setting higher
|
||||
# in the file so check for that first.
|
||||
if i in found: break
|
||||
if i in found:
|
||||
break
|
||||
buf += line
|
||||
found.add(i)
|
||||
break
|
||||
|
||||
# comment-out the existing line (also comment any folded lines)
|
||||
if is_comment is None:
|
||||
buf += comment_char + line.rstrip().replace("\n", "\n" + comment_char) + "\n"
|
||||
buf += comment_char + line.rstrip().replace(
|
||||
"\n", "\n" + comment_char) + "\n"
|
||||
else:
|
||||
# the line is already commented, pass it through
|
||||
buf += line
|
||||
|
@ -97,11 +106,14 @@ def edit_conf(filename, settings, delimiter_re, delimiter, comment_char, folded_
|
|||
# Just print the new file to stdout.
|
||||
print(buf)
|
||||
|
||||
|
||||
# Run standalone
|
||||
if __name__ == "__main__":
|
||||
# sanity check
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]")
|
||||
print(
|
||||
"usage: python3 editconf.py /etc/file.conf [-s] [-w] [-c <CHARACTER>] [-t] NAME=VAL [NAME=VAL ...]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# parse command line arguments
|
||||
|
@ -140,4 +152,5 @@ if __name__ == "__main__":
|
|||
print("Invalid command line: ", subprocess.list2cmdline(sys.argv))
|
||||
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)
|
||||
|
|
|
@ -39,11 +39,13 @@ msg = MIMEMultipart('alternative')
|
|||
noreply = "noreply-daemon@" + env['PRIMARY_HOSTNAME']
|
||||
admin_addr = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
|
||||
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon", "noreply-daemon@" + env['PRIMARY_HOSTNAME'])
|
||||
msg['From'] = "\"%s\" <%s>" % ("System Management Daemon",
|
||||
"noreply-daemon@" + env['PRIMARY_HOSTNAME'])
|
||||
msg['To'] = "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
msg['Subject'] = "[%s] %s" % (env['PRIMARY_HOSTNAME'], subject)
|
||||
|
||||
content_html = "<html><body><pre>{}</pre></body></html>".format(html.escape(content))
|
||||
content_html = "<html><body><pre>{}</pre></body></html>".format(
|
||||
html.escape(content))
|
||||
|
||||
msg.attach(MIMEText(create_signature(content.encode()).decode(), 'plain'))
|
||||
msg.attach(MIMEText(content_html, 'html'))
|
||||
|
|
|
@ -16,7 +16,6 @@ from dateutil.relativedelta import relativedelta
|
|||
|
||||
import utils
|
||||
|
||||
|
||||
LOG_FILES = (
|
||||
'/var/log/mail.log.6.gz',
|
||||
'/var/log/mail.log.5.gz',
|
||||
|
@ -32,8 +31,7 @@ TIME_DELTAS = OrderedDict([
|
|||
('month', datetime.timedelta(weeks=4)),
|
||||
('2weeks', datetime.timedelta(days=14)),
|
||||
('week', datetime.timedelta(days=7)),
|
||||
('2days', datetime.timedelta(days=2)),
|
||||
('day', datetime.timedelta(days=1)),
|
||||
('2days', datetime.timedelta(days=2)), ('day', datetime.timedelta(days=1)),
|
||||
('12hours', datetime.timedelta(hours=12)),
|
||||
('6hours', datetime.timedelta(hours=6)),
|
||||
('hour', datetime.timedelta(hours=1)),
|
||||
|
@ -41,7 +39,8 @@ TIME_DELTAS = OrderedDict([
|
|||
('10min', datetime.timedelta(minutes=10)),
|
||||
('5min', datetime.timedelta(minutes=5)),
|
||||
('min', datetime.timedelta(minutes=1)),
|
||||
('today', datetime.datetime.now() - datetime.datetime.now().replace(hour=0, minute=0, second=0))
|
||||
('today', datetime.datetime.now() -
|
||||
datetime.datetime.now().replace(hour=0, minute=0, second=0))
|
||||
])
|
||||
|
||||
END_DATE = NOW = datetime.datetime.now()
|
||||
|
@ -88,7 +87,6 @@ def scan_files(collector):
|
|||
stop_scan = False
|
||||
|
||||
|
||||
|
||||
def scan_mail_log(env):
|
||||
""" Scan the system's mail log files and collect interesting data
|
||||
|
||||
|
@ -101,7 +99,8 @@ def scan_mail_log(env):
|
|||
|
||||
collector = {
|
||||
"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
|
||||
"sent_mail": OrderedDict(), # Data about email sent by users
|
||||
"received_mail": OrderedDict(), # Data about email received by users
|
||||
|
@ -114,14 +113,14 @@ def scan_mail_log(env):
|
|||
|
||||
try:
|
||||
import mailconfig
|
||||
collector["known_addresses"] = (set(mailconfig.get_mail_users(env)) |
|
||||
set(alias[0] for alias in mailconfig.get_mail_aliases(env)))
|
||||
collector["known_addresses"] = (
|
||||
set(mailconfig.get_mail_users(env))
|
||||
| set(alias[0] for alias in mailconfig.get_mail_aliases(env)))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".format(
|
||||
START_DATE, END_DATE)
|
||||
)
|
||||
print("Scanning logs from {:%Y-%m-%d %H:%M:%S} to {:%Y-%m-%d %H:%M:%S}".
|
||||
format(START_DATE, END_DATE))
|
||||
|
||||
# Scan the lines in the log files until the date goes out of range
|
||||
scan_files(collector)
|
||||
|
@ -132,7 +131,8 @@ def scan_mail_log(env):
|
|||
|
||||
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))
|
||||
|
||||
# Print Sent Mail report
|
||||
|
@ -141,7 +141,8 @@ def scan_mail_log(env):
|
|||
msg = "Sent email"
|
||||
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(
|
||||
data.keys(),
|
||||
|
@ -165,10 +166,7 @@ def scan_mail_log(env):
|
|||
for h in range(24):
|
||||
accum[h] = sum(d["activity-by-hour"][h] for d in data)
|
||||
|
||||
print_time_table(
|
||||
["sent"],
|
||||
[accum]
|
||||
)
|
||||
print_time_table(["sent"], [accum])
|
||||
|
||||
# Print Received Mail report
|
||||
|
||||
|
@ -176,7 +174,8 @@ def scan_mail_log(env):
|
|||
msg = "Received email"
|
||||
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(
|
||||
data.keys(),
|
||||
|
@ -194,10 +193,7 @@ def scan_mail_log(env):
|
|||
for h in range(24):
|
||||
accum[h] = sum(d["activity-by-hour"][h] for d in data.values())
|
||||
|
||||
print_time_table(
|
||||
["received"],
|
||||
[accum]
|
||||
)
|
||||
print_time_table(["received"], [accum])
|
||||
|
||||
# Print login report
|
||||
|
||||
|
@ -212,27 +208,34 @@ def scan_mail_log(env):
|
|||
for u in data.values():
|
||||
for protocol_name, count in u["totals_by_protocol"].items():
|
||||
all_protocols[protocol_name] += count
|
||||
all_protocols = [k for k, v in sorted(all_protocols.items(), key=lambda kv : -kv[1])]
|
||||
all_protocols = [
|
||||
k for k, v in sorted(all_protocols.items(), key=lambda kv: -kv[1])
|
||||
]
|
||||
|
||||
print_user_table(
|
||||
data.keys(),
|
||||
data=[
|
||||
(protocol_name, [
|
||||
round(u["totals_by_protocol"][protocol_name] / (u["latest"]-u["earliest"]).total_seconds() * 60*60, 1)
|
||||
if (u["latest"]-u["earliest"]).total_seconds() > 0
|
||||
else 0 # prevent division by zero
|
||||
for u in data.values()])
|
||||
for protocol_name in all_protocols
|
||||
(
|
||||
protocol_name,
|
||||
[
|
||||
round(
|
||||
u["totals_by_protocol"][protocol_name] /
|
||||
(u["latest"] - u["earliest"]).total_seconds() *
|
||||
60 * 60, 1) if
|
||||
(u["latest"] - u["earliest"]).total_seconds() > 0 else
|
||||
0 # prevent division by zero
|
||||
for u in data.values()
|
||||
]) for protocol_name in all_protocols
|
||||
],
|
||||
sub_data=[
|
||||
("Protocol and Source", [[
|
||||
sub_data=[("Protocol and Source", [[
|
||||
"{} {}: {} times".format(protocol_name, host, count)
|
||||
for (protocol_name, host), count
|
||||
in sorted(u["totals_by_protocol_and_host"].items(), key=lambda kv:-kv[1])
|
||||
] for u in data.values()])
|
||||
],
|
||||
for (protocol_name, host), count in sorted(
|
||||
u["totals_by_protocol_and_host"].items(),
|
||||
key=lambda kv: -kv[1])
|
||||
] for u in data.values()])],
|
||||
activity=[
|
||||
(protocol_name, [u["activity-by-hour"][protocol_name] for u in data.values()])
|
||||
(protocol_name,
|
||||
[u["activity-by-hour"][protocol_name] for u in data.values()])
|
||||
for protocol_name in all_protocols
|
||||
],
|
||||
earliest=[u["earliest"] for u in data.values()],
|
||||
|
@ -240,15 +243,19 @@ def scan_mail_log(env):
|
|||
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 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(
|
||||
all_protocols,
|
||||
[accum[protocol_name] for protocol_name in all_protocols]
|
||||
)
|
||||
[accum[protocol_name] for protocol_name in all_protocols])
|
||||
|
||||
if collector["postgrey"]:
|
||||
msg = "Greylisted Email {:%Y-%m-%d %H:%M:%S} and {:%Y-%m-%d %H:%M:%S}"
|
||||
|
@ -257,10 +264,13 @@ def scan_mail_log(env):
|
|||
print(textwrap.fill(
|
||||
"The following mail was greylisted, meaning the emails were temporarily rejected. "
|
||||
"Legitimate senders must try again after three minutes.",
|
||||
width=80, initial_indent=" ", subsequent_indent=" "
|
||||
), end='\n\n')
|
||||
width=80,
|
||||
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 = []
|
||||
received = []
|
||||
senders = []
|
||||
|
@ -268,8 +278,10 @@ def scan_mail_log(env):
|
|||
delivered_dates = []
|
||||
|
||||
for recipient in data:
|
||||
sorted_recipients = sorted(data[recipient].items(), key=lambda kv: kv[1][0] or kv[1][1])
|
||||
for (client_address, sender), (first_date, delivered_date) in sorted_recipients:
|
||||
sorted_recipients = sorted(data[recipient].items(),
|
||||
key=lambda kv: kv[1][0] or kv[1][1])
|
||||
for (client_address,
|
||||
sender), (first_date, delivered_date) in sorted_recipients:
|
||||
if first_date:
|
||||
users.append(recipient)
|
||||
received.append(first_date)
|
||||
|
@ -279,12 +291,10 @@ def scan_mail_log(env):
|
|||
|
||||
print_user_table(
|
||||
users,
|
||||
data=[
|
||||
("received", received),
|
||||
("sender", senders),
|
||||
("delivered", [str(d) or "no retry yet" for d in delivered_dates]),
|
||||
("sending host", sender_clients)
|
||||
],
|
||||
data=[("received", received), ("sender", senders),
|
||||
("delivered",
|
||||
[str(d) or "no retry yet" for d in delivered_dates]),
|
||||
("sending host", sender_clients)],
|
||||
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}"
|
||||
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 = []
|
||||
|
||||
|
@ -327,7 +338,8 @@ def scan_mail_log(env):
|
|||
def scan_mail_log_line(line, collector):
|
||||
""" Scan a log line and extract interesting data """
|
||||
|
||||
m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)", line)
|
||||
m = re.match(r"(\w+[\s]+\d+ \d+:\d+:\d+) ([\w]+ )?([\w\-/]+)[^:]*: (.*)",
|
||||
line)
|
||||
|
||||
if not m:
|
||||
return True
|
||||
|
@ -346,7 +358,8 @@ 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.
|
||||
# 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 date > NOW:
|
||||
date = date.replace(year=NOW.year - 1)
|
||||
|
@ -375,8 +388,9 @@ def scan_mail_log_line(line, collector):
|
|||
elif service == "postfix/smtpd":
|
||||
if SCAN_BLOCKED:
|
||||
scan_postfix_smtpd_line(date, log, collector)
|
||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache",
|
||||
"spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp",
|
||||
elif service in ("postfix/qmgr", "postfix/pickup", "postfix/cleanup",
|
||||
"postfix/scache", "spampd", "postfix/anvil",
|
||||
"postfix/master", "opendkim", "postfix/lmtp",
|
||||
"postfix/tlsmgr", "anvil"):
|
||||
# nothing to look at
|
||||
return True
|
||||
|
@ -391,9 +405,9 @@ def scan_mail_log_line(line, collector):
|
|||
def scan_postgrey_line(date, log, collector):
|
||||
""" Scan a postgrey log line and extract interesting data """
|
||||
|
||||
m = re.match("action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
|
||||
"client_address=(.*), sender=(.*), recipient=(.*)",
|
||||
log)
|
||||
m = re.match(
|
||||
"action=(greylist|pass), reason=(.*?), (?:delay=\d+, )?client_name=(.*), "
|
||||
"client_address=(.*), sender=(.*), recipient=(.*)", log)
|
||||
|
||||
if m:
|
||||
|
||||
|
@ -409,7 +423,8 @@ def scan_postgrey_line(date, log, collector):
|
|||
# if len(addr) > 2:
|
||||
# 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, {})
|
||||
|
||||
|
@ -424,7 +439,8 @@ def scan_postfix_smtpd_line(date, log, collector):
|
|||
|
||||
# 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:
|
||||
message, sender, user = m.groups()
|
||||
|
@ -435,26 +451,24 @@ def scan_postfix_smtpd_line(date, log, collector):
|
|||
|
||||
# only log mail to known recipients
|
||||
if user_match(user):
|
||||
if collector["known_addresses"] is None or user in collector["known_addresses"]:
|
||||
data = collector["rejected"].get(
|
||||
user,
|
||||
{
|
||||
if collector["known_addresses"] is None or user in collector[
|
||||
"known_addresses"]:
|
||||
data = collector["rejected"].get(user, {
|
||||
"blocked": [],
|
||||
"earliest": None,
|
||||
"latest": None,
|
||||
}
|
||||
)
|
||||
})
|
||||
# simplify this one
|
||||
m = re.search(
|
||||
r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)", message
|
||||
)
|
||||
r"Client host \[(.*?)\] blocked using zen.spamhaus.org; (.*)",
|
||||
message)
|
||||
if m:
|
||||
message = "ip blocked: " + m.group(2)
|
||||
else:
|
||||
# simplify this one too
|
||||
m = re.search(
|
||||
r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)", message
|
||||
)
|
||||
r"Sender address \[.*@(.*)\] blocked using dbl.spamhaus.org; (.*)",
|
||||
message)
|
||||
if m:
|
||||
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):
|
||||
# Get the user data, or create it if the user is new
|
||||
data = collector["logins"].get(
|
||||
user,
|
||||
{
|
||||
user, {
|
||||
"earliest": None,
|
||||
"latest": None,
|
||||
"totals_by_protocol": defaultdict(int),
|
||||
"totals_by_protocol_and_host": defaultdict(int),
|
||||
"activity-by-hour": defaultdict(lambda: defaultdict(int)),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if data["earliest"] is None:
|
||||
data["earliest"] = date
|
||||
|
@ -521,14 +533,12 @@ def scan_postfix_lmtp_line(date, log, collector):
|
|||
if user_match(user):
|
||||
# Get the user data, or create it if the user is new
|
||||
data = collector["received_mail"].get(
|
||||
user,
|
||||
{
|
||||
user, {
|
||||
"received_count": 0,
|
||||
"earliest": None,
|
||||
"latest": None,
|
||||
"activity-by-hour": defaultdict(int),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
data["received_count"] += 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
|
||||
# allowed by Dovecot. Exclude trailing comma after the username when additional fields
|
||||
# follow after.
|
||||
m = re.match("([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)", log)
|
||||
m = re.match(
|
||||
"([A-Z0-9]+): client=(\S+), sasl_method=(PLAIN|LOGIN), sasl_username=(\S+)(?<!,)",
|
||||
log)
|
||||
|
||||
if m:
|
||||
_, client, method, user = m.groups()
|
||||
|
@ -559,15 +571,13 @@ def scan_postfix_submission_line(date, log, collector):
|
|||
if user_match(user):
|
||||
# Get the user data, or create it if the user is new
|
||||
data = collector["sent_mail"].get(
|
||||
user,
|
||||
{
|
||||
user, {
|
||||
"sent_count": 0,
|
||||
"hosts": set(),
|
||||
"earliest": None,
|
||||
"latest": None,
|
||||
"activity-by-hour": defaultdict(int),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
data["sent_count"] += 1
|
||||
data["hosts"].add(client)
|
||||
|
@ -582,8 +592,10 @@ def scan_postfix_submission_line(date, log, collector):
|
|||
# Also log this as a login.
|
||||
add_login(user, date, "smtp", client, collector)
|
||||
|
||||
|
||||
# Utility functions
|
||||
|
||||
|
||||
def readline(filename):
|
||||
""" A generator that returns the lines of a file
|
||||
"""
|
||||
|
@ -610,12 +622,14 @@ def valid_date(string):
|
|||
try:
|
||||
date = dateutil.parser.parse(string)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" % string)
|
||||
raise argparse.ArgumentTypeError("Unrecognized date and/or time '%s'" %
|
||||
string)
|
||||
return date
|
||||
|
||||
|
||||
# Print functions
|
||||
|
||||
|
||||
def print_time_table(labels, data, do_print=True):
|
||||
labels.insert(0, "hour")
|
||||
data.insert(0, [str(h) for h in range(24)])
|
||||
|
@ -642,8 +656,14 @@ def print_time_table(labels, data, do_print=True):
|
|||
return lines
|
||||
|
||||
|
||||
def print_user_table(users, data=None, sub_data=None, activity=None, latest=None, earliest=None,
|
||||
delimit=False, numstr=str):
|
||||
def print_user_table(users,
|
||||
data=None,
|
||||
sub_data=None,
|
||||
activity=None,
|
||||
latest=None,
|
||||
earliest=None,
|
||||
delimit=False,
|
||||
numstr=str):
|
||||
str_temp = "{:<32} "
|
||||
lines = []
|
||||
data = data or []
|
||||
|
@ -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):
|
||||
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
|
||||
elif isinstance(d[row], datetime.datetime):
|
||||
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) * "─")
|
||||
|
||||
if activity is not None:
|
||||
lines.extend(print_time_table(
|
||||
[label for label, _ in activity],
|
||||
lines.extend(
|
||||
print_time_table([label for label, _ in activity],
|
||||
[data[row] for _, data in activity],
|
||||
do_print=False
|
||||
))
|
||||
do_print=False))
|
||||
|
||||
except KeyError:
|
||||
pass
|
||||
|
@ -750,7 +770,8 @@ def print_user_table(users, data=None, sub_data=None, activity=None, latest=None
|
|||
|
||||
if vert_pos:
|
||||
t_line = t_line[:vert_pos + 1] + "┼" + t_line[vert_pos + 2:]
|
||||
b_line = b_line[:vert_pos + 1] + ("┬" if VERBOSE else "┼") + b_line[vert_pos + 2:]
|
||||
b_line = b_line[:vert_pos +
|
||||
1] + ("┬" if VERBOSE else "┼") + b_line[vert_pos + 2:]
|
||||
|
||||
lines.insert(1, t_line)
|
||||
lines.append(b_line)
|
||||
|
@ -798,43 +819,72 @@ if __name__ == "__main__":
|
|||
env_vars = {}
|
||||
|
||||
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 ("
|
||||
"re)written for the Mail-in-a-box email server."
|
||||
"https://github.com/mail-in-a-box/mailinabox",
|
||||
add_help=False
|
||||
)
|
||||
add_help=False)
|
||||
|
||||
# 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")
|
||||
parser.add_argument("-s", "--sent", help="Scan for sent emails.",
|
||||
parser.add_argument("-s",
|
||||
"--sent",
|
||||
help="Scan for sent emails.",
|
||||
action="store_true")
|
||||
parser.add_argument("-l", "--logins", help="Scan for user logins to IMAP/POP3.",
|
||||
parser.add_argument("-l",
|
||||
"--logins",
|
||||
help="Scan for user logins to IMAP/POP3.",
|
||||
action="store_true")
|
||||
parser.add_argument("-g", "--grey", help="Scan for greylisted emails.",
|
||||
parser.add_argument("-g",
|
||||
"--grey",
|
||||
help="Scan for greylisted emails.",
|
||||
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")
|
||||
|
||||
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>',
|
||||
help="Time span to scan, going back from the end date. Possible values: "
|
||||
"{}. Defaults to 'today'.".format(", ".join(list(TIME_DELTAS.keys()))))
|
||||
# keep the --startdate arg for backward compatibility
|
||||
parser.add_argument("-d", "--enddate", "--startdate", action="store", dest="enddate",
|
||||
type=valid_date, metavar='<end date>',
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--enddate",
|
||||
"--startdate",
|
||||
action="store",
|
||||
dest="enddate",
|
||||
type=valid_date,
|
||||
metavar='<end date>',
|
||||
help="Date and time to end scanning the log file. If no date is "
|
||||
"provided, scanning will end at the current date and time. "
|
||||
"Alias --startdate is for compatibility.")
|
||||
parser.add_argument("-u", "--users", action="store", dest="users",
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--users",
|
||||
action="store",
|
||||
dest="users",
|
||||
metavar='<email1,email2,email...>',
|
||||
help="Comma separated list of (partial) email addresses to filter the "
|
||||
"output with.")
|
||||
|
||||
parser.add_argument('-h', '--help', action='help', help="Print this message and exit.")
|
||||
parser.add_argument("-v", "--verbose", help="Output extra data where available.",
|
||||
parser.add_argument('-h',
|
||||
'--help',
|
||||
action='help',
|
||||
help="Print this message and exit.")
|
||||
parser.add_argument("-v",
|
||||
"--verbose",
|
||||
help="Output extra data where available.",
|
||||
action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
|
|
@ -9,11 +9,16 @@
|
|||
# Python 3 in setup/questions.sh to validate the email
|
||||
# address entered by the user.
|
||||
|
||||
import subprocess, shutil, os, sqlite3, re
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
import sqlite3
|
||||
import re
|
||||
import utils
|
||||
from email_validator import validate_email as validate_email_, EmailNotValidError
|
||||
import idna
|
||||
|
||||
|
||||
def validate_email(email, mode=None):
|
||||
# Checks that an email address is syntactically valid. Returns True/False.
|
||||
# An email address may contain ASCII characters only because Dovecot's
|
||||
|
@ -31,8 +36,7 @@ def validate_email(email, mode=None):
|
|||
validate_email_(email,
|
||||
allow_smtputf8=False,
|
||||
check_deliverability=False,
|
||||
allow_empty_local=(mode=="alias")
|
||||
)
|
||||
allow_empty_local=(mode == "alias"))
|
||||
except EmailNotValidError:
|
||||
return False
|
||||
|
||||
|
@ -45,13 +49,15 @@ def validate_email(email, mode=None):
|
|||
# Our database is case sensitive (oops), which affects mail delivery
|
||||
# (Postfix always queries in lowercase?), so also only permit lowercase
|
||||
# letters.
|
||||
if len(email) > 255: return False
|
||||
if len(email) > 255:
|
||||
return False
|
||||
if re.search(r'[^\@\.a-z0-9_\-]+', email):
|
||||
return False
|
||||
|
||||
# Everything looks good.
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_idn_email_address(email):
|
||||
# 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 ---
|
||||
|
@ -72,6 +78,7 @@ def sanitize_idn_email_address(email):
|
|||
# validate_email.
|
||||
return email
|
||||
|
||||
|
||||
def prettify_idn_email_address(email):
|
||||
# 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.
|
||||
|
@ -84,13 +91,17 @@ def prettify_idn_email_address(email):
|
|||
# single @-sign. Should never happen.
|
||||
return email
|
||||
|
||||
|
||||
def is_dcv_address(email):
|
||||
email = email.lower()
|
||||
for localpart in ("admin", "administrator", "postmaster", "hostmaster", "webmaster", "abuse"):
|
||||
if email.startswith(localpart+"@") or email.startswith(localpart+"+"):
|
||||
for localpart in ("admin", "administrator", "postmaster", "hostmaster",
|
||||
"webmaster", "abuse"):
|
||||
if email.startswith(localpart + "@") or email.startswith(localpart +
|
||||
"+"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def open_database(env, with_connection=False):
|
||||
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
||||
if not with_connection:
|
||||
|
@ -98,6 +109,7 @@ def open_database(env, with_connection=False):
|
|||
else:
|
||||
return conn, conn.cursor()
|
||||
|
||||
|
||||
def get_mail_users(env):
|
||||
# Returns a flat, sorted list of all user accounts.
|
||||
c = open_database(env)
|
||||
|
@ -105,6 +117,7 @@ def get_mail_users(env):
|
|||
users = [row[0] for row in c.fetchall()]
|
||||
return utils.sort_email_addresses(users, env)
|
||||
|
||||
|
||||
def sizeof_fmt(num):
|
||||
for unit in ['', 'K', 'M', 'G', 'T']:
|
||||
if abs(num) < 1024.0:
|
||||
|
@ -117,6 +130,7 @@ def sizeof_fmt(num):
|
|||
|
||||
return str(num)
|
||||
|
||||
|
||||
def get_mail_users_ex(env, with_archived=False):
|
||||
# Returns a complex data structure of all user accounts, optionally
|
||||
# including archived (status="inactive") accounts.
|
||||
|
@ -150,7 +164,9 @@ def get_mail_users_ex(env, with_archived=False):
|
|||
box_quota = 0
|
||||
percent = ''
|
||||
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:
|
||||
box_quota = int(f.readline().split('S')[0])
|
||||
for line in f.readlines():
|
||||
|
@ -178,7 +194,8 @@ def get_mail_users_ex(env, with_archived=False):
|
|||
"quota": quota,
|
||||
"box_quota": box_quota,
|
||||
"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,
|
||||
"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)):
|
||||
email = user + "@" + domain
|
||||
mbox = os.path.join(root, domain, user)
|
||||
if email in active_accounts: continue
|
||||
if email in active_accounts:
|
||||
continue
|
||||
user = {
|
||||
"email": email,
|
||||
"privileges": [],
|
||||
|
@ -210,21 +228,22 @@ def get_mail_users_ex(env, with_archived=False):
|
|||
for user in users:
|
||||
domain = get_domain(user["email"])
|
||||
if domain not in domains:
|
||||
domains[domain] = {
|
||||
"domain": domain,
|
||||
"users": []
|
||||
}
|
||||
domains[domain] = {"domain": domain, "users": []}
|
||||
domains[domain]["users"].append(user)
|
||||
|
||||
# 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.
|
||||
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
|
||||
|
||||
|
||||
def get_admins(env):
|
||||
# Returns a set of users with admin privileges.
|
||||
users = set()
|
||||
|
@ -234,16 +253,23 @@ def get_admins(env):
|
|||
users.add(user["email"])
|
||||
return users
|
||||
|
||||
|
||||
def get_mail_aliases(env):
|
||||
# Returns a sorted list of tuples of (address, forward-tos, permitted-senders, auto).
|
||||
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(
|
||||
'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
|
||||
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
|
||||
|
||||
|
||||
def get_mail_aliases_ex(env):
|
||||
# Returns a complex data structure of all mail aliases, similar
|
||||
# to get_mail_users_ex.
|
||||
|
@ -268,7 +294,8 @@ def get_mail_aliases_ex(env):
|
|||
domains = {}
|
||||
for address, forwards_to, permitted_senders, auto in get_mail_aliases(env):
|
||||
# skip auto domain maps since these are not informative in the control panel's aliases list
|
||||
if auto and address.startswith("@"): continue
|
||||
if auto and address.startswith("@"):
|
||||
continue
|
||||
|
||||
# get alias info
|
||||
domain = get_domain(address)
|
||||
|
@ -280,21 +307,34 @@ def get_mail_aliases_ex(env):
|
|||
"aliases": [],
|
||||
}
|
||||
domains[domain]["aliases"].append({
|
||||
"address": address,
|
||||
"address_display": prettify_idn_email_address(address),
|
||||
"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),
|
||||
"address":
|
||||
address,
|
||||
"address_display":
|
||||
prettify_idn_email_address(address),
|
||||
"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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
def get_noreply_addresses(env):
|
||||
# Returns a set of noreply addresses:
|
||||
# Noreply addresses are a special type of addresses that are send-only.
|
||||
|
@ -303,6 +343,7 @@ def get_noreply_addresses(env):
|
|||
c.execute('SELECT email FROM noreply')
|
||||
return set(row[0] for row in c.fetchall())
|
||||
|
||||
|
||||
def get_domain(emailaddr, as_unicode=True):
|
||||
# Gets the domain part of an email address. Turns IDNA
|
||||
# back to Unicode for display.
|
||||
|
@ -316,6 +357,7 @@ def get_domain(emailaddr, as_unicode=True):
|
|||
pass
|
||||
return ret
|
||||
|
||||
|
||||
def get_all_mail_addresses(env, no_catchalls=True):
|
||||
# Gets the email addresses on literally all tables (users, aliases and noreplies)
|
||||
emails = get_mail_users(env)
|
||||
|
@ -328,18 +370,28 @@ def get_all_mail_addresses(env, no_catchalls=True):
|
|||
|
||||
return set(emails)
|
||||
|
||||
|
||||
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
|
||||
# configured on the system. If users_only is True, only return domains
|
||||
# with email addresses that correspond to user accounts. Exclude Unicode
|
||||
# forms of domain names listed in the automatic aliases table.
|
||||
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:
|
||||
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([get_domain(address, as_unicode=False) for address in get_noreply_addresses(env)])
|
||||
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([
|
||||
get_domain(address, as_unicode=False)
|
||||
for address in get_noreply_addresses(env)
|
||||
])
|
||||
return set(domains)
|
||||
|
||||
|
||||
def add_mail_user(email, pw, privs, quota, env):
|
||||
# validate email
|
||||
if email.strip() == "":
|
||||
|
@ -347,12 +399,16 @@ def add_mail_user(email, pw, privs, quota, env):
|
|||
elif not validate_email(email):
|
||||
return ("Invalid email address.", 400)
|
||||
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:
|
||||
# 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
|
||||
# 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(pw)
|
||||
|
@ -364,7 +420,8 @@ def add_mail_user(email, pw, privs, quota, env):
|
|||
privs = privs.split("\n")
|
||||
for p in privs:
|
||||
validation = validate_privilege(p)
|
||||
if validation: return validation
|
||||
if validation:
|
||||
return validation
|
||||
|
||||
if quota is None:
|
||||
quota = get_default_quota()
|
||||
|
@ -382,7 +439,8 @@ def add_mail_user(email, pw, privs, quota, env):
|
|||
|
||||
# add the user to the database
|
||||
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))
|
||||
except sqlite3.IntegrityError:
|
||||
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.
|
||||
return kick(env, "mail user added")
|
||||
|
||||
|
||||
def set_mail_password(email, pw, env):
|
||||
# validate that password is acceptable
|
||||
validate_password(pw)
|
||||
|
@ -410,11 +469,14 @@ def set_mail_password(email, pw, env):
|
|||
conn.commit()
|
||||
return "OK"
|
||||
|
||||
|
||||
def hash_password(pw):
|
||||
# Turn the plain password into a Dovecot-format hashed password, meaning
|
||||
# something like "{SCHEME}hashedpassworddata".
|
||||
# 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):
|
||||
|
@ -442,6 +504,7 @@ def set_mail_quota(email, quota, env):
|
|||
|
||||
return "OK"
|
||||
|
||||
|
||||
def dovecot_quota_recalc(email):
|
||||
# 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
|
||||
|
@ -452,10 +515,12 @@ def dovecot_quota_recalc(email):
|
|||
# force dovecot to recalculate the quota info for the user.
|
||||
subprocess.call(["doveadm", "quota", "recalc", "-u", email])
|
||||
|
||||
|
||||
def get_default_quota(env):
|
||||
config = utils.load_settings(env)
|
||||
return config.get("default-quota", '0')
|
||||
|
||||
|
||||
def validate_quota(quota):
|
||||
# validate quota
|
||||
quota = quota.strip().upper()
|
||||
|
@ -463,12 +528,14 @@ def validate_quota(quota):
|
|||
if quota == "":
|
||||
raise ValueError("No quota provided.")
|
||||
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):
|
||||
raise ValueError("Invalid quota.")
|
||||
|
||||
return quota
|
||||
|
||||
|
||||
def get_mail_password(email, env):
|
||||
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
||||
# password format, with a prefixed scheme.
|
||||
|
@ -481,6 +548,7 @@ def get_mail_password(email, env):
|
|||
raise ValueError("That's not a user (%s)." % email)
|
||||
return rows[0][0]
|
||||
|
||||
|
||||
def remove_mail_user(email, env):
|
||||
# remove
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
|
@ -492,32 +560,39 @@ def remove_mail_user(email, env):
|
|||
# Update things in case any domains are removed.
|
||||
return kick(env, "mail user removed")
|
||||
|
||||
|
||||
def parse_privs(value):
|
||||
return [p for p in value.split("\n") if p.strip() != ""]
|
||||
|
||||
|
||||
def get_mail_user_privileges(email, env, empty_on_error=False):
|
||||
# get privs
|
||||
c = open_database(env)
|
||||
c.execute('SELECT privileges FROM users WHERE email=?', (email, ))
|
||||
rows = c.fetchall()
|
||||
if len(rows) != 1:
|
||||
if empty_on_error: return []
|
||||
if empty_on_error:
|
||||
return []
|
||||
return ("That's not a user (%s)." % email, 400)
|
||||
return parse_privs(rows[0][0])
|
||||
|
||||
|
||||
def validate_privilege(priv):
|
||||
if "\n" in priv or priv.strip() == "":
|
||||
return ("That's not a valid privilege (%s)." % priv, 400)
|
||||
return None
|
||||
|
||||
|
||||
def add_remove_mail_user_privilege(email, priv, action, env):
|
||||
# validate
|
||||
validation = validate_privilege(priv)
|
||||
if validation: return validation
|
||||
if validation:
|
||||
return validation
|
||||
|
||||
# get existing privs, but may fail
|
||||
privs = get_mail_user_privileges(email, env)
|
||||
if isinstance(privs, tuple): return privs # error
|
||||
if isinstance(privs, tuple):
|
||||
return privs # error
|
||||
|
||||
# update privs set
|
||||
if action == "add":
|
||||
|
@ -530,14 +605,21 @@ def add_remove_mail_user_privilege(email, priv, action, env):
|
|||
|
||||
# commit to database
|
||||
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:
|
||||
return ("Something went wrong.", 400)
|
||||
conn.commit()
|
||||
|
||||
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
|
||||
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 email in line.split(","):
|
||||
email = email.strip()
|
||||
if email == "": continue
|
||||
if email == "":
|
||||
continue
|
||||
email = sanitize_idn_email_address(email) # Unicode => IDNA
|
||||
# Strip any +tag from email alias and check privileges
|
||||
privileged_email = re.sub(r"(?=\+)[^@]*(?=@)", '', email)
|
||||
if not validate_email(email):
|
||||
return ("Invalid receiver email address (%s)." % email, 400)
|
||||
if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(privileged_email, env, empty_on_error=True):
|
||||
return ("Invalid receiver email address (%s)." % email,
|
||||
400)
|
||||
if is_dcv_source and not is_dcv_address(
|
||||
email) and "admin" not in get_mail_user_privileges(
|
||||
privileged_email, env, empty_on_error=True):
|
||||
# Make domain control validation hijacking a little harder to mess up by
|
||||
# requiring aliases for email addresses typically used in DCV to forward
|
||||
# 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)
|
||||
|
||||
# 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 login in line.split(","):
|
||||
login = login.strip()
|
||||
if login == "": continue
|
||||
if login == "":
|
||||
continue
|
||||
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)
|
||||
|
||||
# Make sure the alias has either a forwards_to or a permitted_sender.
|
||||
if len(validated_forwards_to) + len(validated_permitted_senders) == 0:
|
||||
return ("The alias must either forward to an address or have a permitted sender.", 400)
|
||||
return (
|
||||
"The alias must either forward to an address or have a permitted sender.",
|
||||
400)
|
||||
|
||||
# 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)
|
||||
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"
|
||||
except sqlite3.IntegrityError:
|
||||
if not update_if_exists:
|
||||
return ("Alias already exists (%s)." % address, 400)
|
||||
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"
|
||||
|
||||
conn.commit()
|
||||
|
@ -632,6 +729,7 @@ def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exist
|
|||
# Update things in case any new domains are added.
|
||||
return kick(env, return_status)
|
||||
|
||||
|
||||
def remove_mail_alias(address, env, do_kick=True):
|
||||
# convert Unicode domain to IDNA
|
||||
address = sanitize_idn_email_address(address)
|
||||
|
@ -647,16 +745,21 @@ def remove_mail_alias(address, env, do_kick=True):
|
|||
# Update things in case any domains are removed.
|
||||
return kick(env, "alias removed")
|
||||
|
||||
|
||||
def add_auto_aliases(aliases, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute("DELETE FROM auto_aliases");
|
||||
c.execute("DELETE FROM auto_aliases")
|
||||
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()
|
||||
|
||||
|
||||
def get_system_administrator(env):
|
||||
return "administrator@" + env['PRIMARY_HOSTNAME']
|
||||
|
||||
|
||||
def get_required_aliases(env):
|
||||
# These are the aliases that must exist.
|
||||
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
|
||||
# email on that domain are the required aliases or a catch-all/domain-forwarder.
|
||||
real_mail_domains = get_mail_domains(env,
|
||||
filter_aliases = lambda alias :
|
||||
not alias.startswith("postmaster@")
|
||||
and not alias.startswith("admin@")
|
||||
and not alias.startswith("abuse@")
|
||||
and not alias.startswith("@")
|
||||
)
|
||||
real_mail_domains = get_mail_domains(
|
||||
env,
|
||||
filter_aliases=lambda alias: not alias.startswith("postmaster@") and
|
||||
not alias.startswith("admin@") and not alias.startswith(
|
||||
"abuse@") and not alias.startswith("@"))
|
||||
|
||||
# Create postmaster@, admin@ and abuse@ for all domains we serve
|
||||
# mail on. postmaster@ is assumed to exist by our Postfix configuration.
|
||||
|
@ -689,6 +790,7 @@ def get_required_aliases(env):
|
|||
|
||||
return aliases
|
||||
|
||||
|
||||
def add_noreply_address(env, address, do_kick=True):
|
||||
email = sanitize_idn_email_address(address)
|
||||
# validate email
|
||||
|
@ -719,6 +821,7 @@ def add_noreply_address(env, address, do_kick=True):
|
|||
conn.commit()
|
||||
return ret
|
||||
|
||||
|
||||
def remove_noreply_address(env, address, do_kick=True):
|
||||
email = sanitize_idn_email_address(address)
|
||||
|
||||
|
@ -733,9 +836,11 @@ def remove_noreply_address(env, address, do_kick=True):
|
|||
# Update things in case any domains are removed.
|
||||
return kick(env, "No-reply address removed")
|
||||
|
||||
|
||||
def get_required_noreply_addresses(env):
|
||||
return {"noreply-daemon@" + env['PRIMARY_HOSTNAME']}
|
||||
|
||||
|
||||
def kick(env, mail_result=None):
|
||||
results = []
|
||||
|
||||
|
@ -752,14 +857,16 @@ def kick(env, mail_result=None):
|
|||
required_noreply = get_required_noreply_addresses(env)
|
||||
existing_noreply = get_noreply_addresses(env)
|
||||
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
|
||||
|
||||
# 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):
|
||||
try:
|
||||
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
|
||||
except (ValueError, UnicodeError, idna.IDNAError):
|
||||
continue
|
||||
|
@ -778,7 +885,9 @@ def kick(env, mail_result=None):
|
|||
and forwards_to == get_system_administrator(env) \
|
||||
and not auto:
|
||||
remove_mail_alias(address, env, do_kick=False)
|
||||
results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to))
|
||||
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.
|
||||
|
||||
|
@ -790,6 +899,7 @@ def kick(env, mail_result=None):
|
|||
|
||||
return "".join(s for s in results if s != "")
|
||||
|
||||
|
||||
def validate_password(pw):
|
||||
# validate password
|
||||
if pw.strip() == "":
|
||||
|
@ -797,6 +907,7 @@ def validate_password(pw):
|
|||
if len(pw) < 8:
|
||||
raise ValueError("Passwords must be at least eight characters.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 2 and sys.argv[1] == "validate-email":
|
||||
|
|
|
@ -7,33 +7,46 @@ import qrcode
|
|||
|
||||
from mailconfig import open_database
|
||||
|
||||
|
||||
def get_user_id(email, c):
|
||||
c.execute('SELECT id FROM users WHERE email=?', (email, ))
|
||||
r = c.fetchone()
|
||||
if not r: raise ValueError("User does not exist.")
|
||||
if not r:
|
||||
raise ValueError("User does not exist.")
|
||||
return r[0]
|
||||
|
||||
|
||||
def get_mfa_state(email, env):
|
||||
c = open_database(env)
|
||||
c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),))
|
||||
return [
|
||||
{ "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] }
|
||||
for r in c.fetchall()
|
||||
]
|
||||
c.execute(
|
||||
'SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?',
|
||||
(get_user_id(email, c), ))
|
||||
return [{
|
||||
"id": r[0],
|
||||
"type": r[1],
|
||||
"secret": r[2],
|
||||
"mru_token": r[3],
|
||||
"label": r[4]
|
||||
} for r in c.fetchall()]
|
||||
|
||||
|
||||
def get_public_mfa_state(email, env):
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
return [
|
||||
{ "id": s["id"], "type": s["type"], "label": s["label"] }
|
||||
for s in mfa_state
|
||||
]
|
||||
return [{
|
||||
"id": s["id"],
|
||||
"type": s["type"],
|
||||
"label": s["label"]
|
||||
} for s in mfa_state]
|
||||
|
||||
|
||||
def get_hash_mfa_state(email, env):
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
return [
|
||||
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
|
||||
for s in mfa_state
|
||||
]
|
||||
return [{
|
||||
"id": s["id"],
|
||||
"type": s["type"],
|
||||
"secret": s["secret"]
|
||||
} for s in mfa_state]
|
||||
|
||||
|
||||
def enable_mfa(email, type, secret, token, label, env):
|
||||
if type == "totp":
|
||||
|
@ -46,14 +59,19 @@ def enable_mfa(email, type, secret, token, label, env):
|
|||
raise ValueError("Invalid MFA type.")
|
||||
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label))
|
||||
c.execute(
|
||||
'INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)',
|
||||
(get_user_id(email, c), type, secret, label))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def set_mru_token(email, mfa_id, token, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
|
||||
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?',
|
||||
(token, get_user_id(email, c), mfa_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def disable_mfa(email, mfa_id, env):
|
||||
conn, c = open_database(env, with_connection=True)
|
||||
if mfa_id is None:
|
||||
|
@ -61,16 +79,19 @@ def disable_mfa(email, mfa_id, env):
|
|||
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c), ))
|
||||
else:
|
||||
# Disable a particular MFA mode for a user.
|
||||
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
|
||||
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?',
|
||||
(get_user_id(email, c), mfa_id))
|
||||
conn.commit()
|
||||
return c.rowcount > 0
|
||||
|
||||
|
||||
def validate_totp_secret(secret):
|
||||
if type(secret) != str or secret.strip() == "":
|
||||
raise ValueError("No secret provided.")
|
||||
if len(secret) != 32:
|
||||
raise ValueError("Secret should be a 32 characters base32 string")
|
||||
|
||||
|
||||
def provision_totp(email, env):
|
||||
# Make a new secret.
|
||||
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||
|
@ -79,8 +100,7 @@ def provision_totp(email, env):
|
|||
# Make a URI that we encode within a QR code.
|
||||
uri = pyotp.TOTP(secret).provisioning_uri(
|
||||
name=email,
|
||||
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
|
||||
)
|
||||
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel")
|
||||
|
||||
# Generate a QR code as a base64-encode PNG image.
|
||||
qr = qrcode.make(uri)
|
||||
|
@ -88,11 +108,8 @@ def provision_totp(email, env):
|
|||
qr.save(byte_arr, format='PNG')
|
||||
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||
|
||||
return {
|
||||
"type": "totp",
|
||||
"secret": secret,
|
||||
"qr_code_base64": png_b64
|
||||
}
|
||||
return {"type": "totp", "secret": secret, "qr_code_base64": png_b64}
|
||||
|
||||
|
||||
def validate_auth_mfa(email, request, env):
|
||||
# Validates that a login request satisfies any MFA modes
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
# 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()
|
||||
|
||||
|
@ -11,6 +15,8 @@ daemon_key_fpr = env['PGPKEY']
|
|||
default_context = gpg.Context(armor=True, home_dir=gpghome)
|
||||
|
||||
# Auxiliary function to process the key in order to be read more conveniently
|
||||
|
||||
|
||||
def key_representation(key):
|
||||
if key is None:
|
||||
return None
|
||||
|
@ -24,25 +30,42 @@ def key_representation(key):
|
|||
|
||||
now = datetime.datetime.utcnow()
|
||||
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"] = [{
|
||||
"master": skey.fpr == key.fpr,
|
||||
"sign": skey.can_sign == 1,
|
||||
"cert": skey.can_certify == 1,
|
||||
"encr": skey.can_encrypt == 1,
|
||||
"auth": skey.can_authenticate == 1,
|
||||
"fpr": skey.fpr,
|
||||
"expires": skey.expires if skey.expires != 0 else None,
|
||||
"expires_date": datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat() if skey.expires != 0 else None,
|
||||
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) - now).days if skey.expires != 0 else None,
|
||||
"expired": skey.expired == 1,
|
||||
"algorithm": gpg.core.pubkey_algo_name(skey.pubkey_algo),
|
||||
"bits": skey.length
|
||||
"master":
|
||||
skey.fpr == key.fpr,
|
||||
"sign":
|
||||
skey.can_sign == 1,
|
||||
"cert":
|
||||
skey.can_certify == 1,
|
||||
"encr":
|
||||
skey.can_encrypt == 1,
|
||||
"auth":
|
||||
skey.can_authenticate == 1,
|
||||
"fpr":
|
||||
skey.fpr,
|
||||
"expires":
|
||||
skey.expires if skey.expires != 0 else None,
|
||||
"expires_date":
|
||||
datetime.datetime.utcfromtimestamp(skey.expires).date().isoformat()
|
||||
if skey.expires != 0 else None,
|
||||
"expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) -
|
||||
now).days if skey.expires != 0 else None,
|
||||
"expired":
|
||||
skey.expired == 1,
|
||||
"algorithm":
|
||||
gpg.core.pubkey_algo_name(skey.pubkey_algo),
|
||||
"bits":
|
||||
skey.length
|
||||
} for skey in key.subkeys]
|
||||
|
||||
return key_rep
|
||||
|
||||
|
||||
# Tests an import as for whether we have any sort of private key material in our import
|
||||
|
||||
|
||||
def contains_private_keys(imports):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with gpg.Context(home_dir=tmpdir, armor=True) as tmp:
|
||||
|
@ -52,9 +75,13 @@ def contains_private_keys(imports):
|
|||
except AttributeError:
|
||||
raise ValueError("Import is not a valid PGP key block!")
|
||||
|
||||
|
||||
# Decorator: Copies the homedir of a context onto a temporary directory and returns a context operating over that tmpdir
|
||||
|
||||
|
||||
def fork_context(f, context=default_context):
|
||||
from os.path import isdir, isfile
|
||||
|
||||
def dirs_files_only(current_dir, files):
|
||||
ignore = []
|
||||
for f in files:
|
||||
|
@ -65,8 +92,11 @@ def fork_context(f, context = default_context):
|
|||
|
||||
def wrapped(*args, **kwargs):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
shutil.copytree(context.home_dir, f"{tmpdir}/gnupg", ignore=dirs_files_only)
|
||||
kwargs["context"] = gpg.Context(armor=context.armor, home_dir=f"{tmpdir}/gnupg")
|
||||
shutil.copytree(context.home_dir,
|
||||
f"{tmpdir}/gnupg",
|
||||
ignore=dirs_files_only)
|
||||
kwargs["context"] = gpg.Context(armor=context.armor,
|
||||
home_dir=f"{tmpdir}/gnupg")
|
||||
kwargs["buffer"] = gpg.Data()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
|
@ -81,19 +111,19 @@ def get_key(fingerprint, context = default_context):
|
|||
except gpg.errors.GPGMEError:
|
||||
return None
|
||||
|
||||
|
||||
def get_daemon_key(context=default_context):
|
||||
if daemon_key_fpr is None or daemon_key_fpr == "":
|
||||
return None
|
||||
return context.get_key(daemon_key_fpr, secret=True)
|
||||
|
||||
|
||||
def get_imported_keys(context=default_context):
|
||||
# All the keys in the keyring, except for the daemon's key
|
||||
return list(
|
||||
filter(
|
||||
lambda k: k.fpr != daemon_key_fpr,
|
||||
context.keylist(secret=False)
|
||||
)
|
||||
)
|
||||
filter(lambda k: k.fpr != daemon_key_fpr,
|
||||
context.keylist(secret=False)))
|
||||
|
||||
|
||||
def import_key(key, context=default_context):
|
||||
data = str.encode(key)
|
||||
|
@ -101,10 +131,13 @@ def import_key(key, context = default_context):
|
|||
raise ValueError("Import cannot contain private keys!")
|
||||
return context.key_import(data)
|
||||
|
||||
|
||||
def export_key(fingerprint, context=default_context):
|
||||
if get_key(fingerprint) is None:
|
||||
return None
|
||||
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):
|
||||
key = get_key(fingerprint)
|
||||
|
@ -112,18 +145,26 @@ def delete_key(fingerprint, context = default_context):
|
|||
raise ValueError("You cannot delete the daemon's key!")
|
||||
elif key is None:
|
||||
return None
|
||||
context.op_delete_ext(key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
|
||||
context.op_delete_ext(
|
||||
key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE)
|
||||
return True
|
||||
|
||||
|
||||
# Key usage
|
||||
|
||||
# Uses the daemon key to sign the provided message. If 'detached' is True, only the signature will be returned
|
||||
|
||||
|
||||
def create_signature(data, detached=False, context=default_context):
|
||||
signed_data, _ = context.sign(data, mode=gpg.constants.sig.mode.DETACH if detached else gpg.constants.sig.mode.CLEAR)
|
||||
signed_data, _ = context.sign(data,
|
||||
mode=gpg.constants.sig.mode.DETACH if
|
||||
detached else gpg.constants.sig.mode.CLEAR)
|
||||
return signed_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys, utils
|
||||
import sys
|
||||
import utils
|
||||
# Check if we should renew the key
|
||||
|
||||
daemon_key = get_daemon_key()
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
#!/usr/local/lib/mailinabox/env/bin/python
|
||||
# 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
|
||||
import idna
|
||||
|
||||
# SELECTING SSL CERTIFICATES FOR USE IN WEB
|
||||
|
||||
|
||||
def get_ssl_certificates(env):
|
||||
# Scan all of the installed SSL certificates and map every domain
|
||||
# that the certificates are good for to the best certificate for
|
||||
|
@ -87,7 +93,8 @@ def get_ssl_certificates(env):
|
|||
# The primary hostname can only use a certificate mapped
|
||||
# to the system private key.
|
||||
if domain == env['PRIMARY_HOSTNAME']:
|
||||
if cert._private_key._filename != os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
|
||||
if cert._private_key._filename != os.path.join(
|
||||
env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem'):
|
||||
continue
|
||||
|
||||
domains.setdefault(domain, []).append(cert)
|
||||
|
@ -98,7 +105,8 @@ def get_ssl_certificates(env):
|
|||
ret = {}
|
||||
for domain, cert_list in domains.items():
|
||||
#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename)
|
||||
cert_list.sort(key = lambda cert : (
|
||||
cert_list.sort(
|
||||
key=lambda cert: (
|
||||
# must be valid NOW
|
||||
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,
|
||||
# prefer the... lexicographically last one?
|
||||
cert._filename,
|
||||
|
||||
), reverse=True)
|
||||
),
|
||||
reverse=True)
|
||||
cert = cert_list.pop(0)
|
||||
ret[domain] = {
|
||||
"private-key": cert._private_key._filename,
|
||||
|
@ -141,16 +149,24 @@ def get_ssl_certificates(env):
|
|||
|
||||
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:
|
||||
# Get the system certificate info.
|
||||
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||
ssl_private_key = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||
ssl_certificate = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||
system_certificate = {
|
||||
"private-key": ssl_private_key,
|
||||
"certificate": ssl_certificate,
|
||||
"primary-domain": env['PRIMARY_HOSTNAME'],
|
||||
"certificate_object": load_pem(load_cert_chain(ssl_certificate)[0]),
|
||||
"certificate_object":
|
||||
load_pem(load_cert_chain(ssl_certificate)[0]),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
# using certbot. We start with domains that the box is serving web
|
||||
# for and subtract:
|
||||
|
@ -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.
|
||||
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.
|
||||
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
|
||||
# how Let's Encrypt will connect.
|
||||
bad_dns = []
|
||||
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
|
||||
if not value: continue # IPv6 is not configured
|
||||
for rtype, value in [("A", env["PUBLIC_IP"]),
|
||||
("AAAA", env.get("PUBLIC_IPV6"))]:
|
||||
if not value:
|
||||
continue # IPv6 is not configured
|
||||
response = query_dns(domain, rtype)
|
||||
if response != normalize_ip(value):
|
||||
bad_dns.append("%s (%s)" % (response, rtype))
|
||||
|
@ -226,13 +248,21 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
|
|||
# DNS is all good.
|
||||
|
||||
# Check for a good existing cert.
|
||||
existing_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
|
||||
existing_cert = get_domain_ssl_files(domain,
|
||||
existing_certs,
|
||||
env,
|
||||
use_main_cert=False,
|
||||
allow_missing_cert=True)
|
||||
if existing_cert:
|
||||
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],
|
||||
existing_cert_check = check_certificate(
|
||||
domain,
|
||||
existing_cert['certificate'],
|
||||
existing_cert['private-key'],
|
||||
warn_if_expiring_soon=14)
|
||||
if existing_cert_check[0] == "OK":
|
||||
if show_valid_certs:
|
||||
domains_cant_provision[domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
|
||||
domains_cant_provision[
|
||||
domain] = "The domain has a valid certificate already. ({} Certificate: {}, private key {})".format(
|
||||
existing_cert_check[1],
|
||||
existing_cert['certificate'],
|
||||
existing_cert['private-key'])
|
||||
|
@ -242,10 +272,12 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
|
|||
|
||||
return (domains_to_provision, domains_cant_provision)
|
||||
|
||||
|
||||
def provision_certificates(env, limit_domains):
|
||||
# What domains should we provision certificates for? And what
|
||||
# errors prevent provisioning for other domains.
|
||||
domains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)
|
||||
domains, domains_cant_provision = get_certificates_to_provision(
|
||||
env, limit_domains=limit_domains)
|
||||
|
||||
# Build a list of what happened on each domain or domain-set.
|
||||
ret = []
|
||||
|
@ -308,7 +340,8 @@ def provision_certificates(env, limit_domains):
|
|||
try:
|
||||
# Create a CSR file for our master private key so that certbot
|
||||
# uses our private key.
|
||||
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')
|
||||
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl',
|
||||
'ssl_private_key.pem')
|
||||
with tempfile.NamedTemporaryFile() as csr_file:
|
||||
# We could use openssl, but certbot requires
|
||||
# that the CN domain and SAN domains match
|
||||
|
@ -326,12 +359,18 @@ def provision_certificates(env, limit_domains):
|
|||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.x509.oid import NameOID
|
||||
builder = x509.CertificateSigningRequestBuilder()
|
||||
builder = builder.subject_name(x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0]) ]))
|
||||
builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
builder = builder.subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domain_list[0])
|
||||
]))
|
||||
builder = builder.add_extension(x509.BasicConstraints(
|
||||
ca=False, path_length=None),
|
||||
critical=True)
|
||||
builder = builder.add_extension(x509.SubjectAlternativeName(
|
||||
[x509.DNSName(d) for d in domain_list]
|
||||
), critical=False)
|
||||
request = builder.sign(load_pem(load_cert_chain(key_file)[0]), hashes.SHA256(), default_backend())
|
||||
[x509.DNSName(d) for d in domain_list]),
|
||||
critical=False)
|
||||
request = builder.sign(load_pem(load_cert_chain(key_file)[0]),
|
||||
hashes.SHA256(), default_backend())
|
||||
with open(csr_file.name, "wb") as f:
|
||||
f.write(request.public_bytes(Encoding.PEM))
|
||||
|
||||
|
@ -340,8 +379,10 @@ def provision_certificates(env, limit_domains):
|
|||
os.makedirs(webroot, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cert_file = os.path.join(d, 'cert_and_chain.pem')
|
||||
print("Provisioning TLS certificates for " + ", ".join(domain_list) + ".")
|
||||
certbotret = subprocess.check_output([
|
||||
print("Provisioning TLS certificates for " +
|
||||
", ".join(domain_list) + ".")
|
||||
certbotret = subprocess.check_output(
|
||||
[
|
||||
"certbot",
|
||||
"certonly",
|
||||
# "-v", # just enough to see ACME errors
|
||||
|
@ -349,18 +390,29 @@ def provision_certificates(env, limit_domains):
|
|||
"--agree-tos", # Automatically agrees to Let's Encrypt TOS
|
||||
"--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
|
||||
"--cert-path", os.path.join(d, 'cert'), # we only use the full chain
|
||||
"--chain-path", os.path.join(d, 'chain'), # we only use the full chain
|
||||
"--fullchain-path", cert_file,
|
||||
|
||||
"--webroot", "--webroot-path", webroot,
|
||||
|
||||
"--config-dir", account_path,
|
||||
# use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually
|
||||
"--csr",
|
||||
csr_file.name,
|
||||
# we only use the full chain
|
||||
"--cert-path",
|
||||
os.path.join(d, 'cert'),
|
||||
# we only use the full chain
|
||||
"--chain-path",
|
||||
os.path.join(d, 'chain'),
|
||||
"--fullchain-path",
|
||||
cert_file,
|
||||
"--webroot",
|
||||
"--webroot-path",
|
||||
webroot,
|
||||
"--config-dir",
|
||||
account_path,
|
||||
# "--staging",
|
||||
], stderr=subprocess.STDOUT).decode("utf8")
|
||||
],
|
||||
stderr=subprocess.STDOUT).decode("utf8")
|
||||
install_cert_copy_file(cert_file, env)
|
||||
|
||||
ret[-1]["log"].append(certbotret)
|
||||
|
@ -378,6 +430,7 @@ def provision_certificates(env, limit_domains):
|
|||
# Return what happened with each certificate request.
|
||||
return ret
|
||||
|
||||
|
||||
def provision_certificates_cmdline():
|
||||
import sys
|
||||
from exclusiveprocess import Lock
|
||||
|
@ -414,12 +467,13 @@ def provision_certificates_cmdline():
|
|||
|
||||
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
|
||||
|
||||
|
||||
def create_csr(domain, ssl_key, country_code, env):
|
||||
return shell("check_output", [
|
||||
"openssl", "req", "-new",
|
||||
"-key", ssl_key,
|
||||
"-sha256",
|
||||
"-subj", "/C=%s/CN=%s" % (country_code, domain)])
|
||||
"openssl", "req", "-new", "-key", ssl_key, "-sha256", "-subj",
|
||||
"/C=%s/CN=%s" % (country_code, domain)
|
||||
])
|
||||
|
||||
|
||||
def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
||||
# Write the combined cert+chain to a temporary path and validate that it is OK.
|
||||
|
@ -430,8 +484,10 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
|||
os.close(fd)
|
||||
|
||||
# Do validation on the certificate before installing it.
|
||||
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||
cert_status, cert_status_details = check_certificate(domain, fn, ssl_private_key)
|
||||
ssl_private_key = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
|
||||
cert_status, cert_status_details = check_certificate(
|
||||
domain, fn, ssl_private_key)
|
||||
if cert_status != "OK":
|
||||
if cert_status == "SELF-SIGNED":
|
||||
cert_status = "This is a self-signed certificate. I can't install that."
|
||||
|
@ -445,7 +501,8 @@ def install_cert(domain, ssl_cert, ssl_chain, env, raw=False):
|
|||
|
||||
# Run post-install steps.
|
||||
ret = post_install_func(env)
|
||||
if raw: return ret
|
||||
if raw:
|
||||
return ret
|
||||
return "\n".join(ret)
|
||||
|
||||
|
||||
|
@ -457,11 +514,15 @@ def install_cert_copy_file(fn, env):
|
|||
cert = load_pem(load_cert_chain(fn)[0])
|
||||
all_domains, cn = get_certificate_domains(cert)
|
||||
path = "%s-%s-%s.pem" % (
|
||||
safe_domain_name(cn), # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
|
||||
cert.not_valid_after.date().isoformat().replace("-", ""), # expiration date
|
||||
hexlify(cert.fingerprint(hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
||||
# common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
|
||||
safe_domain_name(cn),
|
||||
cert.not_valid_after.date().isoformat().replace("-",
|
||||
""), # expiration date
|
||||
hexlify(cert.fingerprint(
|
||||
hashes.SHA256())).decode("ascii")[0:8], # fingerprint prefix
|
||||
)
|
||||
ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
||||
ssl_certificate = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', path))
|
||||
|
||||
# Install the certificate.
|
||||
os.makedirs(os.path.dirname(ssl_certificate), exist_ok=True)
|
||||
|
@ -473,16 +534,21 @@ def post_install_func(env):
|
|||
|
||||
# Get the certificate to use for PRIMARY_HOSTNAME.
|
||||
ssl_certificates = get_ssl_certificates(env)
|
||||
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'], ssl_certificates, env, use_main_cert=False)
|
||||
cert = get_domain_ssl_files(env['PRIMARY_HOSTNAME'],
|
||||
ssl_certificates,
|
||||
env,
|
||||
use_main_cert=False)
|
||||
if not cert:
|
||||
# Ruh-row, we don't have any certificate usable
|
||||
# for the primary hostname.
|
||||
ret.append("there is no valid certificate for " + env['PRIMARY_HOSTNAME'])
|
||||
ret.append("there is no valid certificate for " +
|
||||
env['PRIMARY_HOSTNAME'])
|
||||
|
||||
# Symlink the best cert for PRIMARY_HOSTNAME to the system
|
||||
# certificate path, which is hard-coded for various purposes, and then
|
||||
# restart postfix and dovecot.
|
||||
system_ssl_certificate = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||
system_ssl_certificate = os.path.join(
|
||||
os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_certificate.pem'))
|
||||
if cert and os.readlink(system_ssl_certificate) != cert['certificate']:
|
||||
# Update symlink.
|
||||
ret.append("updating primary certificate")
|
||||
|
@ -505,9 +571,16 @@ def post_install_func(env):
|
|||
|
||||
return ret
|
||||
|
||||
|
||||
# VALIDATION OF CERTIFICATES
|
||||
|
||||
def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring_soon=10, rounded_time=False, just_check_domain=False):
|
||||
|
||||
def check_certificate(domain,
|
||||
ssl_certificate,
|
||||
ssl_private_key,
|
||||
warn_if_expiring_soon=10,
|
||||
rounded_time=False,
|
||||
just_check_domain=False):
|
||||
# Check that the ssl_certificate & ssl_private_key files are good
|
||||
# for the provided domain.
|
||||
|
||||
|
@ -520,9 +593,11 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
try:
|
||||
ssl_cert_chain = load_cert_chain(ssl_certificate)
|
||||
cert = load_pem(ssl_cert_chain[0])
|
||||
if not isinstance(cert, Certificate): raise ValueError("This is not a certificate file.")
|
||||
if not isinstance(cert, Certificate):
|
||||
raise ValueError("This is not a certificate file.")
|
||||
except ValueError as e:
|
||||
return ("There is a problem with the certificate file: %s" % str(e), None)
|
||||
return ("There is a problem with the certificate file: %s" % str(e),
|
||||
None)
|
||||
|
||||
# First check that the domain name is one of the names allowed by
|
||||
# the certificate.
|
||||
|
@ -534,21 +609,27 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
# should work in normal cases).
|
||||
wildcard_domain = re.sub("^[^\.]+", "*", domain)
|
||||
if domain not in certificate_names and wildcard_domain not in certificate_names:
|
||||
return ("The certificate is for the wrong domain name. It is for %s."
|
||||
% ", ".join(sorted(certificate_names)), None)
|
||||
return (
|
||||
"The certificate is for the wrong domain name. It is for %s." %
|
||||
", ".join(sorted(certificate_names)), None)
|
||||
|
||||
# Second, check that the certificate matches the private key.
|
||||
if ssl_private_key is not None:
|
||||
try:
|
||||
priv_key = load_pem(open(ssl_private_key, 'rb').read())
|
||||
except ValueError as e:
|
||||
return ("The private key file %s is not a private key file: %s" % (ssl_private_key, str(e)), None)
|
||||
return ("The private key file %s is not a private key file: %s" %
|
||||
(ssl_private_key, str(e)), None)
|
||||
|
||||
if not isinstance(priv_key, RSAPrivateKey):
|
||||
return ("The private key file %s is not a private key file." % ssl_private_key, None)
|
||||
return ("The private key file %s is not a private key file." %
|
||||
ssl_private_key, None)
|
||||
|
||||
if priv_key.public_key().public_numbers() != cert.public_key().public_numbers():
|
||||
return ("The certificate does not correspond to the private key at %s." % ssl_private_key, None)
|
||||
if priv_key.public_key().public_numbers() != cert.public_key(
|
||||
).public_numbers():
|
||||
return (
|
||||
"The certificate does not correspond to the private key at %s."
|
||||
% ssl_private_key, None)
|
||||
|
||||
# We could also use the openssl command line tool to get the modulus
|
||||
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
|
||||
|
@ -570,7 +651,9 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
import datetime
|
||||
now = datetime.datetime.utcnow()
|
||||
if not (cert.not_valid_before <= now <= cert.not_valid_after):
|
||||
return ("The certificate has expired or is not yet valid. It is valid from %s to %s." % (cert.not_valid_before, cert.not_valid_after), None)
|
||||
return (
|
||||
"The certificate has expired or is not yet valid. It is valid from %s to %s."
|
||||
% (cert.not_valid_before, cert.not_valid_after), None)
|
||||
|
||||
# Next validate that the certificate is valid. This checks whether the certificate
|
||||
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
|
||||
|
@ -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.
|
||||
# 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",
|
||||
"verify", "-verbose",
|
||||
"-purpose", "sslserver", "-policy_check",]
|
||||
+ ([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
||||
"verify",
|
||||
"-verbose",
|
||||
"-purpose",
|
||||
"sslserver",
|
||||
"-policy_check",
|
||||
] +
|
||||
([] if len(ssl_cert_chain) == 1 else ["-untrusted", "/proc/self/fd/0"])
|
||||
+ [ssl_certificate],
|
||||
input=b"\n\n".join(ssl_cert_chain[1:]),
|
||||
trap=True)
|
||||
|
@ -594,10 +683,13 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
|
||||
elif retcode != 0:
|
||||
if "unable to get local issuer certificate" in verifyoutput:
|
||||
return ("The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)" % verifyoutput, None)
|
||||
return (
|
||||
"The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. (%s)"
|
||||
% verifyoutput, None)
|
||||
|
||||
# There is some unknown problem. Return the `openssl verify` raw output.
|
||||
return ("There is a problem with the certificate.", verifyoutput.strip())
|
||||
return ("There is a problem with the certificate.",
|
||||
verifyoutput.strip())
|
||||
|
||||
else:
|
||||
# `openssl verify` returned a zero exit status so the cert is currently
|
||||
|
@ -608,10 +700,12 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
ndays = (cert_expiration_date - now).days
|
||||
if not rounded_time or ndays <= 10:
|
||||
# Yikes better renew soon!
|
||||
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
|
||||
expiry_info = "The certificate expires in %d days on %s." % (
|
||||
ndays, cert_expiration_date.date().isoformat())
|
||||
else:
|
||||
# We'll renew it with Lets Encrypt.
|
||||
expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
|
||||
expiry_info = "The certificate expires on %s." % cert_expiration_date.date(
|
||||
).isoformat()
|
||||
|
||||
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
|
||||
# Warn on day 10 to give 4 days for us to automatically renew the
|
||||
|
@ -621,6 +715,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
|
|||
# Return the special OK code.
|
||||
return ("OK", expiry_info)
|
||||
|
||||
|
||||
def load_cert_chain(pemfile):
|
||||
# A certificate .pem file may contain a chain of certificates.
|
||||
# Load the file and split them apart.
|
||||
|
@ -632,6 +727,7 @@ def load_cert_chain(pemfile):
|
|||
raise ValueError("File does not contain valid PEM data.")
|
||||
return pemblocks
|
||||
|
||||
|
||||
def load_pem(pem):
|
||||
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
|
||||
# using classes from the cryptography package.
|
||||
|
@ -643,10 +739,14 @@ def load_pem(pem):
|
|||
raise ValueError("File is not a valid PEM-formatted file.")
|
||||
pem_type = pem_type.group(1)
|
||||
if pem_type in (b"RSA PRIVATE KEY", b"PRIVATE KEY"):
|
||||
return serialization.load_pem_private_key(pem, password=None, backend=default_backend())
|
||||
return serialization.load_pem_private_key(pem,
|
||||
password=None,
|
||||
backend=default_backend())
|
||||
if pem_type == b"CERTIFICATE":
|
||||
return load_pem_x509_certificate(pem, default_backend())
|
||||
raise ValueError("Unsupported PEM object type: " + pem_type.decode("ascii", "replace"))
|
||||
raise ValueError("Unsupported PEM object type: " +
|
||||
pem_type.decode("ascii", "replace"))
|
||||
|
||||
|
||||
def get_certificate_domains(cert):
|
||||
from cryptography.x509 import DNSName, ExtensionNotFound, OID_COMMON_NAME, OID_SUBJECT_ALTERNATIVE_NAME
|
||||
|
@ -675,7 +775,8 @@ def get_certificate_domains(cert):
|
|||
return idna.encode(dns_name).decode('ascii')
|
||||
|
||||
try:
|
||||
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
||||
sans = cert.extensions.get_extension_for_oid(
|
||||
OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
|
||||
for san in sans:
|
||||
names.add(idna_decode_dns_name(san))
|
||||
except ExtensionNotFound:
|
||||
|
@ -683,6 +784,7 @@ def get_certificate_domains(cert):
|
|||
|
||||
return names, cn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Provision certificates.
|
||||
provision_certificates_cmdline()
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,12 @@
|
|||
<style>
|
||||
#alias_table .alias-auto .actions > * { display: none }
|
||||
#addalias-form .hidden { display: none; }
|
||||
#alias_table .alias-auto .actions>* {
|
||||
display: none
|
||||
}
|
||||
|
||||
#addalias-form .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn.btn-xs {
|
||||
padding: .1rem .3rem;
|
||||
font-size: .75rem;
|
||||
|
@ -12,9 +18,11 @@
|
|||
|
||||
<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;">
|
||||
|
||||
|
@ -26,7 +34,8 @@
|
|||
</div>
|
||||
<div id="alias_mode_info" class="text-info small" style="display: none; margin: .5em 0 0 0;">
|
||||
<span class="catchall hidden">A catch-all alias captures all otherwise unmatched email to a domain.</span>
|
||||
<span class="domainalias hidden">A domain alias forwards all otherwise unmatched email from one domain to another domain, preserving the part before the @-sign.</span>
|
||||
<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>
|
||||
|
||||
|
@ -47,8 +56,12 @@
|
|||
<label for="addaliasForwardsTo">Forwards To</label>
|
||||
</div>
|
||||
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%">
|
||||
<span class="domainalias text-muted">Enter just the part of an email address starting with the @-sign.</span>
|
||||
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other domains, create a mail user and then log into webmail for the user and create a filter rule to forward mail.</span>
|
||||
<span class="domainalias text-muted">Enter just the part of an email address starting with the
|
||||
@-sign.</span>
|
||||
<span class="text-danger">Only forward mail to addresses handled by this Mail-in-a-Box, since mail forwarded
|
||||
by aliases to other domains may be rejected or filtered by the receiver. To forward mail to other
|
||||
domains, create a mail user and then log into webmail for the user and create a filter rule to forward
|
||||
mail.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -59,20 +72,27 @@
|
|||
<div class="mb-3">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio" checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
||||
Any mail user listed in the Forwards To box can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
|
||||
<input id="addaliasForwardsToNotAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
||||
checked onclick="$('#addaliasForwardsToDiv').toggle(false)">
|
||||
Any mail user listed in the Forwards To box can send mail claiming to be from <span
|
||||
class="regularalias">the alias address</span><span class="catchall domainalias">any address
|
||||
on the alias domain</span>.
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio" id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
||||
I’ll enter the mail users that can send mail claiming to be from <span class="regularalias">the alias address</span><span class="catchall domainalias">any address on the alias domain</span>.
|
||||
<input id="addaliasForwardsToAdvanced" name="addaliasForwardsToDivToggle" type="radio"
|
||||
id="addaliasForwardsToDivShower" onclick="$('#addaliasForwardsToDiv').toggle(true)">
|
||||
I’ll enter the mail users that can send mail claiming to be from <span
|
||||
class="regularalias">the alias address</span><span class="catchall domainalias">any address
|
||||
on the alias domain</span>.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="addaliasForwardsToDiv" style="display: none;">
|
||||
<div class="form-floating">
|
||||
<textarea class="form-control" style="min-height: 8em;" id="addaliasSenders" placeholder="one user per line or separated by commas"></textarea>
|
||||
<textarea class="form-control" style="min-height: 8em;" id="addaliasSenders"
|
||||
placeholder="one user per line or separated by commas"></textarea>
|
||||
<label for="addaliasSenders">Permitted Senders</label>
|
||||
</div>
|
||||
<small>One user per line or separated by commas</small>
|
||||
|
@ -97,16 +117,19 @@
|
|||
</tbody>
|
||||
</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">
|
||||
<table>
|
||||
<tr id="alias-template">
|
||||
<td class='actions row justify-content-evenly' style="min-width: 4em; border: none;">
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;" class='edit' title="Edit Alias">
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_edit(this); scroll_top(); return false;"
|
||||
class='edit' title="Edit Alias">
|
||||
<span class="text-center text-primary fas fa-pen"></span>
|
||||
</button>
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_remove(this); return false;" class='remove' title="Remove Alias">
|
||||
<button class="col-5 btn btn-xs btn-default" onclick="aliases_remove(this); return false;"
|
||||
class='remove' title="Remove Alias">
|
||||
<span class="text-center text-danger fas fa-trash"></span>
|
||||
</button>
|
||||
</td>
|
||||
|
@ -119,7 +142,8 @@
|
|||
|
||||
<h3>Mail aliases API (advanced)</h3>
|
||||
|
||||
<p>Use your box’s mail aliases API to add and remove mail aliases from the command-line or custom services you build.</p>
|
||||
<p>Use your box’s mail aliases API to add and remove mail aliases from the command-line or custom services you
|
||||
build.</p>
|
||||
|
||||
<p>Usage:</p>
|
||||
|
||||
|
@ -133,15 +157,34 @@
|
|||
|
||||
<table class="table" style="margin-top: .5em">
|
||||
<caption></caption>
|
||||
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||
<tr><td><b>GET</b></td><td><i>(none)</i></td> <td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||
<tr><td><b>POST</b></td><td class="font-monospace">/add</td> <td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.</td></tr>
|
||||
<tr><td><b>POST</b></td><td class="font-monospace">/remove</td> <td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td></tr>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td><b>GET</b></td>
|
||||
<td><i>(none)</i></td>
|
||||
<td>Returns a list of existing mail aliases. Adding <code>?format=json</code> to the URL will give JSON-encoded
|
||||
results.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/add</td>
|
||||
<td>Adds a new mail alias. Required POST-body parameters are <code>address</code> and <code>forwards_to</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/remove</td>
|
||||
<td>Removes a mail alias. Required POST-body parameter is <code>address</code>.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
||||
line argument which you must fill in with your email address and password.</p>
|
||||
|
||||
<pre># Gives a JSON-encoded list of all mail aliases
|
||||
curl -X GET https://{{hostname}}/admin/mail/aliases?format=json
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
|
||||
<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;">
|
||||
<div class="col-lg-10 col-xl-7 mb-3">
|
||||
|
@ -28,15 +29,29 @@
|
|||
<div class="input-group">
|
||||
<label for="customdnsType" class="input-group-text">Type</label>
|
||||
<select id="customdnsType" class="form-select" onchange="show_customdns_rtype_hint()">
|
||||
<option value="A" data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">A (IPv4 address)</option>
|
||||
<option value="AAAA" data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">AAAA (IPv6 address)</option>
|
||||
<option value="CAA" data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild "letsencrypt.org")">CAA (Certificate Authority Authorization)</option>
|
||||
<option value="CNAME" data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">CNAME (DNS forwarding)</option>
|
||||
<option value="A"
|
||||
data-hint="Enter an IPv4 address (i.e. a dotted quad, such as 123.456.789.012). The 'local' alias sets the record to this box's public IPv4 address.">
|
||||
A (IPv4 address)</option>
|
||||
<option value="AAAA"
|
||||
data-hint="Enter an IPv6 address. The 'local' alias sets the record to this box's public IPv6 address.">
|
||||
AAAA (IPv6 address)</option>
|
||||
<option value="CAA"
|
||||
data-hint="Enter a CA that can issue certificates for this domain in the form of FLAG TAG VALUE. (0 issuewild "letsencrypt.org")">
|
||||
CAA (Certificate Authority Authorization)</option>
|
||||
<option value="CNAME"
|
||||
data-hint="Enter another domain name followed by a period at the end (e.g. mypage.github.io.).">
|
||||
CNAME (DNS forwarding)</option>
|
||||
<option value="TXT" data-hint="Enter arbitrary text.">TXT (text record)</option>
|
||||
<option value="MX" data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">MX (mail exchanger)</option>
|
||||
<option value="SRV" data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">SRV (service record)</option>
|
||||
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH fingerprint record)</option>
|
||||
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS subdomain delegation)</option>
|
||||
<option value="MX"
|
||||
data-hint="Enter record in the form of PRIORITY DOMAIN., including trailing period (e.g. 20 mx.example.com.).">
|
||||
MX (mail exchanger)</option>
|
||||
<option value="SRV"
|
||||
data-hint="Enter record in the form of PRIORITY WEIGHT PORT TARGET., including trailing period (e.g. 10 10 5060 sip.example.com.).">
|
||||
SRV (service record)</option>
|
||||
<option value="SSHFP" data-hint="Enter record in the form of ALGORITHM TYPE FINGERPRINT.">SSHFP (SSH
|
||||
fingerprint record)</option>
|
||||
<option value="NS" data-hint="Enter a hostname to which this subdomain should be delegated to">NS (DNS
|
||||
subdomain delegation)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -54,9 +69,12 @@
|
|||
|
||||
<div style="font-size: 90%;">
|
||||
<b>Sort by:</b>
|
||||
<a href="#" onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain name</a>
|
||||
<a href="#"
|
||||
onclick="window.miab_custom_dns_data_sort_order='qname'; show_current_custom_dns_update_after_sort(); return false;">Domain
|
||||
name</a>
|
||||
<b>|</b>
|
||||
<a href="#" onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">Created</a>
|
||||
<a href="#"
|
||||
onclick="window.miab_custom_dns_data_sort_order='created'; show_current_custom_dns_update_after_sort(); return false;">Created</a>
|
||||
</div>
|
||||
<table id="custom-dns-current" class="table col-12" style="display: none; margin-top: 0;">
|
||||
<caption></caption>
|
||||
|
@ -68,26 +86,37 @@
|
|||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="5">Loading...</td></tr>
|
||||
<tr>
|
||||
<td colspan="5">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Using a secondary nameserver</h3>
|
||||
|
||||
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#" onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or use the DNS server on this box but add a secondary (aka “slave”) nameserver.</p>
|
||||
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
|
||||
<p>If your TLD requires you to have two separate nameservers, you can either set up <a href="#"
|
||||
onclick="return show_panel('external_dns')">external DNS</a> and ignore the DNS server on this box entirely, or
|
||||
use the DNS server on this box but add a secondary (aka “slave”) nameserver.</p>
|
||||
<p>If you choose to use a secondary nameserver, you must find a secondary nameserver service provider. Your domain name
|
||||
registrar or virtual cloud provider may provide this service for you. Once you set up the secondary nameserver
|
||||
service, enter the hostname (not the IP address) of <em>their</em> secondary nameserver in the box below.</p>
|
||||
|
||||
<form class="form-horizontal" role="form" onsubmit="do_set_secondary_dns(); return false;">
|
||||
<div class="col-12 form-floating mb-3">
|
||||
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname" placeholder="ns1.example.com"></textarea>
|
||||
<textarea type="text" class="form-control font-monospace" id="secondarydnsHostname"
|
||||
placeholder="ns1.example.com"></textarea>
|
||||
<label for="secondarydnsHostname">Secondary Nameservers</label>
|
||||
<div>
|
||||
<p class="small">
|
||||
Multiple secondary servers can be separated with commas or spaces (i.e., <span class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
|
||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span class="font-monospace">xfr:10.0.0.0/8</span>.
|
||||
Multiple secondary servers can be separated with commas or spaces (i.e., <span
|
||||
class="font-monospace">ns2.hostingcompany.com ns3.hostingcompany.com</span>).
|
||||
To enable zone transfers to additional servers without listing them as secondary nameservers, add an IP
|
||||
address or subnet using <span class="font-monospace">xfr:10.20.30.40</span> or <span
|
||||
class="font-monospace">xfr:10.0.0.0/8</span>.
|
||||
</p>
|
||||
<p id="secondarydns-clear-instructions" style="display: none" class="small">
|
||||
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the default/normal setup.
|
||||
Clear the input field above and click Update to use this machine itself as secondary DNS, which is the
|
||||
default/normal setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -98,7 +127,8 @@
|
|||
|
||||
<h3>Custom DNS API</h3>
|
||||
|
||||
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own dynamic DNS service.</p>
|
||||
<p>Use your box’s DNS API to set custom DNS records on domains hosted here. For instance, you can create your own
|
||||
dynamic DNS service.</p>
|
||||
|
||||
<p>Usage:</p>
|
||||
|
||||
|
@ -110,30 +140,82 @@
|
|||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead><th>Verb</th> <th>Usage</th></thead>
|
||||
<tr><td class="font-monospace"><b>GET</b></td> <td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>, <code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code> parameters in the request URL filter the records returned in the response. The request body (<code>-d "..."</code>) must be omitted.</td></tr>
|
||||
<tr><td class="font-monospace"><b>PUT</b></td> <td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and <code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and <code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td></tr>
|
||||
<tr><td class="font-monospace"><b>POST</b></td> <td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin <code>A</code> records. (PUT would delete previously added records.)</td></tr>
|
||||
<tr><td class="font-monospace"><b>DELETE</b></td> <td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only the record matching the <code>qname</code>, <code>rtype</code> and value.</td></tr>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Usage</th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>GET</b></td>
|
||||
<td>Returns matching custom DNS records as a JSON array of objects. Each object has the keys <code>qname</code>,
|
||||
<code>rtype</code>, and <code>value</code>. The optional <code>qname</code> and <code>rtype</code>
|
||||
parameters in the request URL filter the records returned in the response. The request body
|
||||
(<code>-d "..."</code>) must be omitted.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>PUT</b></td>
|
||||
<td>Sets a custom DNS record replacing any existing records with the same <code>qname</code> and
|
||||
<code>rtype</code>. Use PUT (instead of POST) when you only have one value for a <code>qname</code> and
|
||||
<code>rtype</code>, such as typical <code>A</code> records (without round-robin).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>POST</b></td>
|
||||
<td>Adds a new custom DNS record. Use POST when you have multiple <code>TXT</code> records or round-robin
|
||||
<code>A</code> records. (PUT would delete previously added records.)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>DELETE</b></td>
|
||||
<td>Deletes custom DNS records. If the request body (<code>-d "..."</code>) is empty or omitted, deletes all
|
||||
records matching the <code>qname</code> and <code>rtype</code>. If the request body is present, deletes only
|
||||
the record matching the <code>qname</code>, <code>rtype</code> and value.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Parameters</h4>
|
||||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead><th>Parameter</th> <th>Value</th></thead>
|
||||
<tr><td class="font-monospace"><b>email</b></td> <td>The email address of any administrative user here.</td></tr>
|
||||
<tr><td class="font-monospace"><b>password</b></td> <td>That user’s password.</td></tr>
|
||||
<tr><td class="font-monospace"><b>qname</b></td> <td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)</td></tr>
|
||||
<tr><td class="font-monospace"><b>rtype</b></td> <td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address), <code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which is a fully qualified domain name — don’t forget the final period), <code>MX</code>, <code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td></tr>
|
||||
<tr><td class="font-monospace"><b>value</b></td> <td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or <code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!</td></tr>
|
||||
<thead>
|
||||
<th>Parameter</th>
|
||||
<th>Value</th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>email</b></td>
|
||||
<td>The email address of any administrative user here.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>password</b></td>
|
||||
<td>That user’s password.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>qname</b></td>
|
||||
<td>The fully qualified domain name for the record you are trying to set. It must be one of the domain names or
|
||||
a subdomain of one of the domain names hosted on this box. (Add mail users or aliases to add new domains.)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>rtype</b></td>
|
||||
<td>The resource type. Defaults to <code>A</code> if omitted. Possible values: <code>A</code> (an IPv4 address),
|
||||
<code>AAAA</code> (an IPv6 address), <code>TXT</code> (a text string), <code>CNAME</code> (an alias, which
|
||||
is a fully qualified domain name — don’t forget the final period), <code>MX</code>,
|
||||
<code>SRV</code>, <code>SSHFP</code>, <code>CAA</code> or <code>NS</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-monospace"><b>value</b></td>
|
||||
<td>For PUT, POST, and DELETE, the record’s value. If the <code>rtype</code> is <code>A</code> or
|
||||
<code>AAAA</code> and <code>value</code> is empty or omitted, the IPv4 or IPv6 address of the remote host is
|
||||
used (be sure to use the <code>-4</code> or <code>-6</code> options to curl). This is handy for dynamic DNS!
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be added to all custom domains unless you override them.</p>
|
||||
<p>Strict <a href="http://tools.ietf.org/html/rfc4408">SPF</a> and <a
|
||||
href="https://datatracker.ietf.org/doc/draft-kucherawy-dmarc-base/?include_text=1">DMARC</a> records will be
|
||||
added to all custom domains unless you override them.</p>
|
||||
|
||||
<h4>Examples:</h4>
|
||||
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command line argument which you must fill in with your email address and password.</p>
|
||||
<p>Try these examples. For simplicity the examples omit the <code>--user me@mydomain.com:yourpassword</code> command
|
||||
line argument which you must fill in with your email address and password.</p>
|
||||
|
||||
<pre># sets laptop.mydomain.com to point to the IP address of the machine you are executing curl on
|
||||
curl -X PUT https://{{hostname}}/admin/dns/custom/laptop.mydomain.com
|
||||
|
|
|
@ -3,14 +3,17 @@
|
|||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#external_dns_settings .values td {
|
||||
border: 0;
|
||||
padding-top: .75em;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#external_dns_settings .value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#external_dns_settings .explanation td {
|
||||
padding-top: .5em;
|
||||
padding-bottom: .75em;
|
||||
|
@ -23,16 +26,22 @@
|
|||
|
||||
<p class="text-warning">This is an advanced configuration page.</p>
|
||||
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS zone information shown in the table below into your external DNS server’s control panel.</p>
|
||||
<p>Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere — such as in
|
||||
the DNS control panel provided by your domain name registrar or virtual cloud provider — by copying the DNS
|
||||
zone information shown in the table below into your external DNS server’s control panel.</p>
|
||||
|
||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your domain name by setting a DS record at your registrar, you will likely have to turn it off before changing nameservers.</p>
|
||||
<p>If you do so, you are responsible for keeping your DNS entries up to date! If you previously enabled DNSSEC on your
|
||||
domain name by setting a DS record at your registrar, you will likely have to turn it off before changing
|
||||
nameservers.</p>
|
||||
|
||||
|
||||
<p class="alert" role="alert">
|
||||
<span class="fas fa-info-circle"></span>
|
||||
You may encounter zone file errors when attempting to create a TXT record with a long string.
|
||||
<a href="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to validate your zone file.
|
||||
<a href="https://tools.ietf.org/html/rfc4408#section-3.1.3">RFC 4408</a> states a TXT record is allowed to contain
|
||||
multiple strings, and this technique can be used to construct records that would exceed the 255-byte maximum length.
|
||||
You may need to adopt this technique when adding DomainKeys. Use a tool like <code>named-checkzone</code> to
|
||||
validate your zone file.
|
||||
</p>
|
||||
|
||||
<h3>Download zonefile</h3>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta charset="utf-8">
|
||||
|
@ -23,7 +24,10 @@
|
|||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -39,6 +43,7 @@
|
|||
margin-bottom: 13px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.panel-heading h3 {
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
@ -50,6 +55,7 @@
|
|||
margin-bottom: 13px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
h4:first-child {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
@ -66,16 +72,23 @@
|
|||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.if-logged-in { display: none; }
|
||||
.if-logged-in-admin { display: none; }
|
||||
.if-logged-in {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.if-logged-in-admin {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="">
|
||||
|
||||
<div class="navbar navbar-expand-lg navbar-light" role="navigation">
|
||||
<div class="container bg-light pt-2 pb-2">
|
||||
<div class="if-logged-in">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="#navbarContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
|
||||
aria-controls="#navbarContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -93,41 +106,60 @@
|
|||
<div class="collapse navbar-collapse" id="navbarContent">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">System</button>
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">System</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#system_status" onclick="return show_panel(this);">Status Checks</a></li>
|
||||
<li><a class="dropdown-item" href="#tls" onclick="return show_panel(this);">TLS (SSL) Certificates</a></li>
|
||||
<li><a class="dropdown-item" href="#system_backup" onclick="return show_panel(this);">Backup Status</a></li>
|
||||
<li><a class="dropdown-item" href="#smtp_relays" onclick="return show_panel(this);">SMTP Relays</a></li>
|
||||
<li><a class="dropdown-item" href="#system_status" onclick="return show_panel(this);">Status
|
||||
Checks</a></li>
|
||||
<li><a class="dropdown-item" href="#tls" onclick="return show_panel(this);">TLS (SSL)
|
||||
Certificates</a></li>
|
||||
<li><a class="dropdown-item" href="#system_backup" onclick="return show_panel(this);">Backup
|
||||
Status</a></li>
|
||||
<li><a class="dropdown-item" href="#smtp_relays" onclick="return show_panel(this);">SMTP
|
||||
Relays</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Advanced</button>
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Advanced</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#pgp_keyring" onclick="return show_panel(this);">PGP Keyring Management</a></li>
|
||||
<li><a class="dropdown-item" href="#wkd" onclick="return show_panel(this);">WKD Management</a></li>
|
||||
<li><a class="dropdown-item" href="#munin" onclick="return show_panel(this);">Munin Monitoring</a></li>
|
||||
<li><a class="dropdown-item" href="#custom_dns" onclick="return show_panel(this);">Custom
|
||||
DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#external_dns"
|
||||
onclick="return show_panel(this);">External DNS</a></li>
|
||||
<li><a class="dropdown-item" href="#pgp_keyring" onclick="return show_panel(this);">PGP
|
||||
Keyring Management</a></li>
|
||||
<li><a class="dropdown-item" href="#wkd" onclick="return show_panel(this);">WKD
|
||||
Management</a></li>
|
||||
<li><a class="dropdown-item" href="#munin" onclick="return show_panel(this);">Munin
|
||||
Monitoring</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 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
|
||||
</li>
|
||||
<li class="nav-item me-1 me-xl-4 dropdown if-logged-in-admin">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Mail</button>
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Mail</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#mail-guide" onclick="return show_panel(this);">Mail Guide</a></li>
|
||||
<li><a class="dropdown-item" href="#mail-guide" onclick="return show_panel(this);">Mail
|
||||
Guide</a></li>
|
||||
<li><a class="dropdown-item" href="#users" onclick="return show_panel(this);">Users</a></li>
|
||||
<li><a class="dropdown-item" href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
|
||||
<li><a class="dropdown-item" href="#aliases" onclick="return show_panel(this);">Aliases</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Your Account</li>
|
||||
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||
<li><a class="dropdown-item" href="#mfa" onclick="return show_panel(this);">Two-Factor
|
||||
Authentication</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in" type="button" href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</button></li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in-admin" type="button" href="#web" onclick="return show_panel(this);">Web</button></li>
|
||||
<li><button class="nav-item btn btn-secondary if-logged-in" type="button" onclick="do_logout(); return false;"><b>Logout</b></button></li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in" type="button" href="#sync_guide"
|
||||
onclick="return show_panel(this);">Contacts/Calendar</button></li>
|
||||
<li><button class="nav-item me-1 me-xl-4 btn if-logged-in-admin" type="button" href="#web"
|
||||
onclick="return show_panel(this);">Web</button></li>
|
||||
<li><button class="nav-item btn btn-secondary if-logged-in" type="button"
|
||||
onclick="do_logout(); return false;"><b>Logout</b></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -209,14 +241,16 @@
|
|||
</footer>
|
||||
</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="spinner-border text-light" role="status" style="width: 4rem; height: 4rem;"></div>
|
||||
<div class="text-light display-5">Loading... please wait!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle" aria-hidden="true">
|
||||
<div id="global_modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="errorModalTitle"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
@ -285,7 +319,11 @@
|
|||
}
|
||||
|
||||
.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 .dropdown-menu,
|
||||
.darkmode th,
|
||||
|
|
|
@ -61,7 +61,8 @@ sudo management/cli.py user make-admin me@{{hostname}}</pre>
|
|||
<div class="form-floating mb-3" id="loginOtp">
|
||||
<input type="text" class="form-control" id="loginOtpInput" placeholder="000000" autocomplete="off">
|
||||
<label for="loginOtpInput">TOTP Code</label>
|
||||
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
|
||||
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two
|
||||
factor authentication app.</div>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<div class="ms-auto">
|
||||
|
@ -133,9 +134,11 @@ function do_login() {
|
|||
// Login succeeded.
|
||||
|
||||
// Save the new credentials.
|
||||
api_credentials = { username: response.email,
|
||||
api_credentials = {
|
||||
username: response.email,
|
||||
session_key: response.api_key,
|
||||
privileges: response.privileges };
|
||||
privileges: response.privileges
|
||||
};
|
||||
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
|
|
|
@ -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>
|
||||
<h2 style="margin-bottom: 0">Checking and Sending Mail</h2>
|
||||
|
@ -8,7 +13,8 @@
|
|||
<h3>Webmail</h3>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
@ -16,7 +22,10 @@
|
|||
|
||||
<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>
|
||||
|
||||
|
@ -25,32 +34,71 @@
|
|||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead>
|
||||
<tr><th>Option</th> <th>Value</th></tr>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr><th>Protocol/Method</th> <td>IMAP</td></tr>
|
||||
<tr><th>Mail server</th> <td>{{hostname}}</td>
|
||||
<tr><th>IMAP Port</th> <td>993</td></tr>
|
||||
<tr><th>IMAP Security</th> <td>SSL or TLS</td></tr>
|
||||
<tr><th>SMTP Port</th> <td>465</td></tr>
|
||||
<tr><th>SMTP Security</td> <td>SSL or TLS</td></tr>
|
||||
<tr><th>Username:</th> <td>Your whole email address.</td></tr>
|
||||
<tr><th>Password:</th> <td>Your mail password.</td></tr>
|
||||
<tr>
|
||||
<th>Protocol/Method</th>
|
||||
<td>IMAP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Mail server</th>
|
||||
<td>{{hostname}}</td>
|
||||
<tr>
|
||||
<th>IMAP Port</th>
|
||||
<td>993</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>IMAP Security</th>
|
||||
<td>SSL or TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SMTP Port</th>
|
||||
<td>465</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SMTP Security</td>
|
||||
<td>SSL or TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Username:</th>
|
||||
<td>Your whole email address.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Password:</th>
|
||||
<td>Your mail password.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide" onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
||||
<p>In addition to setting up your email, you’ll also need to set up <a href="#sync_guide"
|
||||
onclick="return show_panel(this);">contacts and calendar synchronization</a> separately.</p>
|
||||
|
||||
<p>As an alternative to IMAP you can also use the POP protocol: choose POP as the protocol, port 995, and SSL or TLS security in your mail client. The SMTP settings and usernames and passwords remain the same. However, we recommend you use IMAP instead.</p>
|
||||
<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>
|
||||
|
||||
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as described above. If you encounter any problems, please use the manual settings above.</p>
|
||||
<p>On iOS devices, devices on this <a href="https://wiki.z-hub.io/display/ZP/Compatibility">compatibility
|
||||
list</a>, or using Outlook 2007 or later on Windows 7 and later, you may set up your mail as an
|
||||
Exchange or ActiveSync server. However, we’ve found this to be more buggy than using IMAP as
|
||||
described above. If you encounter any problems, please use the manual settings above.</p>
|
||||
|
||||
<table class="table">
|
||||
<tr><th>Server</th> <td>{{hostname}}</td></tr>
|
||||
<tr><th>Options</th> <td>Secure Connection</td></tr>
|
||||
<tr>
|
||||
<th>Server</th>
|
||||
<td>{{hostname}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Options</th>
|
||||
<td>Secure Connection</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this method.</p>
|
||||
<p>Your device should also provide a contacts list and calendar that syncs to this box when you use this
|
||||
method.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-5">
|
||||
|
@ -60,13 +108,24 @@
|
|||
</div>
|
||||
<div class="panel-body">
|
||||
<h4>Greylisting</h4>
|
||||
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially rejecting mail from people you haven’t received mail from before. Legitimate mail servers will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this. If you are waiting for an email from someone new, such as if you are registering on a new website and are waiting for an email confirmation, please be aware there will be a minimum of 3 minutes delay, depending how soon the remote server attempts redelivery.</p>
|
||||
<p>Your box uses a technique called greylisting to cut down on spam. Greylisting works by initially
|
||||
rejecting mail from people you haven’t received mail from before. Legitimate mail servers
|
||||
will attempt redelivery shortly afterwards, but the vast majority of spam gets tricked by this.
|
||||
If you are waiting for an email from someone new, such as if you are registering on a new
|
||||
website and are waiting for an email confirmation, please be aware there will be a minimum of 3
|
||||
minutes delay, depending how soon the remote server attempts redelivery.</p>
|
||||
|
||||
<h4>+tag addresses</h4>
|
||||
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email address is <code>you@yourdomain.com</code>, you’ll also automatically get mail sent to <code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail for your own filtering rules without having to create aliases in this control panel.</p>
|
||||
<p>Every incoming email address also receives mail for <code>+tag</code> addresses. If your email
|
||||
address is <code>you@yourdomain.com</code>, you’ll also automatically get mail sent to
|
||||
<code>you+anythinghere@yourdomain.com</code>. Use this as a fast way to segment incoming mail
|
||||
for your own filtering rules without having to create aliases in this control panel.</p>
|
||||
|
||||
<h4>Use only this box to send as you</h4>
|
||||
<p>Your box sets strict email sending policies for your domain names to make it harder for spam and other fraudulent mail to claim to be you. Only this machine is authorized to send email on behalf of your domain names. If you use any other service to send email as you, it will likely get spam filtered by recipients.</p>
|
||||
<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>
|
||||
|
|
|
@ -41,7 +41,8 @@ authenticator app (usually on your phone) when you log into this control panel.<
|
|||
Enabling two-factor authentication does not protect access to your email
|
||||
</div>
|
||||
<div class="card-body bg-light">
|
||||
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to
|
||||
Enabling two-factor authentication on this page only limits access to this control panel. Remember that most
|
||||
websites allow you to
|
||||
reset your password by checking your email, so anyone with access to your email can typically take over
|
||||
your other accounts. Additionally, if your email address or any alias that forwards to your email
|
||||
address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@,
|
||||
|
@ -58,11 +59,13 @@ and ensure every administrator account for this control panel does the same.</st
|
|||
|
||||
<div class="row gx-5">
|
||||
<div class="col-12 col-lg-6">
|
||||
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||
<p><b>1.</b> Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a
|
||||
href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
|
||||
other two-factor authentication app</a> that supports TOTP.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into the app:</p>
|
||||
<p style="margin-bottom: 0"><b>2.</b> Scan the QR code in the app or directly enter the secret into
|
||||
the app:</p>
|
||||
<div id="totp-setup-qr">
|
||||
<img class="mt-3 mb-3 ms-auto me-auto" id="twofactor-qrimg">
|
||||
<div class="input-group">
|
||||
|
@ -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="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" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit code and enter it here:</label>
|
||||
<label for="otp" style="font-weight: normal"><b>4.</b> Use the app to generate your first six-digit
|
||||
code and enter it here:</label>
|
||||
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -88,16 +93,19 @@ and ensure every administrator account for this control panel does the same.</st
|
|||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="disable-2fa">
|
||||
<div>
|
||||
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span class="font-monospace"></span></span>.</p>
|
||||
<p>Two-factor authentication is active for your account<span id="mfa-device-label"> on device <span
|
||||
class="font-monospace"></span></span>.</p>
|
||||
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||
</div>
|
||||
<small>You will have to log into the admin panel again after disabling two-factor authentication.</small>
|
||||
|
|
|
@ -88,7 +88,8 @@ copy and paste this block in the area below
|
|||
</pre>
|
||||
</p>
|
||||
<div class="form-floating col-12 col-xl-6 mb-3">
|
||||
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----
stuff here
-----END PGP PUBLIC KEY BLOCK-----"></textarea>
|
||||
<textarea id="pgp_paste_key" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----
stuff here
-----END PGP PUBLIC KEY BLOCK-----"></textarea>
|
||||
<label for="pgp_paste_key">Paste your PGP public key here</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="importkey()">Import Key</button>
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
<label for="use_relay" class="input-group-text"><b>Use Relay?</b></label>
|
||||
<div class="input-group-text">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false onclick="checkfields();">
|
||||
<input type="checkbox" role="switch" id="use_relay" class="form-check-input" value=false
|
||||
onclick="checkfields();">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -45,7 +46,8 @@
|
|||
<label class="input-group-text">Password/Key</label>
|
||||
<input type="password" class="form-control" id="relay_auth_pass" placeholder="">
|
||||
</div>
|
||||
<p class="small">If you've already set up a relay before on this box, you can leave this field blank if you don't want to change it's password.</p>
|
||||
<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>
|
||||
|
||||
|
|
|
@ -14,20 +14,25 @@
|
|||
|
||||
<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’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’s hostname ({{hostname}}) and every other domain name and subdomain
|
||||
that this box is hosting a website for (see the list below).</p>
|
||||
|
||||
<div id="ssl_provision">
|
||||
<h3>Provision certificates</h3>
|
||||
|
||||
<div id="ssl_provision_p" style="display: none; margin-top: 1.5em">
|
||||
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:</p>
|
||||
<p>A TLS certificate can be automatically provisioned from <a href="https://letsencrypt.org/"
|
||||
target="_blank">Let’s Encrypt</a>, a free TLS certificate provider, for:</p>
|
||||
<ul class="text-primary"></ul>
|
||||
|
||||
<div class="container input-group mt-3" style="overflow-x: auto;">
|
||||
<button id="ssl_provision_button" class="btn btn-primary" onclick="return provision_tls_cert();">Provision</button>
|
||||
<label class="input-group-text" for=""><b>By provisioning the certificates, you’re agreeing to the <a href="https://letsencrypt.org/repository">Let’s Encrypt Subscriber Agreement</a>.</b></label>
|
||||
<button id="ssl_provision_button" class="btn btn-primary"
|
||||
onclick="return provision_tls_cert();">Provision</button>
|
||||
<label class="input-group-text" for=""><b>By provisioning the certificates, you’re agreeing to the <a
|
||||
href="https://letsencrypt.org/repository">Let’s Encrypt Subscriber Agreement</a>.</b></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,7 +41,8 @@
|
|||
|
||||
<h3>Certificate status</h3>
|
||||
|
||||
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed through <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a> 14 days prior to expiration.</p>
|
||||
<p style="margin-top: 1.5em">Certificates expire after a period of time. All certificates will be automatically renewed
|
||||
through <a href="https://letsencrypt.org/" target="_blank">Let’s Encrypt</a> 14 days prior to expiration.</p>
|
||||
|
||||
<table id="ssl_domains" class="table align-middle col-12" style="margin-bottom: 2em; display: none">
|
||||
<caption></caption>
|
||||
|
@ -54,7 +60,8 @@
|
|||
|
||||
<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="input-group">
|
||||
|
@ -62,7 +69,8 @@
|
|||
<select id="ssldomain" onchange="show_csr()" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<p><small>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides the one you choose above.</small></p>
|
||||
<p><small>A multi-domain or wildcard certificate will be automatically applied to any domains it is valid for besides
|
||||
the one you choose above.</small></p>
|
||||
|
||||
<div class="col-lg-10 col-xl-8">
|
||||
<div class="input-group">
|
||||
|
@ -75,7 +83,8 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p><small>This is required by some TLS certificate providers. You may just pick any if you know your TLS certificate provider doesn't require it.</small></p>
|
||||
<p><small>This is required by some TLS certificate providers. You may just pick any if you know your TLS certificate
|
||||
provider doesn't require it.</small></p>
|
||||
|
||||
<div id="csr_info" style="display: none;">
|
||||
<p>You will need to provide the certificate provider this Certificate Signing Request (CSR):</p>
|
||||
|
@ -88,16 +97,19 @@
|
|||
<hr>
|
||||
<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="form-floating col-12 col-xl-6">
|
||||
<textarea id="ssl_paste_cert" class="form-control" style="font-size:80%; font-family: monospace; height: 20em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<textarea id="ssl_paste_cert" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<label for="ssl_paste_cert" class="ms-3">TLS/SSL Certificate</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating col-12 col-xl-6">
|
||||
<textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em" placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<textarea id="ssl_paste_chain" class="form-control" style="font-size:80%; font-family: monospace; height: 20em"
|
||||
placeholder="-----BEGIN CERTIFICATE-----
stuff here
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
more stuff here
-----END CERTIFICATE-----"></textarea>
|
||||
<label for="ssl_paste_chain" class="ms-3">TLS/SSL intermediate Chain (if provided)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -13,38 +13,83 @@
|
|||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead><tr><th>For...</th> <th>Visit this URL</th></tr></thead>
|
||||
<tr><th>Contacts</td> <td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td></tr>
|
||||
<tr><th>Calendar</td> <td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td></tr>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>For...</th>
|
||||
<th>Visit this URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<th>Contacts</td>
|
||||
<td><a href="https://{{hostname}}/cloud/contacts">https://{{hostname}}/cloud/contacts</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Calendar</td>
|
||||
<td><a href="https://{{hostname}}/cloud/calendar">https://{{hostname}}/cloud/calendar</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>: your
|
||||
<p>Log in settings are the same as with <a href="#mail-guide" onclick="return show_panel(this);">mail</a>:
|
||||
your
|
||||
complete email address and your mail password.</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<h4>On your mobile device</h4>
|
||||
|
||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using Exchange/ActiveSync,
|
||||
<p>If you set up your <a href="#mail-guide" onclick="return show_panel(this);">mail</a> using
|
||||
Exchange/ActiveSync,
|
||||
your contacts and calendar may already appear on your device.</p>
|
||||
<p>Otherwise, here are some apps that can synchronize your contacts and calendar to your Android phone.</p>
|
||||
|
||||
<table class="table">
|
||||
<caption></caption>
|
||||
<thead><tr><th>For...</th> <th>Use...</th></tr></thead>
|
||||
<tr><td>Contacts and Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVx⁵</a> ($5.99; free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td></tr>
|
||||
<tr><td>Only Contacts</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync free</a> (free)</td></tr>
|
||||
<tr><td>Only Calendar</td> <td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a> ($2.99)</td></tr>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>For...</th>
|
||||
<th>Use...</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td>Contacts and Calendar</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=at.bitfire.davdroid">DAVx⁵</a> ($5.99;
|
||||
free <a href="https://f-droid.org/packages/at.bitfire.davdroid/">here</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Only Contacts</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.carddav.sync">CardDAV-Sync
|
||||
free</a> (free)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Only Calendar</td>
|
||||
<td><a href="https://play.google.com/store/apps/details?id=org.dmfs.caldav.lib">CalDAV-Sync</a>
|
||||
($2.99)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Use the following settings:</p>
|
||||
|
||||
<table class="table">
|
||||
<tr><td>Account Type</td> <td>CardDAV or CalDAV</td></tr>
|
||||
<tr><td>Server Name</td> <td>{{hostname}}</td></tr>
|
||||
<tr><td>Use SSL</td> <td>Yes</td></tr>
|
||||
<tr><td>Username</td> <td>Your complete email address.</td></tr>
|
||||
<tr><td>Password</td> <td>Your mail password.</td></tr>
|
||||
<tr>
|
||||
<td>Account Type</td>
|
||||
<td>CardDAV or CalDAV</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server Name</td>
|
||||
<td>{{hostname}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use SSL</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Username</td>
|
||||
<td>Your complete email address.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Password</td>
|
||||
<td>Your mail password.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
<style>
|
||||
#backup-status tr.full-backup td { font-weight: bold; }
|
||||
#backup-status tr.full-backup td {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
||||
|
@ -25,8 +28,11 @@
|
|||
<!-- LOCAL BACKUP -->
|
||||
<div class="form-group backup-target-local">
|
||||
<div class="col-lg-10 col-xl-8 mb-3">
|
||||
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are encrypted, so they are safe to store anywhere.</p>
|
||||
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files.</p>
|
||||
<p>Backups are stored on this machine’s own hard disk. You are responsible for periodically using SFTP (FTP
|
||||
over SSH) to copy the backup files from <tt class="backup-location"></tt> to a safe location. These files are
|
||||
encrypted, so they are safe to store anywhere.</p>
|
||||
<p>Separately copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure
|
||||
location. You will need this file to decrypt backup files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RSYNC BACKUP -->
|
||||
|
@ -35,7 +41,8 @@
|
|||
|
||||
<p>Backups synced to a remote machine using rsync over SSH, with local
|
||||
copies in <tt class="backup-location"></tt>. These files are encrypted, so
|
||||
they are safe to store anywhere.</p> <p>Separately copy the encryption
|
||||
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>
|
||||
|
||||
|
@ -64,7 +71,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-rsync">
|
||||
<label for="ssh-pub-key" class="col-sm-2 control-label"><h4>Public SSH Key</h4></label>
|
||||
<label for="ssh-pub-key" class="col-sm-2 control-label">
|
||||
<h4>Public SSH Key</h4>
|
||||
</label>
|
||||
<textarea class="form-control font-monospace" id="ssh-pub-key" style="min-height: 12em;" readonly></textarea>
|
||||
<div class="small" style="margin-top: 2px">
|
||||
Copy the Public SSH Key above, and paste it within the <tt>~/.ssh/authorized_keys</tt>
|
||||
|
@ -75,7 +84,8 @@
|
|||
<!-- S3 BACKUP -->
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<p>Backups are stored in an S3-compatible bucket. You must have an AWS or other S3 service account already.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||
secure location. You will need this file to decrypt backup files. It is <b>NOT</b> stored in your S3 bucket.</p>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
|
@ -97,7 +107,8 @@
|
|||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
<div class="input-group">
|
||||
<label for="backup-target-s3-path" class="input-group-text">S3 Path</label>
|
||||
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control" id="backup-target-s3-path">
|
||||
<input type="text" placeholder="your-bucket-name/backup-directory" class="form-control"
|
||||
id="backup-target-s3-path">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-s3">
|
||||
|
@ -114,8 +125,11 @@
|
|||
</div>
|
||||
<!-- Backblaze -->
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2 bucket. You must have a Backblaze account already.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.</p>
|
||||
<p>Backups are stored in a <a href="https://www.backblaze.com/" target="_blank" rel="noreferrer">Backblaze</a> B2
|
||||
bucket. You must have a Backblaze account already.</p>
|
||||
<p>You MUST manually copy the encryption password from <tt class="backup-encpassword-file"></tt> to a safe and
|
||||
secure location. You will need this file to decrypt backup files. It is NOT stored in your Backblaze B2 bucket.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-lg-10 col-xl-8 mb-3 backup-target-b2">
|
||||
<div class="input-group">
|
||||
|
@ -143,7 +157,10 @@
|
|||
<label class="input-group-text" for="backup-min-age">Retention Days</label>
|
||||
<input type="number" class="form-control" id="backup-min-age">
|
||||
</div>
|
||||
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div>
|
||||
<div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an
|
||||
incremental backup most nights, which requires that previous backups back to the most recent full backup be
|
||||
preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when
|
||||
the incremental backup data size exceeds a limit.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
|
@ -153,7 +170,8 @@
|
|||
|
||||
<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">
|
||||
<caption></caption>
|
||||
|
@ -169,8 +187,10 @@
|
|||
|
||||
<!-- Hide these buttons until we're sure we can use them :) -->
|
||||
<div class="row justify-content-evenly">
|
||||
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)" style="display: none;">Create Full Backup Now</button>
|
||||
<button id="create-incremental-backup-button" class="btn btn-primary col-3" onclick="do_backup(false)" style="display: none;">Create Incremental Backup Now</button>
|
||||
<button id="create-full-backup-button" class="btn btn-primary col-3" onclick="do_backup(true)"
|
||||
style="display: none;">Create Full Backup Now</button>
|
||||
<button id="create-incremental-backup-button" class="btn btn-primary col-3" onclick="do_backup(false)"
|
||||
style="display: none;">Create Incremental Backup Now</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
|
@ -4,9 +4,11 @@
|
|||
#system-checks .message {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#system-checks .icon {
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
#system-checks .heading {
|
||||
font-weight: bold;
|
||||
font-size: 180%;
|
||||
|
@ -21,9 +23,11 @@
|
|||
.status-error .fas {
|
||||
color: rgb(190, 0, 0);
|
||||
}
|
||||
|
||||
.status-error.sym {
|
||||
color: rgb(190, 0, 0);
|
||||
}
|
||||
|
||||
.status-error .status-text {
|
||||
color: rgb(70, 0, 0);
|
||||
}
|
||||
|
@ -31,9 +35,11 @@
|
|||
.status-warning .fas {
|
||||
color: rgb(191, 150, 0);
|
||||
}
|
||||
|
||||
.status-warning.sym {
|
||||
color: rgb(191, 150, 0);
|
||||
}
|
||||
|
||||
.status-warning .status-text {
|
||||
color: rgb(69, 54, 0);
|
||||
}
|
||||
|
@ -41,9 +47,11 @@
|
|||
.status-ok .fas {
|
||||
color: rgb(0, 190, 0);
|
||||
}
|
||||
|
||||
.status-ok.sym {
|
||||
color: rgb(0, 190, 0);
|
||||
}
|
||||
|
||||
.status-ok .status-text {
|
||||
color: rgb(0, 70, 0);
|
||||
}
|
||||
|
@ -51,9 +59,11 @@
|
|||
.status-na .fas {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
||||
.status-na.sym {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
||||
.status-na .status-text {
|
||||
color: rgb(100, 100, 100);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,27 @@
|
|||
<h2>Users</h2>
|
||||
|
||||
<style>
|
||||
#user_table tr.account_inactive td.address { color: #888; text-decoration: line-through; }
|
||||
#user_table .actions { margin-top: .33em; font-size: 95%; }
|
||||
#user_table .account_inactive .if_active { display: none; }
|
||||
#user_table .account_active .if_inactive { display: none; }
|
||||
#user_table .account_active.if_inactive { display: none; }
|
||||
#user_table tr.account_inactive td.address {
|
||||
color: #888;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
#user_table .actions {
|
||||
margin-top: .33em;
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
#user_table .account_inactive .if_active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#user_table .account_active .if_inactive {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#user_table .account_active.if_inactive {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h3>Add a mail user</h3>
|
||||
|
@ -18,13 +34,15 @@
|
|||
<div class="col-12 col-lg-6">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserEmail">Email address</label>
|
||||
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail" placeholder="me@example.com">
|
||||
<input type="email" class="form-control" style="min-width: 15em;" id="adduserEmail"
|
||||
placeholder="me@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="input-group">
|
||||
<label class="input-group-text" for="adduserPassword">Password</label>
|
||||
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword" placeholder="Password">
|
||||
<input type="password" class="form-control" style="min-width: 10em;" id="adduserPassword"
|
||||
placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -48,11 +66,15 @@
|
|||
</div>
|
||||
|
||||
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
|
||||
<li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
|
||||
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
|
||||
<li>Passwords must be at least eight characters. If you're out of ideas, you can <a href="#"
|
||||
onclick="return generate_random_password()">generate a random password</a>.</li>
|
||||
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to
|
||||
existing accounts.</li>
|
||||
<li>Administrators get access to this control panel.</li>
|
||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#" onclick="return show_panel('aliases');">aliases</a> can.</li>
|
||||
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are allowed. For unlimited storage enter 0 (zero)</li>
|
||||
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#"
|
||||
onclick="return show_panel('aliases');">aliases</a> can.</li>
|
||||
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are
|
||||
allowed. For unlimited storage enter 0 (zero)</li>
|
||||
</ul>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
|
@ -113,7 +135,8 @@
|
|||
</tr>
|
||||
<tr id="user-extra-template" class="if_inactive">
|
||||
<td colspan="3" style="border: 0; padding-top: 0">
|
||||
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
||||
<div class='restore_info' style='color: #888; font-size: 90%'>To restore account, create a new account with this
|
||||
email address. Or to permanently delete the mailbox, delete the directory <tt></tt> on the machine.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
@ -135,24 +158,44 @@
|
|||
|
||||
<table class="table" style="margin-top: .5em">
|
||||
<caption></caption>
|
||||
<thead><th>Verb</th> <th>Action</th><th></th></thead>
|
||||
<tr><td><b>GET</b</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
|
||||
<thead>
|
||||
<th>Verb</th>
|
||||
<th>Action</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tr>
|
||||
<td><b>GET</b< /td>
|
||||
<td><i>(none)</i></td>
|
||||
<td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded
|
||||
results.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b< /td>
|
||||
<td class="font-monospace">/add</td>
|
||||
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional parameters: <code>privilege=admin</code> and <code>quota</code></td>
|
||||
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional
|
||||
parameters: <code>privilege=admin</code> and <code>quota</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/remove</td>
|
||||
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
|
||||
</tr>
|
||||
<tr><td><b>POST</b></td><td class="font-monospace">/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
|
||||
<tr><td><b>POST</b></td><td class="font-monospace">/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/privileges/add</td>
|
||||
<td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and
|
||||
<code>privilege=admin</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
<td class="font-monospace">/privileges/remove</td>
|
||||
<td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>GET</b></td>
|
||||
<td class="font-monospace">/quota</td>
|
||||
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result</td>
|
||||
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>POST</b></td>
|
||||
|
@ -163,7 +206,8 @@
|
|||
|
||||
<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
|
||||
curl -X GET https://{{hostname}}/admin/mail/users?format=json
|
||||
|
|
|
@ -3,22 +3,33 @@
|
|||
|
||||
<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>
|
||||
|
||||
<p>You can replace the default website with your own HTML pages and other static files. This control panel won’t help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:</p>
|
||||
<p>You can replace the default website with your own HTML pages and other static files. This control panel won’t
|
||||
help you design a website, but once you have <tt>.html</tt> files you can upload them following these instructions:
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
<li>Ensure that any domains you are publishing a website for have no problems on the <a href="#system_status" onclick="return show_panel(this);">Status Checks</a> page.</li>
|
||||
<li>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;">
|
||||
<caption></caption>
|
||||
|
@ -32,7 +43,10 @@
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>To add a domain to this table, create a dummy <a href="#users" onclick="return show_panel(this);">mail user</a> or <a href="#aliases" onclick="return show_panel(this);">alias</a> on the domain first and see the <a href="https://mailinabox.email/guide.html#domain-name-configuration">setup guide</a> for adding nameserver records to the new domain at your registrar (but <i>not</i> glue records).</p>
|
||||
<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>
|
||||
|
||||
|
|
|
@ -59,7 +59,8 @@
|
|||
|
||||
<p>WKD (<b>W</b>eb <b>K</b>ey <b>D</b>irectory) is an
|
||||
<b><a href="https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html">experimental feature</a></b>
|
||||
that allows users to authoratively publish their public PGP keys on the web, via HTTPS.</p>
|
||||
that allows users to authoratively publish their public PGP keys on the web, via HTTPS.
|
||||
</p>
|
||||
<p>Unlike other solutions (like public keyservers), WKD has the advantage that the owner
|
||||
of the domain has some degree of control over what keys are published and as such there
|
||||
is more certainity that the key actually belongs to it's owner.</p>
|
||||
|
@ -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.
|
||||
<br>
|
||||
This box uses the Advanced Method to serve the keys. For example, to host your <code>@some.example.com</code> keys,
|
||||
you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's records.
|
||||
you can add a A, AAAA or CNAME record for <code>openpgpkey.some.example.com</code>. It will override the box's
|
||||
records.
|
||||
<br><br>
|
||||
There's not a way to "disable" WKD at the moment - but if you don't want to publish keys, or if you want to use the
|
||||
Direct method somewhere else, you can always set the records to an unreachable destination
|
||||
|
@ -89,7 +91,8 @@
|
|||
<label for="wkd-show-all-entries" class="input-group-text"><b>Show all email addresses</b></label>
|
||||
<div class="input-group-text">
|
||||
<div class="form-switch">
|
||||
<input type="checkbox" role="switch" id="wkd-show-all-entries" class="form-check-input" value=false onclick="toggle_emails_with_no_pgp_key();">
|
||||
<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>
|
||||
|
|
|
@ -6,47 +6,58 @@ import os.path
|
|||
|
||||
# THE ENVIRONMENT FILE AT /etc/mailinabox.conf
|
||||
|
||||
|
||||
def load_environment():
|
||||
# Load settings from /etc/mailinabox.conf.
|
||||
return load_env_vars_from_file("/etc/mailinabox.conf")
|
||||
|
||||
|
||||
def load_env_vars_from_file(fn):
|
||||
# Load settings from a KEY=VALUE file.
|
||||
import collections
|
||||
env = collections.OrderedDict()
|
||||
for line in open(fn): env.setdefault(*line.strip().split("=", 1))
|
||||
for line in open(fn):
|
||||
env.setdefault(*line.strip().split("=", 1))
|
||||
return env
|
||||
|
||||
|
||||
def save_environment(env):
|
||||
with open("/etc/mailinabox.conf", "w") as f:
|
||||
for k, v in env.items():
|
||||
f.write("%s=%s\n" % (k, v))
|
||||
|
||||
|
||||
# THE SETTINGS FILE AT STORAGE_ROOT/settings.yaml.
|
||||
|
||||
|
||||
def write_settings(config, env):
|
||||
import rtyaml
|
||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||
with open(fn, "w") as f:
|
||||
f.write(rtyaml.dump(config))
|
||||
|
||||
|
||||
def load_settings(env):
|
||||
import rtyaml
|
||||
fn = os.path.join(env['STORAGE_ROOT'], 'settings.yaml')
|
||||
try:
|
||||
config = rtyaml.load(open(fn, "r"))
|
||||
if not isinstance(config, dict): raise ValueError() # caught below
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError() # caught below
|
||||
return config
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
# UTILITIES
|
||||
|
||||
|
||||
def safe_domain_name(name):
|
||||
# Sanitize a domain name so it is safe to use as a file name on disk.
|
||||
import urllib.parse
|
||||
return urllib.parse.quote(name, safe='')
|
||||
|
||||
|
||||
def sort_domains(domain_names, env):
|
||||
# Put domain names in a nice sorted order.
|
||||
|
||||
|
@ -68,17 +79,20 @@ def sort_domains(domain_names, env):
|
|||
zones[domain] = domain
|
||||
|
||||
# Sort the zones.
|
||||
zone_domains = sorted(zones.values(),
|
||||
zone_domains = sorted(
|
||||
zones.values(),
|
||||
key=lambda d: (
|
||||
# PRIMARY_HOSTNAME or the zone that contains it is always first.
|
||||
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].endswith("." + d)),
|
||||
not (d == env['PRIMARY_HOSTNAME'] or env['PRIMARY_HOSTNAME'].
|
||||
endswith("." + d)),
|
||||
|
||||
# Then just dumb lexicographically.
|
||||
d,
|
||||
))
|
||||
|
||||
# Now sort the domain names that fall within each zone.
|
||||
domain_names = sorted(domain_names,
|
||||
domain_names = sorted(
|
||||
domain_names,
|
||||
key=lambda d: (
|
||||
# First by zone.
|
||||
zone_domains.index(zones[d]),
|
||||
|
@ -95,18 +109,28 @@ def sort_domains(domain_names, env):
|
|||
|
||||
return domain_names
|
||||
|
||||
|
||||
def sort_email_addresses(email_addresses, env):
|
||||
email_addresses = set(email_addresses)
|
||||
domains = set(email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||
domains = set(
|
||||
email.split("@", 1)[1] for email in email_addresses if "@" in email)
|
||||
ret = []
|
||||
for domain in sort_domains(domains, env):
|
||||
domain_emails = set(email for email in email_addresses if email.endswith("@" + domain))
|
||||
domain_emails = set(email for email in email_addresses
|
||||
if email.endswith("@" + domain))
|
||||
ret.extend(sorted(domain_emails))
|
||||
email_addresses -= domain_emails
|
||||
ret.extend(sorted(email_addresses)) # whatever is left
|
||||
return ret
|
||||
|
||||
def 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.
|
||||
# Some processes like apt-get require being given a sane PATH.
|
||||
import subprocess
|
||||
|
@ -128,18 +152,21 @@ def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, tr
|
|||
except subprocess.CalledProcessError as e:
|
||||
ret = e.output
|
||||
code = e.returncode
|
||||
if not return_bytes and isinstance(ret, bytes): ret = ret.decode("utf8")
|
||||
if not return_bytes and isinstance(ret, bytes):
|
||||
ret = ret.decode("utf8")
|
||||
if not trap:
|
||||
return ret
|
||||
else:
|
||||
return code, ret
|
||||
|
||||
|
||||
def create_syslog_handler():
|
||||
import logging.handlers
|
||||
handler = logging.handlers.SysLogHandler(address='/dev/log')
|
||||
handler.setLevel(logging.WARNING)
|
||||
return handler
|
||||
|
||||
|
||||
def du(path):
|
||||
# Computes the size of all files in the path, like the `du` command.
|
||||
# Based on http://stackoverflow.com/a/17936789. Takes into account
|
||||
|
@ -159,10 +186,12 @@ def du(path):
|
|||
total_size += stat.st_size
|
||||
return total_size
|
||||
|
||||
|
||||
def wait_for_service(port, public, env, timeout):
|
||||
# Block until a service on a given port (bound privately or publicly)
|
||||
# is taking connections, with a maximum timeout.
|
||||
import socket, time
|
||||
import socket
|
||||
import time
|
||||
start = time.perf_counter()
|
||||
while True:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
|
@ -175,6 +204,7 @@ def wait_for_service(port, public, env, timeout):
|
|||
return False
|
||||
time.sleep(min(timeout / 4, 1))
|
||||
|
||||
|
||||
def fix_boto():
|
||||
# Google Compute Engine instances install some Python-2-only boto plugins that
|
||||
# conflict with boto running under Python 3. Disable boto's default configuration
|
||||
|
@ -182,12 +212,15 @@ def fix_boto():
|
|||
import os
|
||||
os.environ["BOTO_CONFIG"] = "/etc/boto3.cfg"
|
||||
|
||||
|
||||
def get_php_version():
|
||||
# Gets the version of PHP installed in the system.
|
||||
return shell("check_output", ["/usr/bin/php", "-v"])[4:7]
|
||||
|
||||
|
||||
os_codes = {None, "Debian10", "Ubuntu2004"}
|
||||
|
||||
|
||||
def get_os_code():
|
||||
# Massive mess incoming
|
||||
dist = shell("check_output", ["/usr/bin/lsb_release", "-is"]).strip()
|
||||
|
@ -204,6 +237,7 @@ def get_os_code():
|
|||
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from web_update import get_web_domains
|
||||
env = load_environment()
|
||||
|
|
|
@ -2,14 +2,20 @@
|
|||
# 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 dns_update import get_custom_dns_config, get_dns_zones
|
||||
from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate
|
||||
from utils import shell, safe_domain_name, sort_domains, get_php_version
|
||||
|
||||
def get_web_domains(env, 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?
|
||||
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:
|
||||
# 'autoconfig.' for Mozilla Thunderbird auto setup.
|
||||
# 'autodiscover.' for ActiveSync autodiscovery (Z-Push).
|
||||
domains |= set('autoconfig.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||
domains |= set('autodiscover.' + maildomain for maildomain in get_mail_domains(env, users_only=True))
|
||||
domains |= set(
|
||||
'autoconfig.' + maildomain
|
||||
for maildomain in get_mail_domains(env, users_only=True))
|
||||
domains |= set(
|
||||
'autodiscover.' + maildomain
|
||||
for maildomain in get_mail_domains(env, users_only=True))
|
||||
|
||||
# 'mta-sts.' for MTA-STS support for all domains that have email addresses.
|
||||
domains |= set('mta-sts.' + maildomain for maildomain in get_mail_domains(env))
|
||||
domains |= set('mta-sts.' + maildomain
|
||||
for maildomain in get_mail_domains(env))
|
||||
|
||||
# 'openpgpkey.' for WKD support
|
||||
domains |= set('openpgpkey.' + maildomain for maildomain in get_mail_domains(env))
|
||||
domains |= set('openpgpkey.' + maildomain
|
||||
for maildomain in get_mail_domains(env))
|
||||
|
||||
if exclude_dns_elsewhere:
|
||||
# ...Unless the domain has an A/AAAA record that maps it to a different
|
||||
|
@ -52,14 +64,17 @@ def get_web_domains(env, include_www_redirects=True, include_auto=True, exclude_
|
|||
|
||||
return domains
|
||||
|
||||
|
||||
def get_domains_with_a_records(env):
|
||||
domains = set()
|
||||
dns = get_custom_dns_config(env)
|
||||
for domain, rtype, value, ttl in dns:
|
||||
if rtype == "CNAME" or (rtype in ("A", "AAAA") and value not in ("local", env['PUBLIC_IP'])):
|
||||
if rtype == "CNAME" or (rtype in ("A", "AAAA")
|
||||
and value not in ("local", env['PUBLIC_IP'])):
|
||||
domains.add(domain)
|
||||
return domains
|
||||
|
||||
|
||||
def get_web_domains_with_root_overrides(env):
|
||||
# Load custom settings so we can tell what domains have a redirect or proxy set up on '/',
|
||||
# which means static hosting is not happening.
|
||||
|
@ -68,18 +83,22 @@ def get_web_domains_with_root_overrides(env):
|
|||
if os.path.exists(nginx_conf_custom_fn):
|
||||
custom_settings = rtyaml.load(open(nginx_conf_custom_fn))
|
||||
for domain, settings in custom_settings.items():
|
||||
for type, value in [('redirect', settings.get('redirects', {}).get('/')),
|
||||
('proxy', settings.get('proxies', {}).get('/'))]:
|
||||
for type, value in [('redirect', settings.get('redirects',
|
||||
{}).get('/')),
|
||||
('proxy', settings.get('proxies',
|
||||
{}).get('/'))]:
|
||||
if value:
|
||||
root_overrides[domain] = (type, value)
|
||||
return root_overrides
|
||||
|
||||
|
||||
DOMAIN_EXTERNAL = -1
|
||||
DOMAIN_PRIMARY = 1
|
||||
DOMAIN_WWW = 2
|
||||
DOMAIN_REDIRECT = 4
|
||||
DOMAIN_WKD = 8
|
||||
|
||||
|
||||
def get_web_domain_flags(env):
|
||||
flags = dict()
|
||||
zones = get_dns_zones(env)
|
||||
|
@ -108,19 +127,24 @@ def get_web_domain_flags(env):
|
|||
# Last check for websites hosted elsewhere
|
||||
for d in flags.keys():
|
||||
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
|
||||
|
||||
|
||||
def do_web_update(env):
|
||||
# Pre-load what SSL certificates we will use for each domain.
|
||||
ssl_certificates = get_ssl_certificates(env)
|
||||
|
||||
# Build an nginx configuration file.
|
||||
nginx_conf = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-top.conf")).read()
|
||||
nginx_conf = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-top.conf")).read()
|
||||
nginx_conf = re.sub("{{phpver}}", get_php_version(), nginx_conf)
|
||||
|
||||
# Add upstream additions
|
||||
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www", ".upstream.conf")
|
||||
nginx_upstream_include = os.path.join(env["STORAGE_ROOT"], "www",
|
||||
".upstream.conf")
|
||||
if not os.path.exists(nginx_upstream_include):
|
||||
with open(nginx_upstream_include, "a+") as f:
|
||||
f.writelines([
|
||||
|
@ -133,14 +157,23 @@ def do_web_update(env):
|
|||
nginx_conf += "\ninclude %s;\n" % (nginx_upstream_include)
|
||||
|
||||
# Load the templates.
|
||||
template0 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
template1 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-alldomains.conf")).read()
|
||||
template2 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-primaryonly.conf")).read()
|
||||
template0 = open(
|
||||
os.path.join(os.path.dirname(__file__), "../conf/nginx.conf")).read()
|
||||
template1 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-alldomains.conf")).read()
|
||||
template2 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-primaryonly.conf")).read()
|
||||
template3 = "\trewrite ^(.*) https://$REDIRECT_DOMAIN$1 permanent;\n"
|
||||
template4 = open(os.path.join(os.path.dirname(__file__), "../conf/nginx-openpgpkey.conf")).read()
|
||||
template4 = open(
|
||||
os.path.join(os.path.dirname(__file__),
|
||||
"../conf/nginx-openpgpkey.conf")).read()
|
||||
|
||||
# Add the PRIMARY_HOST configuration first so it becomes nginx's default server.
|
||||
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'], [template0, template1, template2], ssl_certificates, env)
|
||||
nginx_conf += make_domain_config(env['PRIMARY_HOSTNAME'],
|
||||
[template0, template1, template2],
|
||||
ssl_certificates, env)
|
||||
|
||||
# Add configuration all other web domains.
|
||||
pairs = list(get_web_domain_flags(env).items())
|
||||
|
@ -154,14 +187,20 @@ def do_web_update(env):
|
|||
if flags & DOMAIN_WWW == 0:
|
||||
# This is a regular domain.
|
||||
if flags & DOMAIN_WKD == DOMAIN_WKD:
|
||||
nginx_conf += make_domain_config(domain, [template0, template1, template4], ssl_certificates, env)
|
||||
nginx_conf += make_domain_config(
|
||||
domain, [template0, template1, template4],
|
||||
ssl_certificates, env)
|
||||
elif flags & DOMAIN_REDIRECT == 0:
|
||||
nginx_conf += make_domain_config(domain, [template0, template1], ssl_certificates, env)
|
||||
nginx_conf += make_domain_config(domain,
|
||||
[template0, template1],
|
||||
ssl_certificates, env)
|
||||
else:
|
||||
nginx_conf += make_domain_config(domain, [template0], ssl_certificates, env)
|
||||
nginx_conf += make_domain_config(domain, [template0],
|
||||
ssl_certificates, env)
|
||||
else:
|
||||
# Add default 'www.' redirect.
|
||||
nginx_conf += make_domain_config(domain, [template0, template3], ssl_certificates, env)
|
||||
nginx_conf += make_domain_config(domain, [template0, template3],
|
||||
ssl_certificates, env)
|
||||
|
||||
# Did the file change? If not, don't bother writing & restarting nginx.
|
||||
nginx_conf_fn = "/etc/nginx/conf.d/local.conf"
|
||||
|
@ -182,6 +221,7 @@ def do_web_update(env):
|
|||
|
||||
return "web updated\n"
|
||||
|
||||
|
||||
def make_domain_config(domain, templates, ssl_certificates, env):
|
||||
# GET SOME VARIABLES
|
||||
|
||||
|
@ -206,7 +246,9 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||
finally:
|
||||
f.close()
|
||||
return sha1.hexdigest()
|
||||
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||
|
||||
nginx_conf_extra += "\t# ssl files sha1: %s / %s\n" % (hashfile(
|
||||
tls_cert["private-key"]), hashfile(tls_cert["certificate"]))
|
||||
|
||||
# Add in any user customizations in YAML format.
|
||||
hsts = "yes"
|
||||
|
@ -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}\n"
|
||||
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
|
||||
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"
|
||||
|
||||
# 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):
|
||||
with open(nginx_conf_custom_include, "a+") as f:
|
||||
f.writelines([
|
||||
|
@ -280,57 +325,75 @@ def make_domain_config(domain, templates, ssl_certificates, env):
|
|||
# of the previous template.
|
||||
nginx_conf = "# ADDITIONAL DIRECTIVES HERE\n"
|
||||
for t in templates + [nginx_conf_extra]:
|
||||
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t, nginx_conf)
|
||||
nginx_conf = re.sub("[ \t]*# ADDITIONAL DIRECTIVES HERE *\n", t,
|
||||
nginx_conf)
|
||||
|
||||
# Replace substitution strings in the template & return.
|
||||
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
|
||||
nginx_conf = nginx_conf.replace("$HOSTNAME", domain)
|
||||
nginx_conf = nginx_conf.replace("$ROOT", root)
|
||||
nginx_conf = nginx_conf.replace("$SSL_KEY", tls_cert["private-key"])
|
||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", tls_cert["certificate"])
|
||||
nginx_conf = nginx_conf.replace("$REDIRECT_DOMAIN", re.sub(r"^www\.", "", domain)) # for default www redirects to parent domain
|
||||
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE",
|
||||
tls_cert["certificate"])
|
||||
nginx_conf = nginx_conf.replace(
|
||||
"$REDIRECT_DOMAIN",
|
||||
re.sub(r"^www\.", "",
|
||||
domain)) # for default www redirects to parent domain
|
||||
|
||||
return nginx_conf
|
||||
|
||||
|
||||
def get_web_root(domain, env, test_exists=True):
|
||||
# Try STORAGE_ROOT/web/domain_name if it exists, but fall back to STORAGE_ROOT/web/default.
|
||||
for test_domain in (domain, 'default'):
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(test_domain))
|
||||
if os.path.exists(root) or not test_exists: break
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www",
|
||||
safe_domain_name(test_domain))
|
||||
if os.path.exists(root) or not test_exists:
|
||||
break
|
||||
return root
|
||||
|
||||
|
||||
def is_default_web_root(domain, env):
|
||||
root = os.path.join(env["STORAGE_ROOT"], "www", safe_domain_name(domain))
|
||||
return not os.path.exists(root)
|
||||
|
||||
|
||||
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))
|
||||
ssl_certificates = get_ssl_certificates(env)
|
||||
|
||||
# for the SSL config panel, get cert status
|
||||
def check_cert(domain):
|
||||
try:
|
||||
tls_cert = get_domain_ssl_files(domain, ssl_certificates, env, allow_missing_cert=True)
|
||||
tls_cert = get_domain_ssl_files(domain,
|
||||
ssl_certificates,
|
||||
env,
|
||||
allow_missing_cert=True)
|
||||
except OSError: # PRIMARY_HOSTNAME cert is missing
|
||||
tls_cert = None
|
||||
if tls_cert is None: return ("danger", "No certificate installed.")
|
||||
cert_status, cert_status_details = check_certificate(domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||
if tls_cert is None:
|
||||
return ("danger", "No certificate installed.")
|
||||
cert_status, cert_status_details = check_certificate(
|
||||
domain, tls_cert["certificate"], tls_cert["private-key"])
|
||||
if cert_status == "OK":
|
||||
return ("success", "Signed & valid. " + cert_status_details)
|
||||
elif cert_status == "SELF-SIGNED":
|
||||
return ("warning", "Self-signed. Get a signed certificate to stop warnings.")
|
||||
return ("warning",
|
||||
"Self-signed. Get a signed certificate to stop warnings.")
|
||||
else:
|
||||
return ("danger", "Certificate has a problem: " + cert_status)
|
||||
|
||||
return [
|
||||
{
|
||||
"domain": domain,
|
||||
"root": get_web_root(domain, env),
|
||||
"custom_root": get_web_root(domain, env, test_exists=False),
|
||||
"ssl_certificate": check_cert(domain),
|
||||
"static_enabled": domain not in (www_redirects | has_root_proxy_or_redirect),
|
||||
}
|
||||
for domain in get_web_domains(env)
|
||||
]
|
||||
|
||||
return [{
|
||||
"domain":
|
||||
domain,
|
||||
"root":
|
||||
get_web_root(domain, env),
|
||||
"custom_root":
|
||||
get_web_root(domain, env, test_exists=False),
|
||||
"ssl_certificate":
|
||||
check_cert(domain),
|
||||
"static_enabled":
|
||||
domain not in (www_redirects | has_root_proxy_or_redirect),
|
||||
} for domain in get_web_domains(env)]
|
||||
|
|
|
@ -2,7 +2,14 @@
|
|||
# WDK (Web Key Directory) Manager: Facilitates discovery of keys by third-parties
|
||||
# Current relevant documents: https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html
|
||||
|
||||
import pgp, 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.backends import default_backend
|
||||
|
||||
|
@ -10,21 +17,25 @@ env = utils.load_environment()
|
|||
|
||||
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
|
||||
|
||||
|
||||
class WKDError(Exception):
|
||||
"""
|
||||
Errors specifically related to WKD.
|
||||
"""
|
||||
|
||||
def __init__(self, msg):
|
||||
self.message = msg
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
def sha1(message):
|
||||
h = hashes.Hash(hashes.SHA1(), default_backend())
|
||||
h.update(message)
|
||||
return h.finalize()
|
||||
|
||||
|
||||
def zbase32(digest):
|
||||
# Crudely check if all quintets are complete
|
||||
if len(digest) % 5 != 0:
|
||||
|
@ -57,11 +68,7 @@ def strip_and_export(fpr, target_email, buffer=None, context=None):
|
|||
return None
|
||||
|
||||
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
|
||||
statusref = {
|
||||
"seq_read": False,
|
||||
"sequence": [],
|
||||
"seq_number": -1
|
||||
}
|
||||
statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
|
||||
|
||||
def parse_key_dump(dump):
|
||||
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["seq_read"] = True
|
||||
|
||||
|
||||
def interaction(request, prompt):
|
||||
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
|
||||
return 0
|
||||
|
@ -108,6 +114,7 @@ def strip_and_export(fpr, target_email, buffer=None, context=None):
|
|||
context.interact(k, interaction, sink=buffer)
|
||||
return pgp.export_key(fpr, context)
|
||||
|
||||
|
||||
def email_compatible_with_key(email, fingerprint):
|
||||
# 1. Does the user exist?
|
||||
if not email in mailconfig.get_all_mail_addresses(env):
|
||||
|
@ -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?
|
||||
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 None
|
||||
|
||||
|
||||
# Gets a table with all the keys that can be served for each user and/or alias
|
||||
|
||||
|
||||
def get_user_fpr_maps():
|
||||
uk_maps = {}
|
||||
for email in mailconfig.get_all_mail_addresses(env):
|
||||
|
@ -141,7 +153,10 @@ def get_user_fpr_maps():
|
|||
pass
|
||||
return uk_maps
|
||||
|
||||
|
||||
# Gets the current WKD configuration
|
||||
|
||||
|
||||
def get_wkd_config():
|
||||
# Test
|
||||
try:
|
||||
|
@ -159,9 +174,12 @@ def get_wkd_config():
|
|||
except:
|
||||
return {}
|
||||
|
||||
|
||||
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
|
||||
# email: An user or alias on this box. e.g. "administrator@example.com"
|
||||
# fingerprint: The fingerprint of the key we want to bind it to. e.g "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
|
||||
|
||||
def update_wkd_config(config_sample):
|
||||
config = dict(config_sample)
|
||||
for email, fingerprint in config_sample.items():
|
||||
|
@ -177,8 +195,11 @@ def update_wkd_config(config_sample):
|
|||
with open(wkdpath, "w") as wkdfile:
|
||||
wkdfile.write(rtyaml.dump(config))
|
||||
|
||||
|
||||
# Looks for incompatible email/key pairs on the WKD configuration file
|
||||
# and returns the uid indexes for compatible email/key pairs
|
||||
|
||||
|
||||
def parse_wkd_list():
|
||||
removed = []
|
||||
uidlist = []
|
||||
|
@ -198,7 +219,8 @@ def parse_wkd_list():
|
|||
key = email_compatible_with_key(u, k)
|
||||
# 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))
|
||||
except:
|
||||
writeable.pop(u)
|
||||
|
@ -208,8 +230,10 @@ def parse_wkd_list():
|
|||
wkdfile.write(rtyaml.dump(writeable))
|
||||
return (removed, uidlist)
|
||||
|
||||
|
||||
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
|
||||
|
||||
|
||||
def build_wkd():
|
||||
# Clean everything
|
||||
try:
|
||||
|
|
Loading…
Reference in a new issue