mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-04 15:54:48 +01: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:
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>
|
||||
Reference in New Issue
Block a user