mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-24 02:37:05 +00:00
move management into a daemon service running as root
* Created a new Python/flask-based management daemon. * Moved the mail user management core code from tools/mail.py to the new daemon. * tools/mail.py is a wrapper around the daemon and can be run as a non-root user. * Adding a new initscript for the management daemon. * Moving dns_update.sh to the management daemon, called via curl'ing the daemon's API. This also now runs the DNS update after mail users and aliases are added/removed, which sets up new domains' DNS as needed.
This commit is contained in:
parent
da15ae5375
commit
c54b0cbefc
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
|||||||
*~
|
*~
|
||||||
tests/__pycache__/
|
tests/__pycache__/
|
||||||
|
management/__pycache__/
|
||||||
|
tools/__pycache__/
|
||||||
|
125
conf/management-initscript
Executable file
125
conf/management-initscript
Executable file
@ -0,0 +1,125 @@
|
|||||||
|
#! /bin/sh
|
||||||
|
### BEGIN INIT INFO
|
||||||
|
# Provides: mailinabox
|
||||||
|
# Required-Start: $all
|
||||||
|
# Required-Stop: $all
|
||||||
|
# Default-Start: 2 3 4 5
|
||||||
|
# Default-Stop: 0 1 6
|
||||||
|
# Short-Description: Start and stop the Mail-in-a-Box management daemon.
|
||||||
|
# Description: Start and stop the Mail-in-a-Box management daemon.
|
||||||
|
### END INIT INFO
|
||||||
|
|
||||||
|
# Adapted from http://blog.codefront.net/2007/06/11/nginx-php-and-a-php-fastcgi-daemon-init-script/
|
||||||
|
|
||||||
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
DESC="Mail-in-a-Box Mgmt"
|
||||||
|
NAME=mailinabox
|
||||||
|
DAEMON=/usr/bin/mailinabox-daemon
|
||||||
|
PIDFILE=/var/run/$NAME.pid
|
||||||
|
SCRIPTNAME=/etc/init.d/$NAME
|
||||||
|
|
||||||
|
# Exit if the package is not installed
|
||||||
|
[ -x "$DAEMON" ] || exit 0
|
||||||
|
|
||||||
|
# Set defaults.
|
||||||
|
START=yes
|
||||||
|
EXEC_AS_USER=root
|
||||||
|
|
||||||
|
# Read configuration variable file if it is present
|
||||||
|
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
|
||||||
|
|
||||||
|
# Load the VERBOSE setting and other rcS variables
|
||||||
|
. /lib/init/vars.sh
|
||||||
|
|
||||||
|
# Define LSB log_* functions.
|
||||||
|
# Depend on lsb-base (>= 3.0-6) to ensure that this file is present.
|
||||||
|
. /lib/lsb/init-functions
|
||||||
|
|
||||||
|
# If the daemon is not enabled, give the user a warning and then exit,
|
||||||
|
# unless we are stopping the daemon
|
||||||
|
if [ "$START" != "yes" -a "$1" != "stop" ]; then
|
||||||
|
log_warning_msg "To enable $NAME, edit /etc/default/$NAME and set START=yes"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Process configuration
|
||||||
|
#export ...
|
||||||
|
DAEMON_ARGS=""
|
||||||
|
|
||||||
|
|
||||||
|
do_start()
|
||||||
|
{
|
||||||
|
# Return
|
||||||
|
# 0 if daemon has been started
|
||||||
|
# 1 if daemon was already running
|
||||||
|
# 2 if daemon could not be started
|
||||||
|
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
|
||||||
|
|| return 1
|
||||||
|
start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON \
|
||||||
|
--background --make-pidfile --chuid $EXEC_AS_USER --startas $DAEMON -- \
|
||||||
|
$DAEMON_ARGS \
|
||||||
|
|| return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
do_stop()
|
||||||
|
{
|
||||||
|
# Return
|
||||||
|
# 0 if daemon has been stopped
|
||||||
|
# 1 if daemon was already stopped
|
||||||
|
# 2 if daemon could not be stopped
|
||||||
|
# other if a failure occurred
|
||||||
|
start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE > /dev/null # --name $DAEMON
|
||||||
|
RETVAL="$?"
|
||||||
|
[ "$RETVAL" = 2 ] && return 2
|
||||||
|
# Wait for children to finish too if this is a daemon that forks
|
||||||
|
# and if the daemon is only ever run from this initscript.
|
||||||
|
# If the above conditions are not satisfied then add some other code
|
||||||
|
# that waits for the process to drop all resources that could be
|
||||||
|
# needed by services started subsequently. A last resort is to
|
||||||
|
# sleep for some time.
|
||||||
|
start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
|
||||||
|
[ "$?" = 2 ] && return 2
|
||||||
|
# Many daemons don't delete their pidfiles when they exit.
|
||||||
|
rm -f $PIDFILE
|
||||||
|
return "$RETVAL"
|
||||||
|
}
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
[ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
|
||||||
|
do_start
|
||||||
|
case "$?" in
|
||||||
|
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||||
|
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
[ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
|
||||||
|
do_stop
|
||||||
|
case "$?" in
|
||||||
|
0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
|
||||||
|
2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
restart|force-reload)
|
||||||
|
log_daemon_msg "Restarting $DESC" "$NAME"
|
||||||
|
do_stop
|
||||||
|
case "$?" in
|
||||||
|
0|1)
|
||||||
|
do_start
|
||||||
|
case "$?" in
|
||||||
|
0) log_end_msg 0 ;;
|
||||||
|
1) log_end_msg 1 ;; # Old process is still running
|
||||||
|
*) log_end_msg 1 ;; # Failed to start
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Failed to stop
|
||||||
|
log_end_msg 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
|
||||||
|
exit 3
|
||||||
|
;;
|
||||||
|
esac
|
64
management/daemon.py
Executable file
64
management/daemon.py
Executable file
@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import os, os.path
|
||||||
|
|
||||||
|
from flask import Flask, request, render_template
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Load settings from /etc/mailinabox.conf.
|
||||||
|
env = { }
|
||||||
|
for line in open("/etc/mailinabox.conf"): env.setdefault(*line.strip().split("=", 1))
|
||||||
|
env["CONF_DIR"] = os.path.join(os.path.dirname(__file__), "../conf")
|
||||||
|
|
||||||
|
from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
# MAIL
|
||||||
|
|
||||||
|
@app.route('/mail/users')
|
||||||
|
def mail_users():
|
||||||
|
return "".join(x+"\n" for x in get_mail_users(env))
|
||||||
|
|
||||||
|
@app.route('/mail/users/add', methods=['POST'])
|
||||||
|
def mail_users_add():
|
||||||
|
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), env)
|
||||||
|
|
||||||
|
@app.route('/mail/users/password', methods=['POST'])
|
||||||
|
def mail_users_password():
|
||||||
|
return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env)
|
||||||
|
|
||||||
|
@app.route('/mail/users/remove', methods=['POST'])
|
||||||
|
def mail_users_remove():
|
||||||
|
return remove_mail_user(request.form.get('email', ''), env)
|
||||||
|
|
||||||
|
@app.route('/mail/aliases')
|
||||||
|
def mail_aliases():
|
||||||
|
return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env))
|
||||||
|
|
||||||
|
@app.route('/mail/aliases/add', methods=['POST'])
|
||||||
|
def mail_aliases_add():
|
||||||
|
return add_mail_alias(request.form.get('source', ''), request.form.get('destination', ''), env)
|
||||||
|
|
||||||
|
@app.route('/mail/aliases/remove', methods=['POST'])
|
||||||
|
def mail_aliases_remove():
|
||||||
|
return remove_mail_alias(request.form.get('source', ''), env)
|
||||||
|
|
||||||
|
@app.route('/mail/domains')
|
||||||
|
def mail_domains():
|
||||||
|
return "".join(x+"\n" for x in get_mail_domains(env))
|
||||||
|
|
||||||
|
# DNS
|
||||||
|
|
||||||
|
@app.route('/dns/update', methods=['POST'])
|
||||||
|
def dns_update():
|
||||||
|
from dns_update import do_dns_update
|
||||||
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
# APP
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if "DEBUG" in os.environ: app.debug = True
|
||||||
|
app.run(port=10222)
|
177
management/dns_update.py
Executable file
177
management/dns_update.py
Executable file
@ -0,0 +1,177 @@
|
|||||||
|
# Creates DNS zone files for all of the domains of all of the mail users
|
||||||
|
# and mail aliases and restarts nsd.
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
import os, os.path, urllib.parse, time, re
|
||||||
|
|
||||||
|
from mailconfig import get_mail_domains
|
||||||
|
|
||||||
|
def do_dns_update(env):
|
||||||
|
# What domains should we serve DNS for?
|
||||||
|
domains = set()
|
||||||
|
|
||||||
|
# Ensure the PUBLIC_HOSTNAME is in that list.
|
||||||
|
domains.add(env['PUBLIC_HOSTNAME'])
|
||||||
|
|
||||||
|
# Add all domain names in use by email users and mail aliases.
|
||||||
|
domains |= get_mail_domains(env)
|
||||||
|
|
||||||
|
# Make a nice and safe filename for each domain.
|
||||||
|
zonefiles = []
|
||||||
|
for domain in domains:
|
||||||
|
zonefiles.append((domain, urllib.parse.quote(domain, safe='') + ".txt" ))
|
||||||
|
|
||||||
|
# Write zone files.
|
||||||
|
os.makedirs('/etc/nsd/zones', exist_ok=True)
|
||||||
|
updated_domains = []
|
||||||
|
for domain, zonefile in zonefiles:
|
||||||
|
if write_nsd_zone(domain, "/etc/nsd/zones/" + zonefile, env):
|
||||||
|
updated_domains.append(domain)
|
||||||
|
|
||||||
|
# Write the main nsd.conf file.
|
||||||
|
write_nsd_conf(zonefiles)
|
||||||
|
|
||||||
|
# Kick nsd.
|
||||||
|
os.system("service nsd restart")
|
||||||
|
|
||||||
|
# Kick opendkim.
|
||||||
|
os.system("service opendkim restart")
|
||||||
|
|
||||||
|
if len(updated_domains) == 0: updated_domains = ['(no domains required an update)']
|
||||||
|
return "Updated: " + ",".join(updated_domains) + "\n"
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
def write_nsd_zone(domain, zonefile, env):
|
||||||
|
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
||||||
|
# You should probably create an alias to your email address.
|
||||||
|
|
||||||
|
zone = """
|
||||||
|
$ORIGIN {domain}. ; default zone domain
|
||||||
|
$TTL 86400 ; default time to live
|
||||||
|
|
||||||
|
@ IN SOA ns1.{primary_domain}. hostmaster.{primary_domain}. (
|
||||||
|
__SERIAL__ ; serial number
|
||||||
|
28800 ; Refresh
|
||||||
|
7200 ; Retry
|
||||||
|
864000 ; Expire
|
||||||
|
86400 ; Min TTL
|
||||||
|
)
|
||||||
|
|
||||||
|
NS ns1.{primary_domain}.
|
||||||
|
NS ns2.{primary_domain}.
|
||||||
|
IN A {ip}
|
||||||
|
MX 10 {primary_domain}.
|
||||||
|
|
||||||
|
300 TXT "v=spf1 mx -all"
|
||||||
|
|
||||||
|
www IN A {ip}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# In PUBLIC_HOSTNAME, also define ns1 and ns2.
|
||||||
|
if domain == env["PUBLIC_HOSTNAME"]:
|
||||||
|
zone += """
|
||||||
|
ns1 IN A {ip}
|
||||||
|
ns2 IN A {ip}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Replace replacement strings.
|
||||||
|
zone = zone.format(domain=domain, primary_domain=env["PUBLIC_HOSTNAME"], ip=env["PUBLIC_IP"])
|
||||||
|
|
||||||
|
# If OpenDKIM is in use..
|
||||||
|
opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt')
|
||||||
|
if os.path.exists(opendkim_record_file):
|
||||||
|
# Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above.
|
||||||
|
with open(opendkim_record_file) as orf:
|
||||||
|
zone += orf.read()
|
||||||
|
|
||||||
|
# Append ADSP (RFC 5617) and DMARC records.
|
||||||
|
zone += """
|
||||||
|
_adsp._domainkey IN TXT "dkim=all"
|
||||||
|
_dmarc IN TXT "v=DMARC1; p=quarantine"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set the serial number.
|
||||||
|
serial = time.strftime("%Y%m%d00")
|
||||||
|
if os.path.exists(zonefile):
|
||||||
|
# If the zone already exists, is different, and has a later serial number,
|
||||||
|
# increment the number.
|
||||||
|
with open(zonefile) as f:
|
||||||
|
existing_zone = f.read()
|
||||||
|
m = re.search(r"(\d+)\s*;\s*serial number", existing_zone)
|
||||||
|
if m:
|
||||||
|
existing_serial = m.group(1)
|
||||||
|
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.
|
||||||
|
if zone == existing_zone:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If the existing serial is not less than the new one, increment it.
|
||||||
|
if existing_serial >= serial:
|
||||||
|
serial = str(int(existing_serial) + 1)
|
||||||
|
|
||||||
|
zone = zone.replace("__SERIAL__", serial)
|
||||||
|
|
||||||
|
# Write the zone file.
|
||||||
|
with open(zonefile, "w") as f:
|
||||||
|
f.write(zone)
|
||||||
|
|
||||||
|
return True # file is updated
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
def write_nsd_conf(zonefiles):
|
||||||
|
with open("/etc/nsd/nsd.conf", "w") as f:
|
||||||
|
f.write("""
|
||||||
|
server:
|
||||||
|
hide-version: yes
|
||||||
|
|
||||||
|
# identify the server (CH TXT ID.SERVER entry).
|
||||||
|
identity: ""
|
||||||
|
|
||||||
|
# The directory for zonefile: files.
|
||||||
|
zonesdir: "/etc/nsd/zones"
|
||||||
|
|
||||||
|
# ZONES
|
||||||
|
""")
|
||||||
|
|
||||||
|
for domain, zonefile in zonefiles:
|
||||||
|
f.write("""
|
||||||
|
zone:
|
||||||
|
name: %s
|
||||||
|
zonefile: %s
|
||||||
|
""" % (domain, zonefile))
|
||||||
|
|
||||||
|
########################################################################
|
||||||
|
|
||||||
|
def write_opendkim_tables(zonefiles, env):
|
||||||
|
# Append a record to OpenDKIM's KeyTable and SigningTable for each domain.
|
||||||
|
#
|
||||||
|
# The SigningTable maps email addresses to signing information. The KeyTable
|
||||||
|
# maps specify the hostname, the selector, and the path to the private key.
|
||||||
|
#
|
||||||
|
# DKIM ADSP and DMARC both only support policies where the signing domain matches
|
||||||
|
# the From address, so the KeyTable must specify that the signing domain for a
|
||||||
|
# sender matches the sender's domain.
|
||||||
|
#
|
||||||
|
# In SigningTable, we map every email address to a key record named after the domain.
|
||||||
|
# Then we specify for the key record its domain, selector, and key.
|
||||||
|
|
||||||
|
opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')
|
||||||
|
if not os.path.exists(opendkim_key_file): return
|
||||||
|
|
||||||
|
with open("/etc/opendkim/KeyTable", "w") as f:
|
||||||
|
f.write("\n".join(
|
||||||
|
"{domain} {domain}:mail:{key_file}".format(domain=domain, key_file=opendkim_key_file)
|
||||||
|
for domain, zonefile in zonefiles
|
||||||
|
))
|
||||||
|
|
||||||
|
with open("/etc/opendkim/SigningTable", "w") as f:
|
||||||
|
f.write("\n".join(
|
||||||
|
"*@{domain} {domain}".format(domain=domain)
|
||||||
|
for domain, zonefile in zonefiles
|
||||||
|
))
|
||||||
|
|
||||||
|
|
111
management/mailconfig.py
Normal file
111
management/mailconfig.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import subprocess, shutil, os, sqlite3, re
|
||||||
|
|
||||||
|
def open_database(env, with_connection=False):
|
||||||
|
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
||||||
|
if not with_connection:
|
||||||
|
return conn.cursor()
|
||||||
|
else:
|
||||||
|
return conn, conn.cursor()
|
||||||
|
|
||||||
|
def get_mail_users(env):
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT email FROM users')
|
||||||
|
return [row[0] for row in c.fetchall()]
|
||||||
|
|
||||||
|
def get_mail_aliases(env):
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT source, destination FROM aliases')
|
||||||
|
return [(row[0], row[1]) for row in c.fetchall()]
|
||||||
|
|
||||||
|
def get_mail_domains(env):
|
||||||
|
def get_domain(emailaddr):
|
||||||
|
return emailaddr.split('@', 1)[1]
|
||||||
|
return set([get_domain(addr) for addr in get_mail_users(env)] + [get_domain(addr1) for addr1, addr2 in get_mail_aliases(env)])
|
||||||
|
|
||||||
|
def add_mail_user(email, pw, env):
|
||||||
|
if not re.match("\w[\w\.]+@\w[\w\.]+$", email):
|
||||||
|
return ("Invalid email address.", 400)
|
||||||
|
|
||||||
|
# get the database
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
|
||||||
|
# hash the password
|
||||||
|
pw = subprocess.check_output(["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||||
|
|
||||||
|
# add the user to the database
|
||||||
|
try:
|
||||||
|
c.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, pw))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return ("User already exists.", 400)
|
||||||
|
|
||||||
|
# write databasebefore next step
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Create the user's INBOX and Spam folders and subscribe them.
|
||||||
|
|
||||||
|
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
||||||
|
# been deleted, the mailboxes will still exist because they are still on disk.
|
||||||
|
existing_mboxes = subprocess.check_output(["doveadm", "mailbox", "list", "-u", email, "-8"]).decode("utf8").split("\n")
|
||||||
|
|
||||||
|
if "INBOX" not in existing_mboxes: subprocess.check_call(["doveadm", "mailbox", "create", "-u", email, "-s", "INBOX"])
|
||||||
|
if "Spam" not in existing_mboxes: subprocess.check_call(["doveadm", "mailbox", "create", "-u", email, "-s", "Spam"])
|
||||||
|
|
||||||
|
# Create the user's sieve script to move spam into the Spam folder, and make it owned by mail.
|
||||||
|
maildirstat = os.stat(env["STORAGE_ROOT"] + "/mail/mailboxes")
|
||||||
|
(em_user, em_domain) = email.split("@", 1)
|
||||||
|
user_mail_dir = env["STORAGE_ROOT"] + ("/mail/mailboxes/%s/%s" % (em_domain, em_user))
|
||||||
|
if not os.path.exists(user_mail_dir):
|
||||||
|
os.makedirs(user_mail_dir)
|
||||||
|
os.chown(user_mail_dir, maildirstat.st_uid, maildirstat.st_gid)
|
||||||
|
shutil.copyfile(env["CONF_DIR"] + "/dovecot_sieve.txt", user_mail_dir + "/.dovecot.sieve")
|
||||||
|
os.chown(user_mail_dir + "/.dovecot.sieve", maildirstat.st_uid, maildirstat.st_gid)
|
||||||
|
|
||||||
|
# Update DNS in case any new domains are added.
|
||||||
|
from dns_update import do_dns_update
|
||||||
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
def set_mail_password(email, pw, env):
|
||||||
|
# hash the password
|
||||||
|
pw = subprocess.check_output(["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||||
|
|
||||||
|
# update the database
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute("UPDATE users SET password=? WHERE email=?", (pw, email))
|
||||||
|
if c.rowcount != 1:
|
||||||
|
return ("That's not a user (%s)." % email, 400)
|
||||||
|
conn.commit()
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
def remove_mail_user(email, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute("DELETE FROM users WHERE email=?", (email,))
|
||||||
|
if c.rowcount != 1:
|
||||||
|
return ("That's not a user (%s)." % email, 400)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update DNS in case any domains are removed.
|
||||||
|
from dns_update import do_dns_update
|
||||||
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
def add_mail_alias(source, destination, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
try:
|
||||||
|
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return ("Alias already exists (%s)." % source, 400)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update DNS in case any new domains are added.
|
||||||
|
from dns_update import do_dns_update
|
||||||
|
return do_dns_update(env)
|
||||||
|
|
||||||
|
def remove_mail_alias(source, env):
|
||||||
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
c.execute("DELETE FROM aliases WHERE source=?", (source,))
|
||||||
|
if c.rowcount != 1:
|
||||||
|
return ("That's not an alias (%s)." % source, 400)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Update DNS in case any domains are removed.
|
||||||
|
from dns_update import do_dns_update
|
||||||
|
return do_dns_update(env)
|
11
management/templates/index.html
Normal file
11
management/templates/index.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Mail-in-a-Box Management Server</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Mail-in-a-Box Management Server</h1>
|
||||||
|
|
||||||
|
<p>Use this server to issue commands to the Mail-in-a-Box management daemon.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,135 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# DNS: Creates DNS zone files
|
|
||||||
#############################
|
|
||||||
|
|
||||||
# Create nsd.conf and zone files, and updates the OpenDKIM signing tables.
|
|
||||||
|
|
||||||
# We set the administrative email address for every domain to domain_contact@[domain.com].
|
|
||||||
# You should probably create an alias to your email address.
|
|
||||||
|
|
||||||
# This script is safe to run on its own.
|
|
||||||
|
|
||||||
source /etc/mailinabox.conf # load global vars
|
|
||||||
|
|
||||||
# Ensure a zone file exists for every domain name in use by a mail user.
|
|
||||||
for mail_user in `tools/mail.py user`; do
|
|
||||||
domain=`echo $mail_user | sed s/.*@//`
|
|
||||||
if [ ! -f $STORAGE_ROOT/dns/$domain.txt ]; then
|
|
||||||
echo "" > $STORAGE_ROOT/dns/$domain.txt;
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Create the top of nsd.conf.
|
|
||||||
|
|
||||||
cat > /etc/nsd/nsd.conf << EOF;
|
|
||||||
server:
|
|
||||||
hide-version: yes
|
|
||||||
|
|
||||||
# identify the server (CH TXT ID.SERVER entry).
|
|
||||||
identity: ""
|
|
||||||
|
|
||||||
# The directory for zonefile: files.
|
|
||||||
zonesdir: "/etc/nsd/zones"
|
|
||||||
|
|
||||||
# ZONES
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# For every zone file in our dns directory, build a proper zone
|
|
||||||
# file and mention it in nsd.conf. And add information to the
|
|
||||||
# OpenDKIM signing tables.
|
|
||||||
|
|
||||||
mkdir -p /etc/nsd/zones;
|
|
||||||
|
|
||||||
truncate --size 0 /etc/opendkim/KeyTable
|
|
||||||
truncate --size 0 /etc/opendkim/SigningTable
|
|
||||||
|
|
||||||
for fn in $STORAGE_ROOT/dns/*.txt; do
|
|
||||||
# $fn is the zone configuration file, which is just a placeholder now.
|
|
||||||
# For every file like mydomain.com.txt we'll create zone information
|
|
||||||
# for that domain. We don't actually read the file.
|
|
||||||
# $fn2 is the file without the directory.
|
|
||||||
# $zone is the domain name (just mydomain.com).
|
|
||||||
fn2=`basename $fn`
|
|
||||||
zone=`echo $fn2 | sed "s/.txt\$//"`
|
|
||||||
|
|
||||||
# If the zone file exists, get the existing zone serial number so we can increment it.
|
|
||||||
# TODO: This needs to be done better so that the existing serial number is persisted in the storage area.
|
|
||||||
serial=`date +"%Y%m%d00"`
|
|
||||||
if [ -f /etc/nsd/zones/$fn2 ]; then
|
|
||||||
existing_serial=`grep "serial number" /etc/nsd/zones/$fn2 | sed "s/; serial number//"`
|
|
||||||
if [ ! -z "$existing_serial" ]; then
|
|
||||||
serial=`echo $existing_serial + 1 | bc`
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the zone file.
|
|
||||||
cat > /etc/nsd/zones/$fn2 << EOF;
|
|
||||||
\$ORIGIN $zone. ; default zone domain
|
|
||||||
\$TTL 86400 ; default time to live
|
|
||||||
|
|
||||||
@ IN SOA ns1.$PUBLIC_HOSTNAME. hostmaster.$PUBLIC_HOSTNAME. (
|
|
||||||
$serial ; serial number
|
|
||||||
28800 ; Refresh
|
|
||||||
7200 ; Retry
|
|
||||||
864000 ; Expire
|
|
||||||
86400 ; Min TTL
|
|
||||||
)
|
|
||||||
|
|
||||||
NS ns1.$PUBLIC_HOSTNAME.
|
|
||||||
NS ns2.$PUBLIC_HOSTNAME.
|
|
||||||
IN A $PUBLIC_IP
|
|
||||||
MX 10 $PUBLIC_HOSTNAME.
|
|
||||||
|
|
||||||
300 TXT "v=spf1 mx -all"
|
|
||||||
|
|
||||||
www IN A $PUBLIC_IP
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# In PUBLIC_HOSTNAME, also define ns1 and ns2.
|
|
||||||
if [ "$zone" = $PUBLIC_HOSTNAME ]; then
|
|
||||||
cat >> /etc/nsd/zones/$fn2 << EOF;
|
|
||||||
ns1 IN A $PUBLIC_IP
|
|
||||||
ns2 IN A $PUBLIC_IP
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If OpenDKIM is set up..
|
|
||||||
if [ -f "$STORAGE_ROOT/mail/dkim/mail.txt" ]; then
|
|
||||||
# Append the DKIM TXT record to the zone as generated by OpenDKIM.
|
|
||||||
cat "$STORAGE_ROOT/mail/dkim/mail.txt" >> /etc/nsd/zones/$fn2;
|
|
||||||
|
|
||||||
# Append ADSP (RFC 5617) and DMARC records.
|
|
||||||
cat >> /etc/nsd/zones/$fn2 << EOF;
|
|
||||||
_adsp._domainkey IN TXT "dkim=all"
|
|
||||||
_dmarc IN TXT "v=DMARC1; p=quarantine"
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add this zone file to the main nsd configuration file.
|
|
||||||
cat >> /etc/nsd/nsd.conf << EOF;
|
|
||||||
zone:
|
|
||||||
name: $zone
|
|
||||||
zonefile: $fn2
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Append a record to OpenDKIM's KeyTable and SigningTable. The SigningTable maps
|
|
||||||
# email addresses to signing information. The KeyTable maps specify the hostname,
|
|
||||||
# the selector, and the path to the private key.
|
|
||||||
#
|
|
||||||
# DKIM ADSP and DMARC both only support policies where the signing domain matches
|
|
||||||
# the From address, so the KeyTable must specify that the signing domain for a
|
|
||||||
# sender matches the sender's domain.
|
|
||||||
#
|
|
||||||
# In SigningTable, we map every email address to a key record called $zone.
|
|
||||||
# Then we specify for the key record named $zone its domain, selector, and key.
|
|
||||||
echo "$zone $zone:mail:$STORAGE_ROOT/mail/dkim/mail.private" >> /etc/opendkim/KeyTable
|
|
||||||
echo "*@$zone $zone" >> /etc/opendkim/SigningTable
|
|
||||||
|
|
||||||
done
|
|
||||||
|
|
||||||
# Kick nsd.
|
|
||||||
service nsd restart
|
|
||||||
|
|
||||||
# Kick opendkim.
|
|
||||||
service opendkim restart
|
|
||||||
|
|
18
setup/management.sh
Executable file
18
setup/management.sh
Executable file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
source setup/functions.sh
|
||||||
|
|
||||||
|
apt_install python3-flask links
|
||||||
|
|
||||||
|
# Link the management server daemon into a well known location.
|
||||||
|
rm -f /usr/bin/mailinabox-daemon
|
||||||
|
ln -s `pwd`/management/daemon.py /usr/bin/mailinabox-daemon
|
||||||
|
|
||||||
|
# Create an init script to start the management daemon and keep it
|
||||||
|
# running after a reboot.
|
||||||
|
rm -f /etc/init.d/mailinabox
|
||||||
|
ln -s $(pwd)/conf/management-initscript /etc/init.d/mailinabox
|
||||||
|
update-rc.d mailinabox defaults
|
||||||
|
|
||||||
|
# Start it.
|
||||||
|
service mailinabox restart
|
@ -69,9 +69,13 @@ fi
|
|||||||
. setup/mail.sh
|
. setup/mail.sh
|
||||||
. setup/dkim.sh
|
. setup/dkim.sh
|
||||||
. setup/spamassassin.sh
|
. setup/spamassassin.sh
|
||||||
. setup/dns_update.sh
|
|
||||||
. setup/web.sh
|
. setup/web.sh
|
||||||
. setup/webmail.sh
|
. setup/webmail.sh
|
||||||
|
. setup/management.sh
|
||||||
|
|
||||||
|
# Write the DNS configuration files.
|
||||||
|
sleep 2 # wait for the daemon to start
|
||||||
|
curl -d POSTDATA http://127.0.0.1:10222/dns/update
|
||||||
|
|
||||||
if [ -t 0 ]; then # are we in an interactive shell?
|
if [ -t 0 ]; then # are we in an interactive shell?
|
||||||
if [ -z "`tools/mail.py user`" ]; then
|
if [ -z "`tools/mail.py user`" ]; then
|
||||||
@ -85,3 +89,4 @@ if [ -z "`tools/mail.py user`" ]; then
|
|||||||
tools/mail.py alias add postmaster@$PUBLIC_HOSTNAME $EMAIL_ADDR
|
tools/mail.py alias add postmaster@$PUBLIC_HOSTNAME $EMAIL_ADDR
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import sys, sqlite3, subprocess, shutil, os
|
import sys, urllib.request, urllib.error
|
||||||
|
|
||||||
# Load STORAGE_ROOT setting from /etc/mailinabox.conf.
|
def mgmt(cmd, data=None):
|
||||||
env = { }
|
req = urllib.request.Request('http://localhost:10222' + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
|
||||||
for line in open("/etc/mailinabox.conf"): env.setdefault(*line.strip().split("=", 1))
|
try:
|
||||||
|
response = urllib.request.urlopen(req)
|
||||||
# Connect to database.
|
except urllib.error.HTTPError as e:
|
||||||
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
|
print(e.read().decode('utf8'))
|
||||||
c = conn.cursor()
|
sys.exit(1)
|
||||||
|
return response.read().decode('utf8')
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("Usage: ")
|
print("Usage: ")
|
||||||
@ -24,9 +25,7 @@ if len(sys.argv) < 2:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
elif sys.argv[1] == "user" and len(sys.argv) == 2:
|
||||||
c.execute('SELECT email FROM users')
|
print(mgmt("/mail/users"))
|
||||||
for row in c.fetchall():
|
|
||||||
print(row[0])
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
||||||
if len(sys.argv) < 5:
|
if len(sys.argv) < 5:
|
||||||
@ -38,68 +37,23 @@ elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
|
|||||||
else:
|
else:
|
||||||
email, pw = sys.argv[3:5]
|
email, pw = sys.argv[3:5]
|
||||||
|
|
||||||
# hash the password
|
|
||||||
pw = subprocess.check_output(["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
|
||||||
|
|
||||||
if sys.argv[2] == "add":
|
if sys.argv[2] == "add":
|
||||||
try:
|
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
|
||||||
c.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, pw))
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
print("User already exists.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
conn.commit() # write it before next step
|
|
||||||
|
|
||||||
# Create the user's INBOX and Spam folders and subscribe them.
|
|
||||||
|
|
||||||
# Check if the mailboxes exist before creating them. When creating a user that had previously
|
|
||||||
# been deleted, the mailboxes will still exist because they are still on disk.
|
|
||||||
existing_mboxes = subprocess.check_output(["doveadm", "mailbox", "list", "-u", email, "-8"]).decode("utf8").split("\n")
|
|
||||||
|
|
||||||
if "INBOX" not in existing_mboxes: subprocess.check_call(["doveadm", "mailbox", "create", "-u", email, "-s", "INBOX"])
|
|
||||||
if "Spam" not in existing_mboxes: subprocess.check_call(["doveadm", "mailbox", "create", "-u", email, "-s", "Spam"])
|
|
||||||
|
|
||||||
# Create the user's sieve script to move spam into the Spam folder, and make it owned by mail.
|
|
||||||
maildirstat = os.stat(env["STORAGE_ROOT"] + "/mail/mailboxes")
|
|
||||||
(em_user, em_domain) = email.split("@", 1)
|
|
||||||
user_mail_dir = env["STORAGE_ROOT"] + ("/mail/mailboxes/%s/%s" % (em_domain, em_user))
|
|
||||||
if not os.path.exists(user_mail_dir):
|
|
||||||
os.makedirs(user_mail_dir)
|
|
||||||
os.chown(user_mail_dir, maildirstat.st_uid, maildirstat.st_gid)
|
|
||||||
shutil.copyfile("conf/dovecot_sieve.txt", user_mail_dir + "/.dovecot.sieve")
|
|
||||||
os.chown(user_mail_dir + "/.dovecot.sieve", maildirstat.st_uid, maildirstat.st_gid)
|
|
||||||
|
|
||||||
elif sys.argv[2] == "password":
|
elif sys.argv[2] == "password":
|
||||||
c.execute("UPDATE users SET password=? WHERE email=?", (pw, email))
|
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
|
||||||
if c.rowcount != 1:
|
|
||||||
print("That's not a user.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
c.execute("DELETE FROM users WHERE email=?", (sys.argv[3],))
|
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
|
||||||
if c.rowcount != 1:
|
|
||||||
print("That's not a user.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
|
||||||
c.execute('SELECT source, destination FROM aliases')
|
print(mgmt("/mail/aliases"))
|
||||||
for row in c.fetchall():
|
|
||||||
print(row[0], "=>", row[1])
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
|
||||||
try:
|
print(mgmt("/mail/aliases/add", { "source": sys.argv[3], "destination": sys.argv[4] }))
|
||||||
c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (sys.argv[3], sys.argv[4]))
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
print("Alias already exists.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
|
||||||
c.execute("DELETE FROM aliases WHERE source=?", (sys.argv[3],))
|
print(mgmt("/mail/aliases/remove", { "source": sys.argv[3] }))
|
||||||
if c.rowcount != 1:
|
|
||||||
print("That's not an alias.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Invalid command-line arguments.")
|
print("Invalid command-line arguments.")
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
Loading…
Reference in New Issue
Block a user