Skip to content

Commit

Permalink
Merge pull request #105 from octodns/svcb-https-type-support
Browse files Browse the repository at this point in the history
Add SVCB and HTTPS record type support
  • Loading branch information
ross authored Oct 7, 2024
2 parents fa2b81f + fb5b54b commit 456ef7b
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
validations
* Throw an error when trying to create a DS without a coresponding NS,
`strict_supports: false` will omit the DS instead
* Add support for SVCB and HTTPS record types

## v0.0.6 - 2024-05-22 - Deal with unknowns and make more knowns

Expand Down
69 changes: 69 additions & 0 deletions octodns_cloudflare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#

from collections import defaultdict
from io import StringIO
from logging import getLogger
from time import sleep
from urllib.parse import urlsplit
Expand All @@ -15,6 +16,14 @@
from octodns.provider.base import BaseProvider
from octodns.record import Create, Record, Update

try: # pragma: no cover
from octodns.record.https import HttpsValue
from octodns.record.svcb import SvcbValue

SUPPORTS_SVCB = True
except ImportError: # pragma: no cover
SUPPORTS_SVCB = False

# TODO: remove __VERSION__ with the next major version release
__version__ = __VERSION__ = '0.0.7'

Expand Down Expand Up @@ -65,6 +74,11 @@ class CloudflareProvider(BaseProvider):
)
)

# These are only supported if we have a new enough octoDNS core
if SUPPORTS_SVCB: # pragma: no cover
SUPPORTS.add('HTTPS')
SUPPORTS.add('SVCB')

TIMEOUT = 15

def __init__(
Expand Down Expand Up @@ -357,6 +371,32 @@ def _data_for_SRV(self, _type, records):
'values': values,
}

def _data_for_SVCB(self, _type, records):
values = []
for r in records:
# it's cleaner/easier to parse the rdata version than CF's broken up
# `data` which is really only half parsed
value = SvcbValue.parse_rdata_text(r['content'])
values.append(value)
return {
'type': _type,
'ttl': self._ttl_data(records[0]['ttl']),
'values': values,
}

def _data_for_HTTPS(self, _type, records):
values = []
for r in records:
# it's cleaner/easier to parse the rdata version than CF's broken up
# `data` which is really only half parsed
value = HttpsValue.parse_rdata_text(r['content'])
values.append(value)
return {
'type': _type,
'ttl': self._ttl_data(records[0]['ttl']),
'values': values,
}

def _data_for_TLSA(self, _type, records):
values = []
for r in records:
Expand Down Expand Up @@ -753,6 +793,29 @@ def _contents_for_SRV(self, record):
}
}

def _contents_for_SVCB(self, record):
for value in record.values:
params = StringIO()
for k, v in value.svcparams.items():
params.write(' ')
params.write(k)
if v is not None:
params.write('="')
if isinstance(v, list):
params.write(','.join(v))
else:
params.write(v)
params.write('"')
yield {
'data': {
'priority': value.svcpriority,
'target': value.targetname,
'value': params.getvalue(),
}
}

_contents_for_HTTPS = _contents_for_SVCB

def _contents_for_TLSA(self, record):
for value in record.values:
yield {
Expand Down Expand Up @@ -911,6 +974,12 @@ def _gen_key(self, data):
fingerprint_type = data['type']
fingerprint = data['fingerprint']
return f'{algorithm} {fingerprint_type} {fingerprint}'
elif _type in ('HTTPS', 'SVCB'):
data = data['data']
priority = data['priority']
target = data['target']
value = data['value']
return f'{priority} {target} {value}'
elif _type == 'TLSA':
data = data['data']
usage = data['usage']
Expand Down
22 changes: 22 additions & 0 deletions tests/config/unit.tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ excluded:
- test
type: CNAME
value: unit.tests.
https:
ttl: 304
type: HTTPS
value:
svcparams:
alpn:
- h3
- h2
ipv4hint:
- 127.0.0.2
svcpriority: 1
targetname: www.unit.tests.
ignored:
octodns:
ignored: true
Expand Down Expand Up @@ -165,6 +177,16 @@ sub:
values:
- 6.2.3.4.
- 7.2.3.4.
svcb:
ttl: 303
type: SVCB
value:
svcparams:
alpn:
- h3
- h2
svcpriority: 1
targetname: www.unit.tests.
txt:
ttl: 600
type: TXT
Expand Down
50 changes: 50 additions & 0 deletions tests/fixtures/cloudflare-dns_records-page-3.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,56 @@
"type": "DS",
"zone_id": "0896a14115d694fe7ad10dd1ed282578",
"zone_name": "unit.tests"
},
{
"content": "1 www.unit.tests. alpn=\"h3,h2\"",
"created_on": "2024-05-20T19:27:04.707266Z",
"data": {
"priority": 1,
"target": "www.unit.tests.",
"value": "alpn=\"h3,h2\""
},
"id": "e7ac1830a4f79eb06d17fd4ca043cafd",
"locked": false,
"meta": {
"auto_added": false,
"managed_by_apps": false,
"managed_by_argo_tunnel": false
},
"modified_on": "2024-05-20T19:27:04.707266Z",
"name": "svcb.unit.tests",
"proxiable": false,
"proxied": false,
"tags": [],
"ttl": 303,
"type": "SVCB",
"zone_id": "0896a14115d694fe7ad10dd1ed282578",
"zone_name": "unit.tests"
},
{
"content": "1 www.unit.tests. alpn=\"h3,h2\" ipv4hint=\"127.0.0.2\"",
"created_on": "2024-05-20T19:27:04.707266Z",
"data": {
"priority": 1,
"target": "www.unit.tests.",
"value": "alpn=\"h3,h2\" ipv4hint=\"127.0.0.2\""
},
"id": "f8ac1830a4f79eb06d17fd4ca043cafd",
"locked": false,
"meta": {
"auto_added": false,
"managed_by_apps": false,
"managed_by_argo_tunnel": false
},
"modified_on": "2024-05-20T19:27:04.707266Z",
"name": "https.unit.tests",
"proxiable": false,
"proxied": false,
"tags": [],
"ttl": 304,
"type": "HTTPS",
"zone_id": "0896a14115d694fe7ad10dd1ed282578",
"zone_name": "unit.tests"
}
],
"result_info": {
Expand Down
58 changes: 47 additions & 11 deletions tests/test_octodns_provider_cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def test_populate(self):

zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEqual(23, len(zone.records))
self.assertEqual(25, len(zone.records))

changes = self.expected.changes(zone, provider)

Expand All @@ -250,7 +250,7 @@ def test_populate(self):
# re-populating the same zone/records comes out of cache, no calls
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEqual(23, len(again.records))
self.assertEqual(25, len(again.records))

def test_apply(self):
provider = CloudflareProvider(
Expand All @@ -264,12 +264,12 @@ def test_apply(self):
{'result': {'id': 42}}, # zone create
] + [
None
] * 32 # individual record creates
] * 34 # individual record creates

