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 %}
<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="small-text mb-5">
@ -28,19 +28,21 @@
</div>
<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.
</div>
{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
<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>
Target: <em>{{ email_server }}</em>
<button class="ml-4 clipboard btn btn-sm btn-outline-success" data-clipboard-action="copy"
data-clipboard-text="{{ email_server }}">
Copy <i class="fe fe-clipboard"></i>
</button>
Target: <em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ email_server }}">{{ email_server }}</em>
</div>
{% 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.
</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">
Domain: <em>{{ custom_domain.domain }}</em> or <em>@</em> <br>
Record: TXT <br>
Domain: {{ custom_domain.domain }} or @ <br>
Value:
<em>
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ spf_record }}">
{{ spf_record }}
</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>
<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.
</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">
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:
<em style="overflow-wrap: break-word">
{{ dkim_record }}
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="{{ dkim_cname }}" style="overflow-wrap: break-word">
{{ dkim_cname }}
</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>
<form method="post" action="#dkim-form">
@ -189,7 +194,7 @@
<div class="text-danger mt-4">
Your DNS is not correctly set.
{% if dkim_errors %}
The TXT record we obtain for
The CNAME record we obtain for
<em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
<div class="mb-3 p-3" style="background-color: #eee">
@ -206,5 +211,73 @@
{% endif %}
</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>
{% endblock %}

View File

@ -1,13 +1,13 @@
from flask import render_template, request, redirect, url_for, flash
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.dns_utils import (
get_mx_domains,
get_spf_domain,
get_dkim_record,
get_txt_record,
get_cname_record,
)
from app.extensions import db
from app.models import CustomDomain, Alias
@ -21,8 +21,15 @@ def domain_detail_dns(custom_domain_id):
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
mx_ok = spf_ok = dkim_ok = True
mx_errors = spf_errors = dkim_errors = []
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} -all"
# 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.form.get("form-name") == "check-mx":
@ -37,7 +44,7 @@ def domain_detail_dns(custom_domain_id):
]
else:
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",
)
custom_domain.verified = True
@ -52,7 +59,7 @@ def domain_detail_dns(custom_domain_id):
if EMAIL_DOMAIN in spf_domains:
custom_domain.spf_verified = True
db.session.commit()
flash("The SPF is setup correctly", "success")
flash("SPF is setup correctly", "success")
return redirect(
url_for(
"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)
elif request.form.get("form-name") == "check-dkim":
dkim_record = get_dkim_record(custom_domain.domain)
correct_dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
if dkim_record == correct_dkim_record:
flash("The DKIM is setup correctly.", "success")
dkim_record = get_cname_record(custom_domain.domain)
if dkim_record == dkim_cname:
flash("DKIM is setup correctly.", "success")
custom_domain.dkim_verified = True
db.session.commit()
@ -80,13 +86,27 @@ def domain_detail_dns(custom_domain_id):
)
)
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_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"
dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
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(
"dashboard/domain_detail/dns.html",

View File

@ -1,3 +1,5 @@
from typing import Optional
import dns.resolver
@ -10,6 +12,19 @@ def _get_dns_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)]:
"""return list of (priority, domain name).
domain name ends with a "." at the end.
@ -56,6 +71,7 @@ def get_spf_domain(hostname) -> [str]:
def get_txt_record(hostname) -> [str]:
"""return all domains listed in *include:*"""
try:
answers = _get_dns_resolver().query(hostname, "TXT")
except Exception:
@ -63,24 +79,10 @@ def get_txt_record(hostname) -> [str]:
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 record in a.strings:
record = record.decode() # record is bytes
ret.append(record)
return "".join(ret)
return ret

View File

@ -1043,6 +1043,9 @@ class CustomDomain(db.Model, ModelMixin):
spf_verified = db.Column(
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
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)
assert len(r) > 0
def test_get_dkim_record():
r = get_dkim_record(_DOMAIN)
assert r.startswith("v=DKIM1; k=rsa;")