1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-02-04 16:01:55 +00:00
community.general/plugins/modules/dnsmadeeasy.py
patchback[bot] b769b0bc01
[PR #11400/236b9c0e backport][stable-12] Sort imports with ruff check --fix (#11409)
Sort imports with ruff check --fix (#11400)

Sort imports with ruff check --fix.

(cherry picked from commit 236b9c0e04)

Co-authored-by: Felix Fontein <felix@fontein.de>
2026-01-09 19:36:52 +01:00

734 lines
23 KiB
Python

#!/usr/bin/python
# Copyright Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: dnsmadeeasy
short_description: Interface with dnsmadeeasy.com (a DNS hosting service)
description:
- 'Manages DNS records using the v2 REST API of the DNS Made Easy service. It handles records only; there is no manipulation
of domains or monitor/account support yet. See: U(https://www.dnsmadeeasy.com/integration/restapi/).'
extends_documentation_fragment:
- community.general.attributes
attributes:
check_mode:
support: none
diff_mode:
support: none
options:
account_key:
description:
- Account API Key.
required: true
type: str
account_secret:
description:
- Account Secret Key.
required: true
type: str
domain:
description:
- Domain to work with. Can be the domain name (for example V(mydomain.com)) or the numeric ID of the domain in DNS Made
Easy (for example V(839989)) for faster resolution.
required: true
type: str
sandbox:
description:
- Decides if the sandbox API should be used. Otherwise (default) the production API of DNS Made Easy is used.
type: bool
default: false
record_name:
description:
- Record name to get/create/delete/update. If O(record_name) is not specified; all records for the domain are returned
in "result" regardless of the state argument.
type: str
record_type:
description:
- Record type.
choices: ['A', 'AAAA', 'CNAME', 'ANAME', 'HTTPRED', 'MX', 'NS', 'PTR', 'SRV', 'TXT']
type: str
record_value:
description:
- 'Record value. HTTPRED: <redirection URL>, MX: <priority> <target name>, NS: <name server>, PTR: <target name>, SRV:
<priority> <weight> <port> <target name>, TXT: <text value>".'
- If O(record_value) is not specified; no changes are made and the record is returned in RV(ignore:result) (in other
words, this module can be used to fetch a record's current ID, type, and TTL).
type: str
record_ttl:
description:
- Record's "Time-To-Live". Number of seconds the record remains cached in DNS servers.
default: 1800
type: int
state:
description:
- Whether the record should exist or not.
required: true
choices: ['present', 'absent']
type: str
validate_certs:
description:
- If V(false), SSL certificates are not validated. This should only be used on personally controlled sites using self-signed
certificates.
type: bool
default: true
monitor:
description:
- If V(true), add or change the monitor. This is applicable only for A records.
type: bool
default: false
systemDescription:
description:
- Description used by the monitor.
default: ''
type: str
maxEmails:
description:
- Number of emails sent to the contact list by the monitor.
default: 1
type: int
protocol:
description:
- Protocol used by the monitor.
default: 'HTTP'
choices: ['TCP', 'UDP', 'HTTP', 'DNS', 'SMTP', 'HTTPS']
type: str
port:
description:
- Port used by the monitor.
default: 80
type: int
sensitivity:
description:
- Number of checks the monitor performs before a failover occurs where Low = 8, Medium = 5,and High = 3.
default: 'Medium'
choices: ['Low', 'Medium', 'High']
type: str
contactList:
description:
- Name or ID of the contact list that the monitor notifies.
- The default V('') means the Account Owner.
type: str
httpFqdn:
description:
- The fully qualified domain name used by the monitor.
type: str
httpFile:
description:
- The file at the Fqdn that the monitor queries for HTTP or HTTPS.
type: str
httpQueryString:
description:
- The string in the httpFile that the monitor queries for HTTP or HTTPS.
type: str
failover:
description:
- If V(true), add or change the failover. This is applicable only for A records.
type: bool
default: false
autoFailover:
description:
- If true, fallback to the primary IP address is manual after a failover.
- If false, fallback to the primary IP address is automatic after a failover.
type: bool
default: false
ip1:
description:
- Primary IP address for the failover.
- Required if adding or changing the monitor or failover.
type: str
ip2:
description:
- Secondary IP address for the failover.
- Required if adding or changing the failover.
type: str
ip3:
description:
- Tertiary IP address for the failover.
type: str
ip4:
description:
- Quaternary IP address for the failover.
type: str
ip5:
description:
- Quinary IP address for the failover.
type: str
notes:
- The DNS Made Easy service requires that machines interacting with the API have the proper time and timezone set. Be sure
you are within a few seconds of actual time by using NTP.
- This module returns record(s) and monitor(s) in the RV(ignore:result) element when O(state=present). These values can
be be registered and used in your playbooks.
- Only A records can have a O(monitor) or O(failover).
- To add failover, the O(failover), O(autoFailover), O(port), O(protocol), O(ip1), and O(ip2) options are required.
- To add monitor, the O(monitor), O(port), O(protocol), O(maxEmails), O(systemDescription), and O(ip1) options are required.
- The options O(monitor) and O(failover) share O(port), O(protocol), and O(ip1) options.
requirements: [hashlib, hmac]
author: "Brice Burgess (@briceburg)"
"""
EXAMPLES = r"""
- name: Fetch my.com domain records
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
register: response
- name: Create a record
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
- name: Update the previously created record
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_value: 192.0.2.23
- name: Fetch a specific record
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
register: response
- name: Delete a record
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
record_type: A
state: absent
record_name: test
- name: Add a failover
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
failover: true
ip1: 127.0.0.2
ip2: 127.0.0.3
- name: Add a failover
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
failover: true
ip1: 127.0.0.2
ip2: 127.0.0.3
ip3: 127.0.0.4
ip4: 127.0.0.5
ip5: 127.0.0.6
- name: Add a monitor
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
monitor: true
ip1: 127.0.0.2
protocol: HTTP # default
port: 80 # default
maxEmails: 1
systemDescription: Monitor Test A record
contactList: my contact list
- name: Add a monitor with http options
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
monitor: true
ip1: 127.0.0.2
protocol: HTTP # default
port: 80 # default
maxEmails: 1
systemDescription: Monitor Test A record
contactList: 1174 # contact list id
httpFqdn: http://my.com
httpFile: example
httpQueryString: some string
- name: Add a monitor and a failover
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
failover: true
ip1: 127.0.0.2
ip2: 127.0.0.3
monitor: true
protocol: HTTPS
port: 443
maxEmails: 1
systemDescription: monitoring my.com status
contactList: emergencycontacts
- name: Remove a failover
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
failover: false
- name: Remove a monitor
community.general.dnsmadeeasy:
account_key: key
account_secret: secret
domain: my.com
state: present
record_name: test
record_type: A
record_value: 127.0.0.1
monitor: false
"""
# ============================================
# DNSMadeEasy module specific support methods.
#
import hashlib
import hmac
import json
import locale
from time import gmtime, strftime
from urllib.parse import urlencode
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.urls import fetch_url
class DME2:
def __init__(self, apikey, secret, domain, sandbox, module):
self.module = module
self.api = apikey
self.secret = secret
if sandbox:
self.baseurl = "https://api.sandbox.dnsmadeeasy.com/V2.0/"
self.module.warn(warning=f"Sandbox is enabled. All actions are made against the URL {self.baseurl}")
else:
self.baseurl = "https://api.dnsmadeeasy.com/V2.0/"
self.domain = str(domain)
self.domain_map = None # ["domain_name"] => ID
self.record_map = None # ["record_name"] => ID
self.records = None # ["record_ID"] => <record>
self.all_records = None
self.contactList_map = None # ["contactList_name"] => ID
# Lookup the domain ID if passed as a domain name vs. ID
if not self.domain.isdigit():
self.domain = self.getDomainByName(self.domain)["id"]
self.record_url = f"dns/managed/{self.domain}/records"
self.monitor_url = "monitor"
self.contactList_url = "contactList"
def _headers(self):
currTime = self._get_date()
hashstring = self._create_hash(currTime)
headers = {
"x-dnsme-apiKey": self.api,
"x-dnsme-hmac": hashstring,
"x-dnsme-requestDate": currTime,
"content-type": "application/json",
}
return headers
def _get_date(self):
locale.setlocale(locale.LC_TIME, "C")
return strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime())
def _create_hash(self, rightnow):
return hmac.new(self.secret.encode(), rightnow.encode(), hashlib.sha1).hexdigest()
def query(self, resource, method, data=None):
url = self.baseurl + resource
if data and not isinstance(data, str):
data = urlencode(data)
response, info = fetch_url(self.module, url, data=data, method=method, headers=self._headers())
if info["status"] not in (200, 201, 204):
self.module.fail_json(msg=f"{url} returned {info['status']}, with body: {info['msg']}")
try:
return json.load(response)
except Exception:
return {}
def getDomain(self, domain_id):
if not self.domain_map:
self._instMap("domain")
return self.domains.get(domain_id, False)
def getDomainByName(self, domain_name):
if not self.domain_map:
self._instMap("domain")
return self.getDomain(self.domain_map.get(domain_name, 0))
def getDomains(self):
return self.query("dns/managed", "GET")["data"]
def getRecord(self, record_id):
if not self.record_map:
self._instMap("record")
return self.records.get(record_id, False)
# Try to find a single record matching this one.
# How we do this depends on the type of record. For instance, there
# can be several MX records for a single record_name while there can
# only be a single CNAME for a particular record_name. Note also that
# there can be several records with different types for a single name.
def getMatchingRecord(self, record_name, record_type, record_value):
# Get all the records if not already cached
if not self.all_records:
self.all_records = self.getRecords()
if record_type in ["CNAME", "ANAME", "HTTPRED", "PTR"]:
for result in self.all_records:
if result["name"] == record_name and result["type"] == record_type:
return result
return False
elif record_type in ["A", "AAAA", "MX", "NS", "TXT", "SRV"]:
for result in self.all_records:
if record_type == "MX":
value = record_value.split(" ")[1]
# Note that TXT records are surrounded by quotes in the API response.
elif record_type == "TXT":
value = f'"{record_value}"'
elif record_type == "SRV":
value = record_value.split(" ")[3]
else:
value = record_value
if result["name"] == record_name and result["type"] == record_type and result["value"] == value:
return result
return False
else:
raise Exception("record_type not yet supported")
def getRecords(self):
return self.query(self.record_url, "GET")["data"]
def _instMap(self, type):
# @TODO cache this call so it is executed only once per ansible execution
map = {}
results = {}
# iterate over e.g. self.getDomains() || self.getRecords()
for result in getattr(self, f"get{type.title()}s")():
map[result["name"]] = result["id"]
results[result["id"]] = result
# e.g. self.domain_map || self.record_map
setattr(self, f"{type}_map", map)
setattr(self, f"{type}s", results) # e.g. self.domains || self.records
def prepareRecord(self, data):
return json.dumps(data, separators=(",", ":"))
def createRecord(self, data):
# @TODO update the cache w/ resultant record + id when implemented
return self.query(self.record_url, "POST", data)
def updateRecord(self, record_id, data):
# @TODO update the cache w/ resultant record + id when implemented
return self.query(f"{self.record_url}/{record_id}", "PUT", data)
def deleteRecord(self, record_id):
# @TODO remove record from the cache when implemented
return self.query(f"{self.record_url}/{record_id}", "DELETE")
def getMonitor(self, record_id):
return self.query(f"{self.monitor_url}/{record_id}", "GET")
def updateMonitor(self, record_id, data):
return self.query(f"{self.monitor_url}/{record_id}", "PUT", data)
def prepareMonitor(self, data):
return json.dumps(data, separators=(",", ":"))
def getContactList(self, contact_list_id):
if not self.contactList_map:
self._instMap("contactList")
return self.contactLists.get(contact_list_id, False)
def getContactlists(self):
return self.query(self.contactList_url, "GET")["data"]
def getContactListByName(self, name):
if not self.contactList_map:
self._instMap("contactList")
return self.getContactList(self.contactList_map.get(name, 0))
# ===========================================
# Module execution.
#
def main():
module = AnsibleModule(
argument_spec=dict(
account_key=dict(required=True, no_log=True),
account_secret=dict(required=True, no_log=True),
domain=dict(required=True),
sandbox=dict(default=False, type="bool"),
state=dict(required=True, choices=["present", "absent"]),
record_name=dict(),
record_type=dict(choices=["A", "AAAA", "CNAME", "ANAME", "HTTPRED", "MX", "NS", "PTR", "SRV", "TXT"]),
record_value=dict(),
record_ttl=dict(default=1800, type="int"),
monitor=dict(default=False, type="bool"),
systemDescription=dict(default=""),
maxEmails=dict(default=1, type="int"),
protocol=dict(default="HTTP", choices=["TCP", "UDP", "HTTP", "DNS", "SMTP", "HTTPS"]),
port=dict(default=80, type="int"),
sensitivity=dict(default="Medium", choices=["Low", "Medium", "High"]),
contactList=dict(),
httpFqdn=dict(),
httpFile=dict(),
httpQueryString=dict(),
failover=dict(default=False, type="bool"),
autoFailover=dict(default=False, type="bool"),
ip1=dict(),
ip2=dict(),
ip3=dict(),
ip4=dict(),
ip5=dict(),
validate_certs=dict(default=True, type="bool"),
),
required_together=[["record_value", "record_ttl", "record_type"]],
required_if=[
["failover", True, ["autoFailover", "port", "protocol", "ip1", "ip2"]],
["monitor", True, ["port", "protocol", "maxEmails", "systemDescription", "ip1"]],
],
)
protocols = dict(TCP=1, UDP=2, HTTP=3, DNS=4, SMTP=5, HTTPS=6)
sensitivities = dict(Low=8, Medium=5, High=3)
DME = DME2(
module.params["account_key"],
module.params["account_secret"],
module.params["domain"],
module.params["sandbox"],
module,
)
state = module.params["state"]
record_name = module.params["record_name"]
record_type = module.params["record_type"]
record_value = module.params["record_value"]
# Follow Keyword Controlled Behavior
if record_name is None:
domain_records = DME.getRecords()
if not domain_records:
module.fail_json(
msg="The requested domain name is not accessible with this api_key; try using its ID if known."
)
module.exit_json(changed=False, result=domain_records)
# Fetch existing record + Build new one
current_record = DME.getMatchingRecord(record_name, record_type, record_value)
new_record = {"name": record_name}
for i in ["record_value", "record_type", "record_ttl"]:
if module.params[i] is not None:
new_record[i[len("record_") :]] = module.params[i]
# Special handling for mx record
if new_record["type"] == "MX":
new_record["mxLevel"] = new_record["value"].split(" ")[0]
new_record["value"] = new_record["value"].split(" ")[1]
# Special handling for SRV records
if new_record["type"] == "SRV":
new_record["priority"] = new_record["value"].split(" ")[0]
new_record["weight"] = new_record["value"].split(" ")[1]
new_record["port"] = new_record["value"].split(" ")[2]
new_record["value"] = new_record["value"].split(" ")[3]
# Fetch existing monitor if the A record indicates it should exist and build the new monitor
current_monitor = dict()
new_monitor = dict()
if current_record and current_record["type"] == "A" and current_record.get("monitor"):
current_monitor = DME.getMonitor(current_record["id"])
# Build the new monitor
for i in [
"monitor",
"systemDescription",
"protocol",
"port",
"sensitivity",
"maxEmails",
"contactList",
"httpFqdn",
"httpFile",
"httpQueryString",
"failover",
"autoFailover",
"ip1",
"ip2",
"ip3",
"ip4",
"ip5",
]:
if module.params[i] is not None:
if i == "protocol":
# The API requires protocol to be a numeric in the range 1-6
new_monitor["protocolId"] = protocols[module.params[i]]
elif i == "sensitivity":
# The API requires sensitivity to be a numeric of 8, 5, or 3
new_monitor[i] = sensitivities[module.params[i]]
elif i == "contactList":
# The module accepts either the name or the id of the contact list
contact_list_id = module.params[i]
if not contact_list_id.isdigit() and contact_list_id != "":
contact_list = DME.getContactListByName(contact_list_id)
if not contact_list:
module.fail_json(msg=f"Contact list {contact_list_id} does not exist")
contact_list_id = contact_list.get("id", "")
new_monitor["contactListId"] = contact_list_id
else:
# The module option names match the API field names
new_monitor[i] = module.params[i]
# Compare new record against existing one
record_changed = False
if current_record:
for i in new_record:
# Remove leading and trailing quote character from values because TXT records
# are surrounded by quotes.
if str(current_record[i]).strip('"') != str(new_record[i]):
record_changed = True
new_record["id"] = str(current_record["id"])
monitor_changed = False
if current_monitor:
for i in new_monitor:
if str(current_monitor.get(i)) != str(new_monitor[i]):
monitor_changed = True
# Follow Keyword Controlled Behavior
if state == "present":
# return the record if no value is specified
if "value" not in new_record:
if not current_record:
module.fail_json(
msg=f"A record with name '{record_name}' does not exist for domain '{module.params['domain']}.'"
)
module.exit_json(changed=False, result=dict(record=current_record, monitor=current_monitor))
# create record and monitor as the record does not exist
if not current_record:
record = DME.createRecord(DME.prepareRecord(new_record))
if new_monitor.get("monitor") and record_type == "A":
monitor = DME.updateMonitor(record["id"], DME.prepareMonitor(new_monitor))
module.exit_json(changed=True, result=dict(record=record, monitor=monitor))
else:
module.exit_json(changed=True, result=dict(record=record, monitor=current_monitor))
# update the record
updated = False
if record_changed:
DME.updateRecord(current_record["id"], DME.prepareRecord(new_record))
updated = True
if monitor_changed:
DME.updateMonitor(current_monitor["recordId"], DME.prepareMonitor(new_monitor))
updated = True
if updated:
module.exit_json(changed=True, result=dict(record=new_record, monitor=new_monitor))
# return the record (no changes)
module.exit_json(changed=False, result=dict(record=current_record, monitor=current_monitor))
elif state == "absent":
changed = False
# delete the record (and the monitor/failover) if it exists
if current_record:
DME.deleteRecord(current_record["id"])
module.exit_json(changed=True)
# record does not exist, return w/o change.
module.exit_json(changed=changed)
else:
module.fail_json(msg=f"'{state}' is an unknown value for the state argument")
if __name__ == "__main__":
main()