# non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEqual(20, len(plan.changes))
self.assertEqual(20, provider.apply(plan))
self.assertEqual(22, len(plan.changes))
self.assertEqual(22, provider.apply(plan))
self.assertFalse(plan.exists)

provider._request.assert_has_calls(
Expand Down Expand Up @@ -333,7 +333,7 @@ def test_apply(self):
True,
)
# expected number of total calls
self.assertEqual(34, provider._request.call_count)
self.assertEqual(36, provider._request.call_count)

provider._request.reset_mock()

Expand Down Expand Up @@ -575,12 +575,12 @@ def test_apply(self):
{'result': {'id': 42}}, # zone create
] + [
None
] * 32 # individual record creates
] * 34 # individual record creates

# non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEqual(20, len(plan.changes))
self.assertEqual(20, provider.apply(plan))
self.assertEqual(22, len(plan.changes))
self.assertEqual(22, provider.apply(plan))
self.assertFalse(plan.exists)

provider._request.assert_has_calls(
Expand Down Expand Up @@ -648,7 +648,7 @@ def test_apply(self):
True,
)
# expected number of total calls
self.assertEqual(34, provider._request.call_count)
self.assertEqual(36, provider._request.call_count)

def test_update_add_swap(self):
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
Expand Down Expand Up @@ -1277,6 +1277,31 @@ def test_srv(self):
list(srv_record_with_sub_contents)[0],
)

def test_svcb(self):
provider = CloudflareProvider('test', 'email', 'token')

value = {
'svcpriority': 42,
'targetname': 'www.unit.tests.',
'svcparams': {
'alpn': ['h3', 'h2'],
'ipv4hint': '127.0.0.1',
'no-default-alpn': None,
},
}
data = {'type': 'SVCB', 'ttl': 93, 'value': value}
zone = Zone('unit.tests.', [])
record = Record.new(zone, 'svcb', data, lenient=True)
contents = list(provider._contents_for_SVCB(record))
self.assertEqual(
{
'priority': value['svcpriority'],
'target': value['targetname'],
'value': ' alpn="h3,h2" ipv4hint="127.0.0.1" no-default-alpn',
},
contents[0]['data'],
)

def test_txt(self):
provider = CloudflareProvider('test', 'email', 'token')

Expand Down Expand Up @@ -1386,6 +1411,17 @@ def test_gen_key(self):
'type': 'LOC',
},
),
(
'99 www.unit.tests. alpn="h3,h2"',
{
'data': {
'priority': 99,
'target': 'www.unit.tests.',
'value': 'alpn="h3,h2"',
},
'type': 'SVCB',
},
),
):
self.assertEqual(expected, provider._gen_key(data))

Expand Down Expand Up @@ -2378,7 +2414,7 @@ def test_idna_domain(self):
# notice the i is a utf-8 character which becomes `xn--gthub-zsa.com.`
zone = Zone('gíthub.com.', [])
provider.populate(zone)
self.assertEqual(9, len(zone.records))
self.assertEqual(11, len(zone.records))
self.assertEqual(zone.name, idna_encode('gíthub.com.'))

def test_account_id_filter(self):
Expand Down

0 comments on commit 456ef7b

Please sign in to comment.