Merge pull request #153 from simple-login/dns

Add DMARC, improve DNS setup
This commit is contained in:
Son Nguyen Kim 2020-05-03 12:03:34 +02:00 committed by GitHub
commit 9991723f5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 181 additions and 59 deletions

View File

@ -9,7 +9,7 @@
{% block domain_detail_content %} {% block domain_detail_content %}
<div class="bg-white p-4" style="max-width: 60rem; margin: auto"> <div class="bg-white p-4" style="max-width: 60rem; margin: auto">
<h1 class="h3"> {{ custom_domain.domain }} </h1> <h1 class="h2"> {{ custom_domain.domain }} </h1>
<div class="">Please follow the steps below to set up your domain.</div> <div class="">Please follow the steps below to set up your domain.</div>
<div class="small-text mb-5"> <div class="small-text mb-5">
@ -28,19 +28,21 @@
</div> </div>
<div class="mb-2">Add the following MX DNS record to your domain. <br> <div class="mb-2">Add the following MX DNS record to your domain. <br>
Please note that there's a point (<em>.</em>) at the end target addresses. <br> Please note that there's a point (<em>.</em>) at the end target addresses.
This is to make sure the <i>absolute</i> address is used.
<br>
Also some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain. Also some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain.
</div> </div>
{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %} {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
<div class="mb-3 p-3" style="background-color: #eee"> <div class="mb-3 p-3" style="background-color: #eee">
Domain: <em>{{ custom_domain.domain }}</em> or <em>@</em> <br> Record: MX <br>
Domain: {{ custom_domain.domain }} or @ <br>
Priority: {{ priority }} <br> Priority: {{ priority }} <br>
Target: <em>{{ email_server }}</em> Target: <em data-toggle="tooltip"
<button class="ml-4 clipboard btn btn-sm btn-outline-success" data-clipboard-action="copy" title="Click to copy"
data-clipboard-text="{{ email_server }}"> class="clipboard"
Copy <i class="fe fe-clipboard"></i> data-clipboard-text="{{ email_server }}">{{ email_server }}</em>
</button>
</div> </div>
{% endfor %} {% endfor %}
@ -93,18 +95,18 @@
Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder. Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
</div> </div>
<div class="mb-2">Add the following TXT DNS record to your domain</div> <div class="mb-2">Add the following TXT DNS record to your domain.</div>
<div class="mb-2 p-3" style="background-color: #eee"> <div class="mb-2 p-3" style="background-color: #eee">
Domain: <em>{{ custom_domain.domain }}</em> or <em>@</em> <br> Record: TXT <br>
Domain: {{ custom_domain.domain }} or @ <br>
Value: Value:
<em> <em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ spf_record }}">
{{ spf_record }} {{ spf_record }}
</em> </em>
<button class="ml-4 clipboard btn btn-sm btn-outline-success" data-clipboard-action="copy"
data-clipboard-text="{{ spf_record }}">
Copy <i class="fe fe-clipboard"></i>
</button>
</div> </div>
<form method="post" action="#spf-form"> <form method="post" action="#spf-form">
@ -158,18 +160,21 @@
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder. Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
</div> </div>
<div class="mb-2">Add the following TXT DNS record to your domain</div> <div class="mb-2">Add the following CNAME DNS record to your domain.</div>
<div class="mb-2 p-3" style="background-color: #eee"> <div class="mb-2 p-3" style="background-color: #eee">
Domain: <em>dkim._domainkey.{{ custom_domain.domain }}</em> <br> Record: CNAME <br>
Domain: <em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="dkim._domainkey">dkim._domainkey</em>.{{ custom_domain.domain }} <br>
Value: Value:
<em style="overflow-wrap: break-word"> <em data-toggle="tooltip"
{{ dkim_record }} title="Click to copy"
class="clipboard"
data-clipboard-text="{{ dkim_cname }}" style="overflow-wrap: break-word">
{{ dkim_cname }}
</em> </em>
<button class="ml-4 clipboard btn btn-sm btn-outline-success" data-clipboard-action="copy"
data-clipboard-text="{{ dkim_record }}">
Copy <i class="fe fe-clipboard"></i>
</button>
</div> </div>
<form method="post" action="#dkim-form"> <form method="post" action="#dkim-form">
@ -189,7 +194,7 @@
<div class="text-danger mt-4"> <div class="text-danger mt-4">
Your DNS is not correctly set. Your DNS is not correctly set.
{% if dkim_errors %} {% if dkim_errors %}
The TXT record we obtain for The CNAME record we obtain for
<em>dkim._domainkey.{{ custom_domain.domain }}</em> is: <em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
<div class="mb-3 p-3" style="background-color: #eee"> <div class="mb-3 p-3" style="background-color: #eee">
@ -206,5 +211,73 @@
{% endif %} {% endif %}
</div> </div>
<hr>
<div id="dmarc-form">
<div class="font-weight-bold">4. DMARC (Optional)
{% if custom_domain.dmarc_verified %}
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Verified"></span>
{% else %}
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Not Verified">🚫 </span>
{% endif %}
</div>
<div>
DMARC <a href="https://en.wikipedia.org/wiki/DMARC" target="_blank">(Wikipedia↗)</a>
is designed to protect the domain from unauthorized use, commonly known as email spoofing. <br>
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
neither of those authentication methods passes.
</div>
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
<div class="mb-2 p-3" style="background-color: #eee">
Record: TXT <br>
Domain: <em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="_dmarc">_dmarc</em>.{{ custom_domain.domain }} <br>
Value:
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ dmarc_record }}">
{{ dmarc_record }}
</em>
</div>
<form method="post" action="#dmarc-form">
<input type="hidden" name="form-name" value="check-dmarc">
{% if custom_domain.dmarc_verified %}
<button type="submit" class="btn btn-outline-primary">
Re-verify
</button>
{% else %}
<button type="submit" class="btn btn-primary">
Verify
</button>
{% endif %}
</form>
{% if not dmarc_ok %}
<div class="text-danger mt-4">
Your DNS is not correctly set.
The TXT record we obtain is:
<div class="mb-3 p-3" style="background-color: #eee">
{% if not dmarc_errors %}
(Empty)
{% endif %}
{% for r in dmarc_errors %}
{{ r }} <br>
{% endfor %}
</div>
{% if custom_domain.dmarc_verified %}
Without DMARC setup, emails sent from your alias might end up in the Spam/Junk folder.
{% endif %}
</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,13 +1,13 @@
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.config import EMAIL_SERVERS_WITH_PRIORITY, DKIM_DNS_VALUE, EMAIL_DOMAIN from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dns_utils import ( from app.dns_utils import (
get_mx_domains, get_mx_domains,
get_spf_domain, get_spf_domain,
get_dkim_record,
get_txt_record, get_txt_record,
get_cname_record,
) )
from app.extensions import db from app.extensions import db
from app.models import CustomDomain, Alias from app.models import CustomDomain, Alias
@ -21,8 +21,15 @@ def domain_detail_dns(custom_domain_id):
flash("You cannot see this page", "warning") flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
mx_ok = spf_ok = dkim_ok = True spf_record = f"v=spf1 include:{EMAIL_DOMAIN} -all"
mx_errors = spf_errors = dkim_errors = []
# hardcode the DKIM selector here
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
mx_ok = spf_ok = dkim_ok = dmarc_ok = True
mx_errors = spf_errors = dkim_errors = dmarc_errors = []
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "check-mx": if request.form.get("form-name") == "check-mx":
@ -37,7 +44,7 @@ def domain_detail_dns(custom_domain_id):
] ]
else: else:
flash( flash(
"Your domain is verified. Now it can be used to create custom alias", "Your domain can start receiving emails. You can now use it to create alias",
"success", "success",
) )
custom_domain.verified = True custom_domain.verified = True
@ -52,7 +59,7 @@ def domain_detail_dns(custom_domain_id):
if EMAIL_DOMAIN in spf_domains: if EMAIL_DOMAIN in spf_domains:
custom_domain.spf_verified = True custom_domain.spf_verified = True
db.session.commit() db.session.commit()
flash("The SPF is setup correctly", "success") flash("SPF is setup correctly", "success")
return redirect( return redirect(
url_for( url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id "dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
@ -67,10 +74,9 @@ def domain_detail_dns(custom_domain_id):
spf_errors = get_txt_record(custom_domain.domain) spf_errors = get_txt_record(custom_domain.domain)
elif request.form.get("form-name") == "check-dkim": elif request.form.get("form-name") == "check-dkim":
dkim_record = get_dkim_record(custom_domain.domain) dkim_record = get_cname_record(custom_domain.domain)
correct_dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}" if dkim_record == dkim_cname:
if dkim_record == correct_dkim_record: flash("DKIM is setup correctly.", "success")
flash("The DKIM is setup correctly.", "success")
custom_domain.dkim_verified = True custom_domain.dkim_verified = True
db.session.commit() db.session.commit()
@ -80,13 +86,27 @@ def domain_detail_dns(custom_domain_id):
) )
) )
else: else:
flash("DKIM: the TXT record is not correctly set", "warning") flash("DKIM: the CNAME record is not correctly set", "warning")
dkim_ok = False dkim_ok = False
dkim_errors = get_txt_record(f"dkim._domainkey.{custom_domain.domain}") dkim_errors = [dkim_record or "[Empty]"]
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} -all" elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}" if dmarc_record in txt_records:
custom_domain.dmarc_verified = True
db.session.commit()
flash("DMARC is setup correctly", "success")
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
flash(
f"DMARC: The TXT record is not correctly set", "warning",
)
dmarc_ok = False
dmarc_errors = txt_records
return render_template( return render_template(
"dashboard/domain_detail/dns.html", "dashboard/domain_detail/dns.html",

View File

@ -1,3 +1,5 @@
from typing import Optional
import dns.resolver import dns.resolver
@ -10,6 +12,19 @@ def _get_dns_resolver():
return my_resolver return my_resolver
def get_cname_record(hostname) -> Optional[str]:
"""Return the CNAME record if exists for a domain"""
try:
answers = _get_dns_resolver().query(hostname, "CNAME")
except Exception:
return None
for a in answers:
return a
return None
def get_mx_domains(hostname) -> [(int, str)]: def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name). """return list of (priority, domain name).
domain name ends with a "." at the end. domain name ends with a "." at the end.
@ -56,6 +71,7 @@ def get_spf_domain(hostname) -> [str]:
def get_txt_record(hostname) -> [str]: def get_txt_record(hostname) -> [str]:
"""return all domains listed in *include:*"""
try: try:
answers = _get_dns_resolver().query(hostname, "TXT") answers = _get_dns_resolver().query(hostname, "TXT")
except Exception: except Exception:
@ -63,24 +79,10 @@ def get_txt_record(hostname) -> [str]:
ret = [] ret = []
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
ret.append(a)
return ret
def get_dkim_record(hostname) -> str:
"""query the dkim._domainkey.{hostname} record and returns its value"""
try:
answers = _get_dns_resolver().query(f"dkim._domainkey.{hostname}", "TXT")
except Exception:
return ""
ret = []
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
for record in a.strings: for record in a.strings:
record = record.decode() # record is bytes record = record.decode() # record is bytes
ret.append(record) ret.append(record)
return "".join(ret) return ret

View File

@ -1043,6 +1043,9 @@ class CustomDomain(db.Model, ModelMixin):
spf_verified = db.Column( spf_verified = db.Column(
db.Boolean, nullable=False, default=False, server_default="0" db.Boolean, nullable=False, default=False, server_default="0"
) )
dmarc_verified = db.Column(
db.Boolean, nullable=False, default=False, server_default="0"
)
# an alias is created automatically the first time it receives an email # an alias is created automatically the first time it receives an email
catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0") catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: de1b457472e0
Revises: f939d67374e4
Create Date: 2020-05-03 12:02:11.958152
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'de1b457472e0'
down_revision = 'f939d67374e4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('custom_domain', sa.Column('dmarc_verified', sa.Boolean(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('custom_domain', 'dmarc_verified')
# ### end Alembic commands ###

View File

@ -23,8 +23,3 @@ def test_get_txt_record():
r = get_txt_record(_DOMAIN) r = get_txt_record(_DOMAIN)
assert len(r) > 0 assert len(r) > 0
def test_get_dkim_record():
r = get_dkim_record(_DOMAIN)
assert r.startswith("v=DKIM1; k=rsa;")