356 lines
11 KiB
Python
356 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import boto3
|
|
import jingrow
|
|
import requests
|
|
from dns.exception import DNSException
|
|
from dns.rdatatype import AAAA, CAA, CNAME, SOA, A
|
|
from dns.resolver import NoAnswer, Resolver
|
|
from frappe.core.utils import find
|
|
from frappe.utils.caching import redis_cache
|
|
|
|
from press.exceptions import (
|
|
AAAARecordExists,
|
|
ConflictingCAARecord,
|
|
ConflictingDNSRecord,
|
|
DNSValidationError,
|
|
DomainProxied,
|
|
MultipleARecords,
|
|
MultipleCNAMERecords,
|
|
)
|
|
from press.utils import log_error
|
|
|
|
if TYPE_CHECKING:
|
|
from press.press.doctype.root_domain.root_domain import RootDomain
|
|
|
|
NAMESERVERS = ["1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4"]
|
|
|
|
|
|
@jingrow.whitelist()
|
|
def create_dns_record(pg, record_name=None):
|
|
"""Check if site needs dns records and creates one."""
|
|
domain: RootDomain = frappe.get_pg("Root Domain", pg.domain)
|
|
|
|
if domain.generic_dns_provider:
|
|
return
|
|
|
|
if frappe.flags.in_test:
|
|
return
|
|
|
|
proxy_server, is_standalone = frappe.get_value("Server", pg.server, ["proxy_server", "is_standalone"])
|
|
|
|
if pg.cluster == domain.default_cluster and not is_standalone:
|
|
# Check if the cluster has multiple proxy servers
|
|
proxy_servers = frappe.get_all(
|
|
"Proxy Server",
|
|
{"cluster": pg.cluster, "status": "Active"},
|
|
pluck="name",
|
|
)
|
|
if len(proxy_servers) == 1 or (
|
|
len(proxy_servers) > 1 and domain.default_proxy_server == proxy_server
|
|
):
|
|
"""
|
|
If we have a single proxy server
|
|
Or, in case of multiple proxy server, the site is using the default proxy server
|
|
|
|
We can skip creating dns record
|
|
"""
|
|
return
|
|
|
|
if is_standalone:
|
|
_change_dns_record("UPSERT", domain, pg.server, record_name=record_name)
|
|
else:
|
|
_change_dns_record("UPSERT", domain, proxy_server, record_name=record_name)
|
|
|
|
|
|
def _change_dns_record(method: str, domain: RootDomain, proxy_server: str, record_name: str | None = None):
|
|
"""
|
|
Change dns record of site
|
|
|
|
method: CREATE | DELETE | UPSERT
|
|
"""
|
|
if domain.generic_dns_provider:
|
|
return
|
|
|
|
client = boto3.client(
|
|
"route53",
|
|
aws_access_key_id=domain.aws_access_key_id,
|
|
aws_secret_access_key=domain.get_password("aws_secret_access_key"),
|
|
region_name=domain.aws_region,
|
|
)
|
|
try:
|
|
zones = client.list_hosted_zones_by_name()["HostedZones"]
|
|
hosted_zone = find(reversed(zones), lambda x: domain.name.endswith(x["Name"][:-1]))["Id"]
|
|
client.change_resource_record_sets(
|
|
ChangeBatch={
|
|
"Changes": [
|
|
{
|
|
"Action": method,
|
|
"ResourceRecordSet": {
|
|
"Name": record_name,
|
|
"Type": "CNAME",
|
|
"TTL": 600,
|
|
"ResourceRecords": [{"Value": proxy_server}],
|
|
},
|
|
}
|
|
]
|
|
},
|
|
HostedZoneId=hosted_zone,
|
|
)
|
|
except client.exceptions.InvalidChangeBatch as e:
|
|
# If we're attempting to DELETE and record is not found, ignore the error
|
|
# e.response["Error"]["Message"] looks like
|
|
# [Tried to delete resource record set [name='xxx.frappe.cloud.', type='CNAME'] but it was not found]
|
|
if method == "DELETE" and "but it was not found" in e.response["Error"]["Message"]:
|
|
return
|
|
log_error(
|
|
"Route 53 Record Creation Error",
|
|
domain=domain.name,
|
|
site=record_name,
|
|
proxy_server=proxy_server,
|
|
)
|
|
except Exception:
|
|
log_error(
|
|
"Route 53 Record Creation Error",
|
|
domain=domain.name,
|
|
site=record_name,
|
|
proxy_server=proxy_server,
|
|
)
|
|
|
|
|
|
def get_dns_provider_mname_rname(domain):
|
|
from tldextract import extract
|
|
|
|
resolver = Resolver(configure=False)
|
|
resolver.nameservers = NAMESERVERS
|
|
try:
|
|
answer = resolver.query(domain, SOA)
|
|
if len(answer) > 0:
|
|
mname = answer[0].mname.to_text()
|
|
rname = answer[0].rname.to_text()
|
|
return extract(mname).registered_domain, extract(rname).registered_domain
|
|
except NoAnswer:
|
|
pass
|
|
except DNSException:
|
|
pass
|
|
return None
|
|
|
|
|
|
def accessible_link_substr(provider: str):
|
|
try:
|
|
res = requests.head(f"http://{provider}", timeout=3)
|
|
res.raise_for_status()
|
|
except requests.RequestException:
|
|
return None
|
|
else:
|
|
return f'<a class=underline href="http://{provider}" target="_blank">{provider}</a>'
|
|
|
|
|
|
@redis_cache()
|
|
def get_dns_provider_link_substr(domain: str):
|
|
# get link to dns provider as html a tag if link is accessible
|
|
provider = get_dns_provider_mname_rname(domain)
|
|
if not provider:
|
|
return ""
|
|
mname, rname = provider
|
|
return f" at your DNS provider (hint: {accessible_link_substr(mname) or accessible_link_substr(rname) or rname})" # likely rname has meaningful link
|
|
|
|
|
|
def check_domain_allows_letsencrypt_certs(domain):
|
|
# Check if domain is allowed to get letsencrypt certificates
|
|
# This is a security measure to prevent unauthorized certificate issuance
|
|
from tldextract import extract
|
|
|
|
naked_domain = extract(domain).registered_domain
|
|
resolver = Resolver(configure=False)
|
|
resolver.nameservers = NAMESERVERS
|
|
try:
|
|
answer = resolver.query(naked_domain, CAA)
|
|
for rdata in answer:
|
|
if "letsencrypt.org" in rdata.to_text():
|
|
return True
|
|
except NoAnswer:
|
|
pass # no CAA record. Anything goes
|
|
except DNSException:
|
|
pass # We have other problems
|
|
else:
|
|
frappe.throw(
|
|
f"Domain {naked_domain} does not allow Let's Encrypt certificates. Please update or remove <b>CAA</b> record{get_dns_provider_link_substr(domain)}.",
|
|
ConflictingCAARecord,
|
|
)
|
|
|
|
|
|
def check_dns_cname(name, domain):
|
|
result = {"type": "CNAME", "exists": True, "matched": False, "answer": ""}
|
|
try:
|
|
resolver = Resolver(configure=False)
|
|
resolver.nameservers = NAMESERVERS
|
|
answer = resolver.query(domain, CNAME)
|
|
if len(answer) > 1:
|
|
raise MultipleCNAMERecords
|
|
mapped_domain = answer[0].to_text().rsplit(".", 1)[0]
|
|
result["answer"] = answer.rrset.to_text()
|
|
other_domains = frappe.db.get_all(
|
|
"Site Domain", {"site": name, "status": "Active", "domain": ("!=", name)}, pluck="domain"
|
|
)
|
|
if mapped_domain == name or mapped_domain in other_domains:
|
|
result["matched"] = True
|
|
except MultipleCNAMERecords:
|
|
multiple_domains = ", ".join(part.to_text() for part in answer)
|
|
frappe.throw(
|
|
f"Domain <b>{domain}</b> has multiple CNAME records: <b>{multiple_domains}</b>. Please keep only one{get_dns_provider_link_substr(domain)}.",
|
|
MultipleCNAMERecords,
|
|
)
|
|
except NoAnswer as e:
|
|
result["exists"] = False
|
|
result["answer"] = str(e)
|
|
except DNSException as e:
|
|
result["answer"] = str(e)
|
|
except Exception as e:
|
|
result["answer"] = str(e)
|
|
log_error("DNS Query Exception - CNAME", site=name, domain=domain, exception=e)
|
|
return result
|
|
|
|
|
|
def check_for_ip_match(site_name: str, site_ip: str | None, domain_ip: str | None):
|
|
if domain_ip == site_ip:
|
|
return True
|
|
if site_ip:
|
|
# We can issue certificates even if the domain points to the secondary proxies
|
|
server = frappe.db.get_value("Site", site_name, "server")
|
|
proxy = frappe.db.get_value("Server", server, "proxy_server")
|
|
secondary_ips = frappe.get_all(
|
|
"Proxy Server",
|
|
{"status": "Active", "primary": proxy, "is_replication_setup": True},
|
|
pluck="ip",
|
|
)
|
|
if domain_ip in secondary_ips:
|
|
return True
|
|
return False
|
|
|
|
|
|
def check_dns_a(name, domain):
|
|
result = {"type": "A", "exists": True, "matched": False, "answer": ""}
|
|
try:
|
|
resolver = Resolver(configure=False)
|
|
resolver.nameservers = NAMESERVERS
|
|
answer = resolver.query(domain, A)
|
|
if len(answer) > 1:
|
|
raise MultipleARecords
|
|
domain_ip = answer[0].to_text()
|
|
site_ip = resolver.query(name, A)[0].to_text()
|
|
result["answer"] = answer.rrset.to_text()
|
|
result["matched"] = check_for_ip_match(name, site_ip, domain_ip)
|
|
except MultipleARecords:
|
|
multiple_ips = ", ".join(part.to_text() for part in answer)
|
|
frappe.throw(
|
|
f"Domain {domain} has multiple A records: {multiple_ips}. Please keep only one{get_dns_provider_link_substr(domain)}.",
|
|
MultipleARecords,
|
|
)
|
|
except NoAnswer as e:
|
|
result["exists"] = False
|
|
result["answer"] = str(e)
|
|
except DNSException as e:
|
|
result["answer"] = str(e)
|
|
except Exception as e:
|
|
result["answer"] = str(e)
|
|
log_error("DNS Query Exception - A", site=name, domain=domain, exception=e)
|
|
return result
|
|
|
|
|
|
def ensure_dns_aaaa_record_doesnt_exist(domain: str):
|
|
"""
|
|
Ensure that the domain doesn't have an AAAA record
|
|
|
|
LetsEncrypt has issues with IPv6, so we need to ensure that the domain doesn't have an AAAA record
|
|
ref: https://letsencrypt.org/docs/ipv6-support/#incorrect-ipv6-addresses
|
|
"""
|
|
try:
|
|
resolver = Resolver(configure=False)
|
|
resolver.nameservers = NAMESERVERS
|
|
answer = resolver.query(domain, AAAA)
|
|
if answer:
|
|
frappe.throw(
|
|
f"Domain {domain} has an AAAA record. This causes issues with https certificate generation. Please remove the same{get_dns_provider_link_substr(domain)}.",
|
|
AAAARecordExists,
|
|
)
|
|
except NoAnswer:
|
|
pass
|
|
except DNSException:
|
|
pass # We have other problems
|
|
|
|
|
|
DNS_HELP_ARTICLE = "https://developers.cloudflare.com/dns/manage-dns-records/"
|
|
|
|
|
|
def check_domain_proxied(domain) -> str | None:
|
|
try:
|
|
res = requests.head(f"http://{domain}", timeout=3)
|
|
except requests.exceptions.RequestException as e:
|
|
frappe.msgprint(
|
|
f"Unable to connect to the domain. Is the DNS correct{get_dns_provider_link_substr(domain)}? <a href='{DNS_HELP_ARTICLE}' target='_blank' class='underline' >Learn more</a>.",
|
|
)
|
|
raise DNSValidationError from e
|
|
else:
|
|
if (server := res.headers.get("server")) not in ("Frappe Cloud", None): # eg: cloudflare
|
|
return server
|
|
return None
|
|
|
|
|
|
def _check_dns_cname_a(name, domain, ignore_proxying=False):
|
|
check_domain_allows_letsencrypt_certs(domain)
|
|
ensure_dns_aaaa_record_doesnt_exist(domain)
|
|
cname = check_dns_cname(name, domain)
|
|
result = {"CNAME": cname} | cname
|
|
|
|
a = check_dns_a(name, domain)
|
|
result |= {"A": a} | a
|
|
|
|
if cname["matched"] and a["exists"] and not a["matched"]:
|
|
frappe.throw(
|
|
f"""
|
|
Domain <b>{domain}</b> has correct CNAME record <b>{cname["answer"].strip().split()[-1]}</b>, but also an A record that points to an incorrect IP address <b>{a["answer"].strip().split()[-1]}</b>.
|
|
<br>Please remove or update the <b>A</b> record{get_dns_provider_link_substr(domain)}.
|
|
""",
|
|
ConflictingDNSRecord,
|
|
)
|
|
if a["matched"] and cname["exists"] and not cname["matched"]:
|
|
frappe.throw(
|
|
f"""
|
|
Domain <b>{domain}</b> has correct A record <b>{a["answer"].strip().split()[-1]}</b>, but also a CNAME record that points to an incorrect domain <b>{cname["answer"].strip().split()[-1]}</b>.
|
|
<br>Please remove or update the <b>CNAME</b> record{get_dns_provider_link_substr(domain)}.
|
|
""",
|
|
ConflictingDNSRecord,
|
|
)
|
|
|
|
proxy = check_domain_proxied(domain)
|
|
if proxy:
|
|
if ignore_proxying: # no point checking the rest if proxied
|
|
return {"CNAME": {}, "A": {}, "matched": True, "type": "A"} # assume A
|
|
frappe.throw(
|
|
f"""Domain <b>{domain}</b> appears to be proxied (server: <b>{proxy}</b>). Please turn off proxying{get_dns_provider_link_substr(domain)} and try again in some time.
|
|
<br>You may enable it once the domain is verified.""",
|
|
DomainProxied,
|
|
)
|
|
|
|
result["valid"] = cname["matched"] or a["matched"]
|
|
return result
|
|
|
|
|
|
def check_dns_cname_a(name, domain, ignore_proxying=False, throw_error=True):
|
|
if throw_error:
|
|
return _check_dns_cname_a(name, domain, ignore_proxying)
|
|
|
|
result = {}
|
|
try:
|
|
result = _check_dns_cname_a(name, domain, ignore_proxying)
|
|
|
|
except Exception as e:
|
|
result["exc_type"] = e.__class__.__name__
|
|
result["exc_message"] = str(e)
|
|
result["valid"] = False
|
|
|
|
return result
|