mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-06-11 02:25:36 +00:00
Increase idempotency for TXT records when solo=true fix #31511, fix #67784 - Prefix-based uniqueness for TXT records with solo=true - Known RFC prefixes: v=spf1, v=DKIM1, v=DMARC1, dkim= - Otherwise, look for a key=value pattern `([\w-]+)=[\w-]+` - Split string normalization for long records (prevents false differences) - Quote difference detection (triggers updates when needed) - Single-record optimization (UPDATE instead of DELETE+CREATE) - Operation tracking (records_to_delete, records_to_update, records_to_add)
1391 lines
50 KiB
Python
1391 lines
50 KiB
Python
#!/usr/bin/python
|
|
|
|
# Copyright (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
|
# 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: cloudflare_dns
|
|
author:
|
|
- Michael Gruener (@mgruener)
|
|
short_description: Manage Cloudflare DNS records
|
|
description:
|
|
- 'Manages DNS records using the Cloudflare API, see the docs: U(https://api.cloudflare.com/).'
|
|
extends_documentation_fragment:
|
|
- community.general._attributes
|
|
attributes:
|
|
check_mode:
|
|
support: full
|
|
diff_mode:
|
|
support: none
|
|
options:
|
|
api_token:
|
|
description:
|
|
- API token.
|
|
- Required for API token authentication.
|
|
- "You can obtain your API token from the bottom of the Cloudflare 'My Account' page, found here: U(https://dash.cloudflare.com/)."
|
|
- Can be specified in E(CLOUDFLARE_TOKEN) environment variable since community.general 2.0.0.
|
|
type: str
|
|
version_added: '0.2.0'
|
|
account_api_key:
|
|
description:
|
|
- Account API key.
|
|
- Required for API keys authentication.
|
|
- "You can obtain your API key from the bottom of the Cloudflare 'My Account' page, found here: U(https://dash.cloudflare.com/)."
|
|
type: str
|
|
aliases: [account_api_token]
|
|
account_email:
|
|
description:
|
|
- Account email. Required for API keys authentication.
|
|
type: str
|
|
algorithm:
|
|
description:
|
|
- Algorithm number.
|
|
- Required for O(type=DS) and O(type=SSHFP) when O(state=present).
|
|
type: int
|
|
cert_usage:
|
|
description:
|
|
- Certificate usage number.
|
|
- Required for O(type=TLSA) when O(state=present).
|
|
type: int
|
|
choices: [0, 1, 2, 3]
|
|
comment:
|
|
description:
|
|
- Comments or notes about the DNS record.
|
|
type: str
|
|
version_added: 10.1.0
|
|
flag:
|
|
description:
|
|
- Issuer Critical Flag.
|
|
- Should be set either to V(0) for not critical, or to V(128) for critical.
|
|
- Before community.general 12.3.0, only V(0) and V(1) were valid options.
|
|
The value V(1) is still available for backwards-compatibility reasons.
|
|
- Required for O(type=CAA) when O(state=present).
|
|
type: int
|
|
choices: [0, 1, 128]
|
|
version_added: 8.0.0
|
|
tag:
|
|
description:
|
|
- CAA issue restriction.
|
|
- Required for O(type=CAA) when O(state=present).
|
|
type: str
|
|
choices: [issue, issuewild, iodef]
|
|
version_added: 8.0.0
|
|
hash_type:
|
|
description:
|
|
- Hash type number.
|
|
- Required for O(type=DS), O(type=SSHFP) and O(type=TLSA) when O(state=present).
|
|
type: int
|
|
choices: [1, 2]
|
|
key_tag:
|
|
description:
|
|
- DNSSEC key tag.
|
|
- Needed for O(type=DS) when O(state=present).
|
|
type: int
|
|
port:
|
|
description:
|
|
- Service port.
|
|
- Required for O(type=SRV) and O(type=TLSA).
|
|
type: int
|
|
priority:
|
|
description:
|
|
- Record priority.
|
|
- Required for O(type=MX) and O(type=SRV).
|
|
default: 1
|
|
type: int
|
|
proto:
|
|
description:
|
|
- Service protocol. Required for O(type=SRV) and O(type=TLSA).
|
|
- Common values are TCP and UDP.
|
|
type: str
|
|
proxied:
|
|
description:
|
|
- Proxy through Cloudflare network or just use DNS.
|
|
type: bool
|
|
default: false
|
|
record:
|
|
description:
|
|
- Record to add.
|
|
- Required if O(state=present).
|
|
- Default is V(@) (that is, the zone name).
|
|
type: str
|
|
default: '@'
|
|
aliases: [name]
|
|
selector:
|
|
description:
|
|
- Selector number.
|
|
- Required for O(type=TLSA) when O(state=present).
|
|
choices: [0, 1]
|
|
type: int
|
|
service:
|
|
description:
|
|
- Record service.
|
|
- Required for O(type=SRV).
|
|
type: str
|
|
solo:
|
|
description:
|
|
- Whether the record should be the only one for that record type and record name.
|
|
- Only use with O(state=present).
|
|
- This deletes all other records with the same record name and type.
|
|
- For O(type=TXT), uniqueness is ensured based on value prefix to preserve different TXT record types.
|
|
- Known RFC-defined prefixes are V(v=spf1) (SPF), V(v=DKIM1) (DKIM), V(v=DMARC1) (DMARC), V(dkim=) (ADSP).
|
|
- Custom verification records use pattern V(key=) extracted from V(key=value) format.
|
|
- Only TXT records with matching prefixes are deleted; other TXT records are preserved.
|
|
- If no prefix pattern is identified, all other TXT records are preserved.
|
|
type: bool
|
|
state:
|
|
description:
|
|
- Whether the record(s) should exist or not.
|
|
type: str
|
|
choices: [absent, present]
|
|
default: present
|
|
tags:
|
|
description:
|
|
- Custom tags for the DNS record.
|
|
type: list
|
|
elements: str
|
|
version_added: 10.1.0
|
|
timeout:
|
|
description:
|
|
- Timeout for Cloudflare API calls.
|
|
type: int
|
|
default: 30
|
|
ttl:
|
|
description:
|
|
- The TTL to give the new record.
|
|
- Must be between V(120) and V(2,147,483,647) seconds, or V(1) for automatic.
|
|
type: int
|
|
default: 1
|
|
type:
|
|
description:
|
|
- The type of DNS record to create. Required if O(state=present).
|
|
- Support for V(SPF) has been removed from community.general 9.0.0 since that record type is no longer supported by
|
|
CloudFlare.
|
|
- Support for V(PTR) has been added in community.general 11.1.0.
|
|
type: str
|
|
choices: [A, AAAA, CNAME, DS, MX, NS, SRV, SSHFP, TLSA, CAA, TXT, PTR]
|
|
value:
|
|
description:
|
|
- The record value.
|
|
- Required for O(state=present).
|
|
type: str
|
|
aliases: [content]
|
|
weight:
|
|
description:
|
|
- Service weight.
|
|
- Required for O(type=SRV).
|
|
type: int
|
|
default: 1
|
|
zone:
|
|
description:
|
|
- The name of the Zone to work with (for example V(example.com)).
|
|
- The Zone must already exist.
|
|
type: str
|
|
required: true
|
|
aliases: [domain]
|
|
"""
|
|
|
|
EXAMPLES = r"""
|
|
- name: Create a test.example.net A record to point to 127.0.0.1
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
record: test
|
|
type: A
|
|
value: 127.0.0.1
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
register: record
|
|
|
|
- name: Create a record using api token
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
record: test
|
|
type: A
|
|
value: 127.0.0.1
|
|
api_token: dummyapitoken
|
|
|
|
- name: Create a record with comment and tags
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
record: test
|
|
type: A
|
|
value: 127.0.0.1
|
|
comment: Local test website
|
|
tags:
|
|
- test
|
|
- local
|
|
api_token: dummyapitoken
|
|
|
|
- name: Create a example.net CNAME record to example.com
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
type: CNAME
|
|
value: example.com
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
- name: Change its TTL
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
type: CNAME
|
|
value: example.com
|
|
ttl: 600
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
- name: Delete the record
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
type: CNAME
|
|
value: example.com
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: absent
|
|
|
|
- name: Create a example.net CNAME record to example.com and proxy through Cloudflare's network
|
|
community.general.cloudflare_dns:
|
|
zone: example.net
|
|
type: CNAME
|
|
value: example.com
|
|
proxied: true
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
# For TXT records with solo=true, only records with matching prefix are deleted
|
|
# This deletes other TXT records with prefix "v=spf1" (SPF records)
|
|
# Other TXT records (DKIM, DMARC, verification, etc.) are preserved
|
|
- name: Create SPF TXT record with solo=true
|
|
community.general.cloudflare_dns:
|
|
domain: example.net
|
|
record: test
|
|
type: TXT
|
|
value: v=spf1 include:_spf.example.com ~all
|
|
solo: true
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
# This deletes other TXT records with prefix "v=DMARC1" only
|
|
- name: Create DMARC TXT record with solo=true
|
|
community.general.cloudflare_dns:
|
|
domain: example.net
|
|
record: _dmarc
|
|
type: TXT
|
|
value: v=DMARC1; p=none; rua=mailto:dmarc@example.com
|
|
solo: true
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
# This deletes other TXT records with prefix "google-site-verification=" only
|
|
- name: Create domain verification TXT record with solo=true
|
|
community.general.cloudflare_dns:
|
|
domain: example.net
|
|
record: test
|
|
type: TXT
|
|
value: google-site-verification=abc123xyz
|
|
solo: true
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
# This preserves all other TXT records since no prefix pattern is identified
|
|
- name: Create TXT record without a recognizable prefix
|
|
community.general.cloudflare_dns:
|
|
domain: example.net
|
|
record: test
|
|
type: TXT
|
|
value: some random text
|
|
solo: true
|
|
account_email: test@example.com
|
|
account_api_key: dummyapitoken
|
|
state: present
|
|
|
|
- name: Create an SRV record _foo._tcp.example.net
|
|
community.general.cloudflare_dns:
|
|
domain: example.net
|
|
service: foo
|
|
proto: tcp
|
|
port: 3500
|
|
priority: 10
|
|
weight: 20
|
|
type: SRV
|
|
value: fooserver.example.net
|
|
|
|
- name: Create a SSHFP record login.example.com
|
|
community.general.cloudflare_dns:
|
|
zone: example.com
|
|
record: login
|
|
type: SSHFP
|
|
algorithm: 4
|
|
hash_type: 2
|
|
value: 9dc1d6742696d2f51ca1f1a78b3d16a840f7d111eb9454239e70db31363f33e1
|
|
|
|
- name: Create a TLSA record _25._tcp.mail.example.com
|
|
community.general.cloudflare_dns:
|
|
zone: example.com
|
|
record: mail
|
|
port: 25
|
|
proto: tcp
|
|
type: TLSA
|
|
cert_usage: 3
|
|
selector: 1
|
|
hash_type: 1
|
|
value: 6b76d034492b493e15a7376fccd08e63befdad0edab8e442562f532338364bf3
|
|
|
|
- name: Create a CAA record subdomain.example.com
|
|
community.general.cloudflare_dns:
|
|
zone: example.com
|
|
record: subdomain
|
|
type: CAA
|
|
flag: 0
|
|
tag: issue
|
|
value: ca.example.com
|
|
|
|
- name: Create a DS record for subdomain.example.com
|
|
community.general.cloudflare_dns:
|
|
zone: example.com
|
|
record: subdomain
|
|
type: DS
|
|
key_tag: 5464
|
|
algorithm: 8
|
|
hash_type: 2
|
|
value: B4EB5AC4467D2DFB3BAF9FB9961DC1B6FED54A58CDFAA3E465081EC86F89BFAB
|
|
|
|
- name: Create PTR record "1.2.0.192.in-addr.arpa" with value "test.example.com"
|
|
community.general.cloudflare_dns:
|
|
zone: 2.0.192.in-addr.arpa
|
|
record: 1
|
|
type: PTR
|
|
value: test.example.com
|
|
state: present
|
|
"""
|
|
|
|
RETURN = r"""
|
|
record:
|
|
description: A dictionary containing the record data.
|
|
returned: success, except on record deletion
|
|
type: complex
|
|
contains:
|
|
comment:
|
|
description: Comments or notes about the DNS record.
|
|
returned: success
|
|
type: str
|
|
sample: Domain verification record
|
|
version_added: 10.1.0
|
|
comment_modified_on:
|
|
description: When the record comment was last modified. Omitted if there is no comment.
|
|
returned: success
|
|
type: str
|
|
sample: "2024-01-01T05:20:00.12345Z"
|
|
version_added: 10.1.0
|
|
content:
|
|
description: The record content (details depend on record type).
|
|
returned: success
|
|
type: str
|
|
sample: 192.0.2.91
|
|
created_on:
|
|
description: The record creation date.
|
|
returned: success
|
|
type: str
|
|
sample: "2016-03-25T19:09:42.516553Z"
|
|
data:
|
|
description: Additional record data.
|
|
returned: success, if type is SRV, DS, SSHFP TLSA or CAA
|
|
type: dict
|
|
sample:
|
|
{
|
|
"name": "jabber",
|
|
"port": 8080,
|
|
"priority": 10,
|
|
"proto": "_tcp",
|
|
"service": "_xmpp",
|
|
"target": "jabberhost.sample.com",
|
|
"weight": 5
|
|
}
|
|
id:
|
|
description: The record ID.
|
|
returned: success
|
|
type: str
|
|
sample: f9efb0549e96abcb750de63b38c9576e
|
|
locked:
|
|
description: No documentation available.
|
|
returned: success
|
|
type: bool
|
|
sample: false
|
|
meta:
|
|
description: Extra Cloudflare-specific information about the record.
|
|
returned: success
|
|
type: dict
|
|
sample: {"auto_added": false}
|
|
modified_on:
|
|
description: Record modification date.
|
|
returned: success
|
|
type: str
|
|
sample: "2016-03-25T19:09:42.516553Z"
|
|
name:
|
|
description: The record name as FQDN (including _service and _proto for SRV).
|
|
returned: success
|
|
type: str
|
|
sample: www.sample.com
|
|
priority:
|
|
description: Priority of the MX record.
|
|
returned: success, if type is MX
|
|
type: int
|
|
sample: 10
|
|
proxiable:
|
|
description: Whether this record can be proxied through Cloudflare.
|
|
returned: success
|
|
type: bool
|
|
sample: false
|
|
proxied:
|
|
description: Whether the record is proxied through Cloudflare.
|
|
returned: success
|
|
type: bool
|
|
sample: false
|
|
tags:
|
|
description: Custom tags for the DNS record.
|
|
returned: success
|
|
type: list
|
|
elements: str
|
|
sample: ["production", "app"]
|
|
version_added: 10.1.0
|
|
tags_modified_on:
|
|
description: When the record tags were last modified. Omitted if there are no tags.
|
|
returned: success
|
|
type: str
|
|
sample: "2025-01-01T05:20:00.12345Z"
|
|
version_added: 10.1.0
|
|
ttl:
|
|
description: The time-to-live for the record.
|
|
returned: success
|
|
type: int
|
|
sample: 300
|
|
type:
|
|
description: The record type.
|
|
returned: success
|
|
type: str
|
|
sample: A
|
|
zone_id:
|
|
description: The ID of the zone containing the record.
|
|
returned: success
|
|
type: str
|
|
sample: abcede0bf9f0066f94029d2e6b73856a
|
|
zone_name:
|
|
description: The name of the zone containing the record.
|
|
returned: success
|
|
type: str
|
|
sample: sample.com
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from urllib.parse import urlencode
|
|
|
|
from ansible.module_utils.basic import AnsibleModule, env_fallback
|
|
from ansible.module_utils.common.text.converters import to_text
|
|
from ansible.module_utils.urls import fetch_url
|
|
|
|
|
|
def lowercase_string(param):
|
|
return param.lower() if isinstance(param, str) else param
|
|
|
|
|
|
def join_str(sep, *args):
|
|
return sep.join([str(arg) for arg in args])
|
|
|
|
|
|
def normalize_txt_split_strings(value):
|
|
"""
|
|
Normalize TXT record value by joining split strings while preserving quotes.
|
|
|
|
Long DNS TXT records can be split into multiple quoted strings:
|
|
'"part1" "part2"' should be treated as '"part1part2"' for comparison
|
|
|
|
This function handles split strings but preserves quote differences so that
|
|
'"value"' and 'value' are considered different (and will trigger an update).
|
|
|
|
Args:
|
|
value: The TXT record value string
|
|
|
|
Returns:
|
|
Normalized string with split strings joined but quotes preserved
|
|
|
|
Examples:
|
|
'"part1" "part2"' -> '"part1part2"'
|
|
'"value"' -> '"value"' (unchanged)
|
|
'value' -> 'value' (unchanged)
|
|
"""
|
|
if not value:
|
|
return value
|
|
|
|
# Check if value contains multiple quoted strings (split string pattern)
|
|
# Pattern: "..." "..." or '...' '...'
|
|
|
|
# Join multiple quoted strings together
|
|
# Match sequences of quoted strings with whitespace between them
|
|
def join_quoted_strings(match):
|
|
# Extract all the content between quotes and rejoin
|
|
parts = re.findall(r'["\']([^"\']*)["\']', match.group(0))
|
|
quote_char = match.group(0)[0] # Use the first quote character found
|
|
return quote_char + ''.join(parts) + quote_char
|
|
|
|
# Pattern to match multiple consecutive quoted strings
|
|
normalized = re.sub(r'(["\'][^"\']*["\'](?:\s+["\'][^"\']*["\'])+)', join_quoted_strings, value)
|
|
|
|
return normalized
|
|
|
|
|
|
def normalize_txt_value(value):
|
|
"""
|
|
Normalize TXT record value by removing quotes and joining split strings.
|
|
|
|
This function is used for prefix extraction where quotes should be ignored.
|
|
|
|
Args:
|
|
value: The TXT record value string
|
|
|
|
Returns:
|
|
Normalized string with quotes removed and parts joined
|
|
|
|
Examples:
|
|
'"v=spf1 include:example.com ~all"' -> 'v=spf1 include:example.com ~all'
|
|
'"part1" "part2"' -> 'part1part2'
|
|
'v=spf1 ...' -> 'v=spf1 ...'
|
|
"""
|
|
if not value:
|
|
return value
|
|
|
|
# Remove all quote characters (both single and double)
|
|
# This handles both quoted and split quoted strings
|
|
normalized = value.replace('"', '').replace("'", '')
|
|
|
|
# Remove extra whitespace that may have been between quoted parts
|
|
normalized = ' '.join(normalized.split())
|
|
|
|
return normalized
|
|
|
|
|
|
def extract_txt_prefix(value):
|
|
r"""
|
|
Extract prefix from TXT record value for uniqueness checking.
|
|
|
|
Identifies known RFC-defined TXT record prefixes or custom key=value patterns.
|
|
Normalizes value first to handle quotes and split strings.
|
|
|
|
Known prefixes (RFC-defined, treated as complete prefix including value):
|
|
- SPF (RFC 7208): 'v=spf1'
|
|
- DKIM (RFC 6376): 'v=DKIM1'
|
|
- DMARC (RFC 7489): 'v=DMARC1'
|
|
- ADSP (RFC 5617): 'dkim='
|
|
- Domain verification patterns: matches ([\w-]+)=[\w-]+ to avoid base64 values
|
|
|
|
Args:
|
|
value: The TXT record value string
|
|
|
|
Returns:
|
|
The prefix string if found, None otherwise
|
|
|
|
Examples:
|
|
'v=spf1 include:_spf.example.com ~all' -> 'v=spf1'
|
|
'"v=spf1 include:_spf.example.com ~all"' -> 'v=spf1'
|
|
'"part1" "part2"' -> extracts prefix from 'part1part2'
|
|
'v=DKIM1; k=rsa; p=MIGfMA0...' -> 'v=DKIM1'
|
|
'v=DMARC1; p=none; rua=mailto:...' -> 'v=DMARC1'
|
|
'dkim=all' -> 'dkim='
|
|
'google-site-verification=abc123' -> 'google-site-verification='
|
|
'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ==' -> None (base64, no prefix)
|
|
'some random text' -> None
|
|
"""
|
|
if not value:
|
|
return None
|
|
|
|
# Normalize the value to handle quotes and split strings
|
|
cleaned_value = normalize_txt_value(value)
|
|
|
|
# Known RFC-defined TXT record prefixes (complete prefix including the value part)
|
|
# These are treated as atomic prefixes
|
|
known_prefixes = [
|
|
'v=spf1', # SPF (RFC 7208)
|
|
'v=DKIM1', # DKIM (RFC 6376)
|
|
'v=DMARC1', # DMARC (RFC 7489)
|
|
'dkim=', # ADSP (RFC 5617)
|
|
]
|
|
|
|
# Check for known prefixes first
|
|
for prefix in known_prefixes:
|
|
if cleaned_value.startswith(prefix):
|
|
return prefix
|
|
|
|
# For other patterns, match key=value format where value starts with word/hyphen chars
|
|
# Pattern ([\w-]+)=[\w-]+ ensures we don't match base64 values ending in "=="
|
|
# This handles domain verification records like google-site-verification=abc123
|
|
match = re.match(r'^([\w-]+)=[\w-]+', cleaned_value)
|
|
if match:
|
|
return match.group(1) + '='
|
|
|
|
return None
|
|
|
|
|
|
class CloudflareAPI:
|
|
cf_api_endpoint = "https://api.cloudflare.com/client/v4"
|
|
changed = False
|
|
|
|
def __init__(self, module):
|
|
self.module = module
|
|
self.api_token = module.params["api_token"]
|
|
self.account_api_key = module.params["account_api_key"]
|
|
self.account_email = module.params["account_email"]
|
|
self.algorithm = module.params["algorithm"]
|
|
self.cert_usage = module.params["cert_usage"]
|
|
self.comment = module.params["comment"]
|
|
self.hash_type = module.params["hash_type"]
|
|
self.flag = module.params["flag"]
|
|
self.tag = module.params["tag"]
|
|
self.tags = module.params["tags"]
|
|
self.key_tag = module.params["key_tag"]
|
|
self.port = module.params["port"]
|
|
self.priority = module.params["priority"]
|
|
self.proto = lowercase_string(module.params["proto"])
|
|
self.proxied = module.params["proxied"]
|
|
self.selector = module.params["selector"]
|
|
self.record = lowercase_string(module.params["record"])
|
|
self.service = lowercase_string(module.params["service"])
|
|
self.is_solo = module.params["solo"]
|
|
self.state = module.params["state"]
|
|
self.timeout = module.params["timeout"]
|
|
self.ttl = module.params["ttl"]
|
|
self.type = module.params["type"]
|
|
self.value = module.params["value"]
|
|
self.weight = module.params["weight"]
|
|
self.zone = lowercase_string(module.params["zone"])
|
|
self.txt_single_record_update = False # Track if TXT record should be updated instead of deleted
|
|
# Track operations for check mode reporting
|
|
self.records_to_delete = []
|
|
self.records_to_update = []
|
|
self.records_to_add = []
|
|
|
|
if self.record == "@":
|
|
self.record = self.zone
|
|
|
|
if (self.type in ["CNAME", "NS", "MX", "SRV"]) and (self.value is not None):
|
|
self.value = self.value.rstrip(".").lower()
|
|
|
|
if (self.type == "AAAA") and (self.value is not None):
|
|
self.value = self.value.lower()
|
|
|
|
if self.type == "SRV":
|
|
if (self.proto is not None) and (not self.proto.startswith("_")):
|
|
self.proto = f"_{self.proto}"
|
|
if (self.service is not None) and (not self.service.startswith("_")):
|
|
self.service = f"_{self.service}"
|
|
|
|
if self.type == "TLSA":
|
|
if (self.proto is not None) and (not self.proto.startswith("_")):
|
|
self.proto = f"_{self.proto}"
|
|
if self.port is not None:
|
|
self.port = f"_{self.port}"
|
|
|
|
if not self.record.endswith(self.zone):
|
|
self.record = join_str(".", self.record, self.zone)
|
|
|
|
if self.type == "DS":
|
|
if self.record == self.zone:
|
|
self.module.fail_json(msg="DS records only apply to subdomains.")
|
|
|
|
def _cf_simple_api_call(self, api_call, method="GET", payload=None):
|
|
if self.api_token:
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
else:
|
|
headers = {
|
|
"X-Auth-Email": self.account_email,
|
|
"X-Auth-Key": self.account_api_key,
|
|
"Content-Type": "application/json",
|
|
}
|
|
data = None
|
|
if payload:
|
|
try:
|
|
data = json.dumps(payload)
|
|
except Exception as e:
|
|
self.module.fail_json(msg=f"Failed to encode payload as JSON: {e} ")
|
|
|
|
resp, info = fetch_url(
|
|
self.module,
|
|
self.cf_api_endpoint + api_call,
|
|
headers=headers,
|
|
data=data,
|
|
method=method,
|
|
timeout=self.timeout,
|
|
)
|
|
|
|
if info["status"] not in [200, 304, 400, 401, 403, 429, 405, 415]:
|
|
self.module.fail_json(
|
|
msg=f"Failed API call {api_call}; got unexpected HTTP code {info['status']}: {info.get('msg')}"
|
|
)
|
|
|
|
error_msg = ""
|
|
if info["status"] == 401:
|
|
# Unauthorized
|
|
error_msg = (
|
|
f"API user does not have permission; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
)
|
|
elif info["status"] == 403:
|
|
# Forbidden
|
|
error_msg = f"API request not authenticated; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
elif info["status"] == 429:
|
|
# Too many requests
|
|
error_msg = f"API client is rate limited; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
elif info["status"] == 405:
|
|
# Method not allowed
|
|
error_msg = (
|
|
f"API incorrect HTTP method provided; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
)
|
|
elif info["status"] == 415:
|
|
# Unsupported Media Type
|
|
error_msg = f"API request is not valid JSON; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
elif info["status"] == 400:
|
|
# Bad Request
|
|
error_msg = f"API bad request; Status: {info['status']}; Method: {method}: Call: {api_call}"
|
|
|
|
result = None
|
|
try:
|
|
content = resp.read()
|
|
except AttributeError:
|
|
content = None
|
|
|
|
if not content:
|
|
if info["body"]:
|
|
content = info["body"]
|
|
else:
|
|
error_msg += "; The API response was empty"
|
|
|
|
if content:
|
|
try:
|
|
result = json.loads(to_text(content, errors="surrogate_or_strict"))
|
|
except getattr(json, "JSONDecodeError", ValueError) as e:
|
|
error_msg += f"; Failed to parse API response with error {e}: {content}"
|
|
|
|
# Without a valid/parsed JSON response no more error processing can be done
|
|
if result is None:
|
|
self.module.fail_json(msg=error_msg)
|
|
|
|
if "success" not in result:
|
|
error_msg += f"; Unexpected error details: {result.get('error')}"
|
|
self.module.fail_json(msg=error_msg)
|
|
|
|
if not result["success"]:
|
|
error_msg += "; Error details: "
|
|
for error in result["errors"]:
|
|
error_msg += f"code: {error['code']}, error: {error['message']}; "
|
|
if "error_chain" in error:
|
|
for chain_error in error["error_chain"]:
|
|
error_msg += f"code: {chain_error['code']}, error: {chain_error['message']}; "
|
|
self.module.fail_json(msg=error_msg)
|
|
|
|
return result, info["status"]
|
|
|
|
def _cf_api_call(self, api_call, method="GET", payload=None):
|
|
result, status = self._cf_simple_api_call(api_call, method, payload)
|
|
|
|
data = result["result"]
|
|
|
|
if "result_info" in result:
|
|
pagination = result["result_info"]
|
|
if pagination["total_pages"] > 1:
|
|
next_page = int(pagination["page"]) + 1
|
|
parameters = [f"page={next_page}"]
|
|
# strip "page" parameter from call parameters (if there are any)
|
|
if "?" in api_call:
|
|
raw_api_call, query = api_call.split("?", 1)
|
|
parameters += [param for param in query.split("&") if not param.startswith("page")]
|
|
else:
|
|
raw_api_call = api_call
|
|
while next_page <= pagination["total_pages"]:
|
|
raw_api_call += f"?{'&'.join(parameters)}"
|
|
result, status = self._cf_simple_api_call(raw_api_call, method, payload)
|
|
data += result["result"]
|
|
next_page += 1
|
|
|
|
return data, status
|
|
|
|
def _get_zone_id(self, zone=None):
|
|
if not zone:
|
|
zone = self.zone
|
|
|
|
zones = self.get_zones(zone)
|
|
if len(zones) > 1:
|
|
self.module.fail_json(msg=f"More than one zone matches {zone}")
|
|
|
|
if len(zones) < 1:
|
|
self.module.fail_json(msg=f"No zone found with name {zone}")
|
|
|
|
return zones[0]["id"]
|
|
|
|
def get_zones(self, name=None):
|
|
if not name:
|
|
name = self.zone
|
|
param = ""
|
|
if name:
|
|
param = f"?{urlencode({'name': name})}"
|
|
zones, status = self._cf_api_call(f"/zones{param}")
|
|
return zones
|
|
|
|
def get_dns_records(self, zone_name=None, type=None, record=None, value=""):
|
|
if not zone_name:
|
|
zone_name = self.zone
|
|
if not type:
|
|
type = self.type
|
|
if not record:
|
|
record = self.record
|
|
# necessary because None as value means to override user
|
|
# set module value
|
|
if (not value) and (value is not None):
|
|
value = self.value
|
|
|
|
zone_id = self._get_zone_id()
|
|
api_call = f"/zones/{zone_id}/dns_records"
|
|
query = {}
|
|
if type:
|
|
query["type"] = type
|
|
if record:
|
|
query["name"] = record
|
|
if value:
|
|
query["content"] = value
|
|
if query:
|
|
api_call += f"?{urlencode(query)}"
|
|
|
|
records, status = self._cf_api_call(api_call)
|
|
return records
|
|
|
|
def delete_dns_records(self, solo):
|
|
records = []
|
|
content = self.value
|
|
search_record = self.record
|
|
if self.type == "SRV":
|
|
if not (self.value is None or self.value == ""):
|
|
content = join_str("\t", self.weight, self.port, self.value)
|
|
search_record = join_str(".", self.service, self.proto, self.record)
|
|
elif self.type == "DS":
|
|
if not (self.value is None or self.value == ""):
|
|
content = join_str("\t", self.key_tag, self.algorithm, self.hash_type, self.value)
|
|
elif self.type == "SSHFP":
|
|
if not (self.value is None or self.value == ""):
|
|
content = join_str(" ", self.algorithm, self.hash_type, self.value.upper())
|
|
elif self.type == "TLSA":
|
|
if not (self.value is None or self.value == ""):
|
|
content = join_str("\t", self.cert_usage, self.selector, self.hash_type, self.value)
|
|
search_record = join_str(".", self.port, self.proto, self.record)
|
|
if solo:
|
|
search_value = None
|
|
else:
|
|
search_value = content
|
|
|
|
zone_id = self._get_zone_id(self.zone)
|
|
records = self.get_dns_records(self.zone, self.type, search_record, search_value)
|
|
|
|
# For TXT records with solo=true, implement prefix-based deletion logic
|
|
txt_prefix = None
|
|
txt_single_record_update = False
|
|
if solo and self.type == 'TXT':
|
|
txt_prefix = extract_txt_prefix(self.value)
|
|
if txt_prefix:
|
|
# Count how many records have the matching prefix (excluding exact match)
|
|
# Normalize content for comparison to handle quotes and split strings
|
|
normalized_content = normalize_txt_value(content)
|
|
matching_prefix_records = []
|
|
for rr in records:
|
|
if rr['type'] == 'TXT' and rr['name'] == search_record:
|
|
normalized_rr_content = normalize_txt_value(rr['content'])
|
|
# Skip if content is the same after normalization
|
|
if normalized_rr_content == normalized_content:
|
|
continue
|
|
rr_prefix = extract_txt_prefix(rr['content'])
|
|
if rr_prefix == txt_prefix:
|
|
matching_prefix_records.append(rr)
|
|
|
|
# If only one record with matching prefix, skip deletion and let ensure_dns_record update it
|
|
if len(matching_prefix_records) == 1:
|
|
txt_single_record_update = True
|
|
self.txt_single_record_update = True # Set instance variable for ensure_dns_record
|
|
# Track for reporting
|
|
self.records_to_update.append({
|
|
'id': matching_prefix_records[0]['id'],
|
|
'name': matching_prefix_records[0]['name'],
|
|
'type': matching_prefix_records[0]['type'],
|
|
'old_content': matching_prefix_records[0]['content'],
|
|
'new_content': content,
|
|
'prefix': txt_prefix
|
|
})
|
|
elif len(matching_prefix_records) > 1:
|
|
# Track multiple records to be deleted
|
|
for rr in matching_prefix_records:
|
|
self.records_to_delete.append({
|
|
'id': rr['id'],
|
|
'name': rr['name'],
|
|
'type': rr['type'],
|
|
'content': rr['content'],
|
|
'prefix': txt_prefix
|
|
})
|
|
|
|
for rr in records:
|
|
if solo:
|
|
# Skip the record we're trying to create/ensure
|
|
# For TXT records, normalize content before comparison
|
|
if self.type == 'TXT':
|
|
if (rr['type'] == self.type) and (rr['name'] == search_record) and (normalize_txt_value(rr['content']) == normalize_txt_value(content)):
|
|
continue
|
|
else:
|
|
if (rr['type'] == self.type) and (rr['name'] == search_record) and (rr['content'] == content):
|
|
continue
|
|
|
|
# For TXT records, apply prefix-based deletion logic
|
|
if self.type == 'TXT' and txt_prefix:
|
|
# If single record update mode, skip deletion (let ensure_dns_record handle the update)
|
|
if txt_single_record_update:
|
|
continue
|
|
|
|
rr_prefix = extract_txt_prefix(rr['content'])
|
|
# Only delete if the existing record has the same prefix
|
|
if rr_prefix == txt_prefix:
|
|
# Track deletion (if not already tracked in the prefix matching logic above)
|
|
if not any(d['id'] == rr['id'] for d in self.records_to_delete):
|
|
self.records_to_delete.append({
|
|
'id': rr['id'],
|
|
'name': rr['name'],
|
|
'type': rr['type'],
|
|
'content': rr['content'],
|
|
'prefix': txt_prefix
|
|
})
|
|
self.changed = True
|
|
if not self.module.check_mode:
|
|
result, info = self._cf_api_call(f"/zones/{zone_id}/dns_records/{rr['id']}", "DELETE")
|
|
# If no matching prefix, preserve the record (do nothing)
|
|
elif self.type == 'TXT' and not txt_prefix:
|
|
# No prefix identified, preserve all other TXT records (do nothing)
|
|
pass
|
|
else:
|
|
# For non-TXT record types, delete all other records of the same type and name
|
|
self.changed = True
|
|
if not self.module.check_mode:
|
|
result, info = self._cf_api_call(f"/zones/{zone_id}/dns_records/{rr['id']}", "DELETE")
|
|
else:
|
|
self.changed = True
|
|
if not self.module.check_mode:
|
|
result, info = self._cf_api_call(f"/zones/{zone_id}/dns_records/{rr['id']}", "DELETE")
|
|
return self.changed
|
|
|
|
def ensure_dns_record(self):
|
|
search_value = self.value
|
|
search_record = self.record
|
|
new_record = None
|
|
|
|
if self.type in ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "PTR"]:
|
|
if not self.value:
|
|
self.module.fail_json(msg="You must provide a non-empty value to create this record type")
|
|
|
|
# there can only be one CNAME per record
|
|
# ignoring the value when searching for existing
|
|
# CNAME records allows us to update the value if it
|
|
# changes
|
|
if self.type == "CNAME":
|
|
search_value = None
|
|
|
|
# For TXT records with solo=true, search without content filter
|
|
# to find existing records (will filter by content/prefix later)
|
|
# This avoids issues with quote differences in the API search
|
|
if self.type == 'TXT' and self.is_solo:
|
|
search_value = None
|
|
|
|
new_record = {"type": self.type, "name": self.record, "content": self.value, "ttl": self.ttl}
|
|
|
|
if self.type in ["A", "AAAA", "CNAME"]:
|
|
new_record["proxied"] = self.proxied
|
|
|
|
if self.type == "MX":
|
|
for attr in [self.priority, self.value]:
|
|
if (attr is None) or (attr == ""):
|
|
self.module.fail_json(msg="You must provide priority and a value to create this record type")
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": self.record,
|
|
"content": self.value,
|
|
"priority": self.priority,
|
|
"ttl": self.ttl,
|
|
}
|
|
|
|
if self.type == "SRV":
|
|
for attr in [self.port, self.priority, self.proto, self.service, self.weight, self.value]:
|
|
if (attr is None) or (attr == ""):
|
|
self.module.fail_json(
|
|
msg="You must provide port, priority, proto, service, weight and a value to create this record type"
|
|
)
|
|
srv_data = {
|
|
"target": self.value,
|
|
"port": self.port,
|
|
"weight": self.weight,
|
|
"priority": self.priority,
|
|
}
|
|
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": join_str(".", self.service, self.proto, self.record),
|
|
"ttl": self.ttl,
|
|
"data": srv_data,
|
|
}
|
|
search_value = join_str("\t", self.weight, self.port, self.value)
|
|
search_record = join_str(".", self.service, self.proto, self.record)
|
|
|
|
if self.type == "DS":
|
|
for attr in [self.key_tag, self.algorithm, self.hash_type, self.value]:
|
|
if (attr is None) or (attr == ""):
|
|
self.module.fail_json(
|
|
msg="You must provide key_tag, algorithm, hash_type and a value to create this record type"
|
|
)
|
|
ds_data = {
|
|
"key_tag": self.key_tag,
|
|
"algorithm": self.algorithm,
|
|
"digest_type": self.hash_type,
|
|
"digest": self.value,
|
|
}
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": self.record,
|
|
"data": ds_data,
|
|
"ttl": self.ttl,
|
|
}
|
|
search_value = join_str("\t", self.key_tag, self.algorithm, self.hash_type, self.value)
|
|
|
|
if self.type == "SSHFP":
|
|
for attr in [self.algorithm, self.hash_type, self.value]:
|
|
if (attr is None) or (attr == ""):
|
|
self.module.fail_json(
|
|
msg="You must provide algorithm, hash_type and a value to create this record type"
|
|
)
|
|
sshfp_data = {
|
|
"fingerprint": self.value.upper(),
|
|
"type": self.hash_type,
|
|
"algorithm": self.algorithm,
|
|
}
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": self.record,
|
|
"data": sshfp_data,
|
|
"ttl": self.ttl,
|
|
}
|
|
search_value = join_str(" ", self.algorithm, self.hash_type, self.value)
|
|
|
|
if self.type == "TLSA":
|
|
for attr in [self.port, self.proto, self.cert_usage, self.selector, self.hash_type, self.value]:
|
|
if (attr is None) or (attr == ""):
|
|
self.module.fail_json(
|
|
msg="You must provide port, proto, cert_usage, selector, hash_type and a value to create this record type"
|
|
)
|
|
search_record = join_str(".", self.port, self.proto, self.record)
|
|
tlsa_data = {
|
|
"usage": self.cert_usage,
|
|
"selector": self.selector,
|
|
"matching_type": self.hash_type,
|
|
"certificate": self.value,
|
|
}
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": search_record,
|
|
"data": tlsa_data,
|
|
"ttl": self.ttl,
|
|
}
|
|
search_value = join_str("\t", self.cert_usage, self.selector, self.hash_type, self.value)
|
|
|
|
if self.type == "CAA":
|
|
for attr in [self.flag, self.tag, self.value]:
|
|
if attr == "":
|
|
self.module.fail_json(msg="You must provide flag, tag and a value to create this record type")
|
|
caa_data = {
|
|
"flags": self.flag,
|
|
"tag": self.tag,
|
|
"value": self.value,
|
|
}
|
|
new_record = {
|
|
"type": self.type,
|
|
"name": self.record,
|
|
"data": caa_data,
|
|
"ttl": self.ttl,
|
|
}
|
|
search_value = None
|
|
|
|
new_record["comment"] = self.comment or None
|
|
new_record["tags"] = self.tags or []
|
|
|
|
zone_id = self._get_zone_id(self.zone)
|
|
records = self.get_dns_records(self.zone, self.type, search_record, search_value)
|
|
|
|
# For TXT records with solo=true, filter results by content/prefix
|
|
if self.type == 'TXT' and self.is_solo and len(records) > 0:
|
|
txt_prefix = extract_txt_prefix(self.value)
|
|
normalized_new_content = normalize_txt_value(self.value)
|
|
matching_records = []
|
|
|
|
if txt_prefix:
|
|
# Has a prefix: filter by matching prefix
|
|
for rr in records:
|
|
rr_prefix = extract_txt_prefix(rr['content'])
|
|
if rr_prefix == txt_prefix:
|
|
matching_records.append(rr)
|
|
else:
|
|
# No prefix: filter by normalized content (ignoring quotes)
|
|
for rr in records:
|
|
if normalize_txt_value(rr['content']) == normalized_new_content:
|
|
matching_records.append(rr)
|
|
|
|
records = matching_records
|
|
|
|
# If exactly one match found, enable single-record update mode
|
|
if len(records) == 1:
|
|
self.txt_single_record_update = True
|
|
|
|
# in theory this should be impossible as cloudflare does not allow
|
|
# the creation of duplicate records but lets cover it anyways
|
|
if len(records) > 1:
|
|
# As Cloudflare API cannot filter record containing quotes
|
|
# CAA records must be compared locally
|
|
if self.type == "CAA":
|
|
for rr in records:
|
|
if (
|
|
rr["data"]["flags"] == caa_data["flags"]
|
|
and rr["data"]["tag"] == caa_data["tag"]
|
|
and rr["data"]["value"] == caa_data["value"]
|
|
):
|
|
return rr, self.changed
|
|
else:
|
|
self.module.fail_json(
|
|
msg="More than one record already exists for the given attributes. That should be impossible, please open an issue!"
|
|
)
|
|
# record already exists, check if it must be updated
|
|
if len(records) == 1:
|
|
cur_record = records[0]
|
|
do_update = False
|
|
if (self.ttl is not None) and (cur_record["ttl"] != self.ttl):
|
|
do_update = True
|
|
if (self.priority is not None) and ("priority" in cur_record) and (cur_record["priority"] != self.priority):
|
|
do_update = True
|
|
if ("proxied" in new_record) and ("proxied" in cur_record) and (cur_record["proxied"] != self.proxied):
|
|
do_update = True
|
|
if ("data" in new_record) and ("data" in cur_record):
|
|
if cur_record["data"] != new_record["data"]:
|
|
do_update = True
|
|
# For CNAME and TXT in single-record update mode, check content changes
|
|
if (self.type == "CNAME") and (cur_record["content"] != new_record["content"]):
|
|
do_update = True
|
|
# For TXT records, normalize split strings but preserve quote differences
|
|
# This prevents unnecessary updates when provider auto-splits long strings
|
|
# but still detects when quotes are added/removed
|
|
if (self.type == 'TXT') and self.txt_single_record_update:
|
|
if normalize_txt_split_strings(cur_record['content']) != normalize_txt_split_strings(new_record['content']):
|
|
do_update = True
|
|
if cur_record["comment"] != new_record["comment"]:
|
|
do_update = True
|
|
if sorted(cur_record["tags"]) != sorted(new_record["tags"]):
|
|
do_update = True
|
|
if do_update:
|
|
if self.module.check_mode:
|
|
result = new_record
|
|
else:
|
|
result, info = self._cf_api_call(
|
|
f"/zones/{zone_id}/dns_records/{records[0]['id']}", "PUT", new_record
|
|
)
|
|
self.changed = True
|
|
return result, self.changed
|
|
else:
|
|
return records, self.changed
|
|
# No existing record found, will create a new one
|
|
# Track record creation for reporting
|
|
self.records_to_add.append({
|
|
'name': new_record['name'],
|
|
'type': self.type,
|
|
'content': new_record['content'],
|
|
'ttl': new_record['ttl']
|
|
})
|
|
if self.module.check_mode:
|
|
result = new_record
|
|
else:
|
|
result, info = self._cf_api_call(f"/zones/{zone_id}/dns_records", "POST", new_record)
|
|
self.changed = True
|
|
return result, self.changed
|
|
|
|
|
|
def main():
|
|
module = AnsibleModule(
|
|
argument_spec=dict(
|
|
api_token=dict(type="str", no_log=True, fallback=(env_fallback, ["CLOUDFLARE_TOKEN"])),
|
|
account_api_key=dict(type="str", no_log=True, aliases=["account_api_token"]),
|
|
account_email=dict(type="str"),
|
|
algorithm=dict(type="int"),
|
|
cert_usage=dict(type="int", choices=[0, 1, 2, 3]),
|
|
comment=dict(type="str"),
|
|
hash_type=dict(type="int", choices=[1, 2]),
|
|
key_tag=dict(type="int", no_log=False),
|
|
port=dict(type="int"),
|
|
flag=dict(type="int", choices=[0, 1, 128]),
|
|
tag=dict(type="str", choices=["issue", "issuewild", "iodef"]),
|
|
tags=dict(type="list", elements="str"),
|
|
priority=dict(type="int", default=1),
|
|
proto=dict(type="str"),
|
|
proxied=dict(type="bool", default=False),
|
|
record=dict(type="str", default="@", aliases=["name"]),
|
|
selector=dict(type="int", choices=[0, 1]),
|
|
service=dict(type="str"),
|
|
solo=dict(type="bool"),
|
|
state=dict(type="str", default="present", choices=["absent", "present"]),
|
|
timeout=dict(type="int", default=30),
|
|
ttl=dict(type="int", default=1),
|
|
type=dict(
|
|
type="str",
|
|
choices=["A", "AAAA", "CNAME", "DS", "MX", "NS", "SRV", "SSHFP", "TLSA", "CAA", "TXT", "PTR"],
|
|
),
|
|
value=dict(type="str", aliases=["content"]),
|
|
weight=dict(type="int", default=1),
|
|
zone=dict(type="str", required=True, aliases=["domain"]),
|
|
),
|
|
supports_check_mode=True,
|
|
required_if=[
|
|
("state", "present", ["record", "type", "value"]),
|
|
("state", "absent", ["record"]),
|
|
("type", "SRV", ["proto", "service"]),
|
|
("type", "TLSA", ["proto", "port"]),
|
|
("type", "CAA", ["flag", "tag"]),
|
|
],
|
|
required_together=[
|
|
("account_api_key", "account_email"),
|
|
],
|
|
required_one_of=[
|
|
["api_token", "account_api_key"],
|
|
],
|
|
)
|
|
|
|
if module.params["type"] == "SRV":
|
|
if not (
|
|
(
|
|
module.params["weight"] is not None
|
|
and module.params["port"] is not None
|
|
and not (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
or (
|
|
module.params["weight"] is None
|
|
and module.params["port"] is None
|
|
and (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
):
|
|
module.fail_json(
|
|
msg="For SRV records the params weight, port and value all need to be defined, or not at all."
|
|
)
|
|
|
|
if module.params["type"] == "SSHFP":
|
|
if not (
|
|
(
|
|
module.params["algorithm"] is not None
|
|
and module.params["hash_type"] is not None
|
|
and not (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
or (
|
|
module.params["algorithm"] is None
|
|
and module.params["hash_type"] is None
|
|
and (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
):
|
|
module.fail_json(
|
|
msg="For SSHFP records the params algorithm, hash_type and value all need to be defined, or not at all."
|
|
)
|
|
|
|
if module.params["type"] == "TLSA":
|
|
if not (
|
|
(
|
|
module.params["cert_usage"] is not None
|
|
and module.params["selector"] is not None
|
|
and module.params["hash_type"] is not None
|
|
and not (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
or (
|
|
module.params["cert_usage"] is None
|
|
and module.params["selector"] is None
|
|
and module.params["hash_type"] is None
|
|
and (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
):
|
|
module.fail_json(
|
|
msg="For TLSA records the params cert_usage, selector, hash_type and value all need to be defined, or not at all."
|
|
)
|
|
|
|
if module.params["type"] == "CAA":
|
|
if not (
|
|
(
|
|
module.params["flag"] is not None
|
|
and module.params["tag"] is not None
|
|
and not (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
or (
|
|
module.params["flag"] is None
|
|
and module.params["tag"] is None
|
|
and (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
):
|
|
module.fail_json(
|
|
msg="For CAA records the params flag, tag and value all need to be defined, or not at all."
|
|
)
|
|
|
|
if module.params["type"] == "DS":
|
|
if not (
|
|
(
|
|
module.params["key_tag"] is not None
|
|
and module.params["algorithm"] is not None
|
|
and module.params["hash_type"] is not None
|
|
and not (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
or (
|
|
module.params["key_tag"] is None
|
|
and module.params["algorithm"] is None
|
|
and module.params["hash_type"] is None
|
|
and (module.params["value"] is None or module.params["value"] == "")
|
|
)
|
|
):
|
|
module.fail_json(
|
|
msg="For DS records the params key_tag, algorithm, hash_type and value all need to be defined, or not at all."
|
|
)
|
|
|
|
changed = False
|
|
cf_api = CloudflareAPI(module)
|
|
|
|
# sanity checks
|
|
if cf_api.is_solo and cf_api.state == "absent":
|
|
module.fail_json(msg="solo=true can only be used with state=present")
|
|
|
|
# perform add, delete or update (only the TTL can be updated) of one or
|
|
# more records
|
|
if cf_api.state == "present":
|
|
# delete all records matching record name + type
|
|
if cf_api.is_solo:
|
|
changed = cf_api.delete_dns_records(solo=cf_api.is_solo)
|
|
result, changed = cf_api.ensure_dns_record()
|
|
|
|
# Include operation tracking information in all modes
|
|
result_dict = {
|
|
'record': result[0] if isinstance(result, list) else result,
|
|
'records_to_delete': cf_api.records_to_delete,
|
|
'records_to_update': cf_api.records_to_update,
|
|
'records_to_add': cf_api.records_to_add
|
|
}
|
|
module.exit_json(changed=changed, result=result_dict)
|
|
else:
|
|
# force solo to False, just to be sure
|
|
changed = cf_api.delete_dns_records(solo=False)
|
|
module.exit_json(changed=changed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|