cf53 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cf53-0.1.0/PKG-INFO +15 -0
- cf53-0.1.0/cf53/__init__.py +17 -0
- cf53-0.1.0/cf53/__main__.py +5 -0
- cf53-0.1.0/cf53/console.py +3 -0
- cf53-0.1.0/cf53/diff.py +108 -0
- cf53-0.1.0/cf53/local.py +51 -0
- cf53-0.1.0/cf53/main.py +73 -0
- cf53-0.1.0/cf53/reconcile.py +68 -0
- cf53-0.1.0/cf53/record.py +48 -0
- cf53-0.1.0/cf53/remote.py +61 -0
- cf53-0.1.0/cf53.egg-info/PKG-INFO +15 -0
- cf53-0.1.0/cf53.egg-info/SOURCES.txt +18 -0
- cf53-0.1.0/cf53.egg-info/dependency_links.txt +1 -0
- cf53-0.1.0/cf53.egg-info/requires.txt +3 -0
- cf53-0.1.0/cf53.egg-info/top_level.txt +1 -0
- cf53-0.1.0/pyproject.toml +30 -0
- cf53-0.1.0/readme.md +5 -0
- cf53-0.1.0/setup.cfg +4 -0
- cf53-0.1.0/tests/test_diff.py +134 -0
- cf53-0.1.0/tests/test_local.py +219 -0
cf53-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cf53
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloudflare DNS client for humans
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: cloudflare>=4.3.1
|
|
9
|
+
Requires-Dist: rich>=14.2.0
|
|
10
|
+
|
|
11
|
+
# cf53
|
|
12
|
+
|
|
13
|
+
A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
|
|
14
|
+
|
|
15
|
+
Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from . import local, record
|
|
2
|
+
from .diff import Diff
|
|
3
|
+
from .local import load_zones_dir
|
|
4
|
+
from .main import main
|
|
5
|
+
from .record import Record, RecordType
|
|
6
|
+
from .remote import load_remote_zones
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Diff",
|
|
10
|
+
"Record",
|
|
11
|
+
"RecordType",
|
|
12
|
+
"load_remote_zones",
|
|
13
|
+
"load_zones_dir",
|
|
14
|
+
"local",
|
|
15
|
+
"main",
|
|
16
|
+
"record",
|
|
17
|
+
]
|
cf53-0.1.0/cf53/diff.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from .console import console
|
|
7
|
+
from .record import Record, RecordType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Update:
|
|
12
|
+
initial: Record
|
|
13
|
+
desired: Record
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Diff:
|
|
18
|
+
domain: str
|
|
19
|
+
zone_id: str
|
|
20
|
+
to_create: set[Record]
|
|
21
|
+
to_update: set[Update]
|
|
22
|
+
to_delete: set[Record]
|
|
23
|
+
in_sync: set[Record]
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_zones(
|
|
27
|
+
cls,
|
|
28
|
+
*,
|
|
29
|
+
domain: str,
|
|
30
|
+
zone_id: str,
|
|
31
|
+
local_zone: set[Record],
|
|
32
|
+
remote_zone: set[Record],
|
|
33
|
+
) -> Self:
|
|
34
|
+
to_create = set()
|
|
35
|
+
to_update = set()
|
|
36
|
+
to_delete = set()
|
|
37
|
+
in_sync = set()
|
|
38
|
+
|
|
39
|
+
# lookup to match remote records semantically identical to local records, where
|
|
40
|
+
# records match in this way we can do an update rather than create/delete
|
|
41
|
+
unmatched_remote_records: dict[
|
|
42
|
+
tuple[RecordType, str, str, int | None], Record
|
|
43
|
+
] = {_semantic_key(r): r for r in remote_zone}
|
|
44
|
+
|
|
45
|
+
# local records must be created or matched to a remote record to update in-place
|
|
46
|
+
for local_record in local_zone:
|
|
47
|
+
key = _semantic_key(local_record)
|
|
48
|
+
|
|
49
|
+
if matching_remote_record := unmatched_remote_records.get(key):
|
|
50
|
+
del unmatched_remote_records[key]
|
|
51
|
+
|
|
52
|
+
if local_record == matching_remote_record:
|
|
53
|
+
in_sync.add(matching_remote_record)
|
|
54
|
+
else:
|
|
55
|
+
to_update.add(
|
|
56
|
+
Update(
|
|
57
|
+
initial=matching_remote_record,
|
|
58
|
+
desired=local_record,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
to_create.add(local_record)
|
|
63
|
+
|
|
64
|
+
to_delete = set(unmatched_remote_records.values())
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
domain=domain,
|
|
68
|
+
zone_id=zone_id,
|
|
69
|
+
to_create=to_create,
|
|
70
|
+
to_update=to_update,
|
|
71
|
+
to_delete=to_delete,
|
|
72
|
+
in_sync=in_sync,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def print(self) -> None:
|
|
76
|
+
if not self:
|
|
77
|
+
console.print(f"✓ {self.domain}", style="dim white")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
console.print(f"~ {self.domain}", style="bold blue")
|
|
81
|
+
for r in self.to_create:
|
|
82
|
+
self._print_record(r, icon="+", style="bold green")
|
|
83
|
+
for update in self.to_update:
|
|
84
|
+
r = update.desired
|
|
85
|
+
self._print_record(r, icon="~", style="bold yellow")
|
|
86
|
+
for r in self.to_delete:
|
|
87
|
+
self._print_record(r, icon="-", style="bold red")
|
|
88
|
+
|
|
89
|
+
def _print_record(self, r: Record, icon: str, style: str):
|
|
90
|
+
proxied = " proxied"
|
|
91
|
+
if not r.proxied:
|
|
92
|
+
proxied = "not proxied"
|
|
93
|
+
priority = ""
|
|
94
|
+
if r.type == "mx":
|
|
95
|
+
priority = f"{r.priority:4} "
|
|
96
|
+
ttl = int(r.ttl)
|
|
97
|
+
if ttl == 1:
|
|
98
|
+
ttl = "auto"
|
|
99
|
+
console.print(f" {icon} {r.type.upper():5} {r.name}", style=style)
|
|
100
|
+
console.print(f' {proxied} | {ttl:4} | "{r.comment}"')
|
|
101
|
+
console.print(f" {priority}{r.content}", style="italic")
|
|
102
|
+
|
|
103
|
+
def __bool__(self) -> bool:
|
|
104
|
+
return any((self.to_create, self.to_delete, self.to_update))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _semantic_key(record: Record) -> tuple[RecordType, str, str, int | None]:
|
|
108
|
+
return (record.type, record.name, record.content, record.priority)
|
cf53-0.1.0/cf53/local.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import tomllib
|
|
4
|
+
|
|
5
|
+
from .record import Record, RecordType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_zones_dir(
|
|
9
|
+
*,
|
|
10
|
+
zones_dir: Path,
|
|
11
|
+
default_comment: str = "",
|
|
12
|
+
only_domains: Iterable[str] | None = None,
|
|
13
|
+
) -> dict[str, set[Record]]:
|
|
14
|
+
"""Map of domains to records in <zones_dir>/<domain>.toml"""
|
|
15
|
+
zones: dict[str, set[Record]] = {}
|
|
16
|
+
only_domains = set(only_domains or set())
|
|
17
|
+
|
|
18
|
+
for toml_file in zones_dir.glob("*.toml"):
|
|
19
|
+
domain = toml_file.stem
|
|
20
|
+
|
|
21
|
+
if only_domains and domain not in only_domains:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
with open(toml_file, "rb") as f:
|
|
25
|
+
toml = tomllib.load(f)
|
|
26
|
+
|
|
27
|
+
zones[toml_file.stem] = {
|
|
28
|
+
Record(
|
|
29
|
+
type=record_type,
|
|
30
|
+
name=_normalize_name(record["name"], domain),
|
|
31
|
+
content=f'"{record["content"]}"'
|
|
32
|
+
if record_type == "txt"
|
|
33
|
+
else record["content"],
|
|
34
|
+
ttl=record.get("ttl", 1), # 1=auto
|
|
35
|
+
proxied=record.get("proxied", False),
|
|
36
|
+
comment=record.get("comment", default_comment),
|
|
37
|
+
priority=record.get("priority") if record_type == "mx" else None,
|
|
38
|
+
)
|
|
39
|
+
for record_type in RecordType
|
|
40
|
+
for record in toml.get(record_type.value, [])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return zones
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_name(toml_name: str, domain: str) -> str:
|
|
47
|
+
if toml_name == "@":
|
|
48
|
+
return domain
|
|
49
|
+
if not toml_name.endswith(domain):
|
|
50
|
+
return f"{toml_name}.{domain}"
|
|
51
|
+
return toml_name
|
cf53-0.1.0/cf53/main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from cloudflare import AsyncCloudflare
|
|
9
|
+
|
|
10
|
+
from .console import console
|
|
11
|
+
from .diff import Diff
|
|
12
|
+
from .local import load_zones_dir
|
|
13
|
+
from .reconcile import reconcile_diffs
|
|
14
|
+
from .remote import load_remote_zones
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.argument("zones_dir", type=click.Path(exists=True, file_okay=False))
|
|
19
|
+
@click.option(
|
|
20
|
+
"--domain",
|
|
21
|
+
"domains",
|
|
22
|
+
multiple=True,
|
|
23
|
+
help="Only process these domains (can be specified multiple times)",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--default-comment",
|
|
27
|
+
"default_comment",
|
|
28
|
+
help="Comment to append to any record not explicitly commented",
|
|
29
|
+
)
|
|
30
|
+
def main(zones_dir: str, domains: tuple[str, ...], default_comment: str = "") -> None:
|
|
31
|
+
zones_dir_path = Path(zones_dir)
|
|
32
|
+
client = _client()
|
|
33
|
+
|
|
34
|
+
with console.status("fetching zones..."):
|
|
35
|
+
local_zones = load_zones_dir(
|
|
36
|
+
zones_dir=zones_dir_path,
|
|
37
|
+
default_comment=default_comment,
|
|
38
|
+
only_domains=domains,
|
|
39
|
+
)
|
|
40
|
+
remote_zones = asyncio.run(
|
|
41
|
+
load_remote_zones(client=client, domains=set(local_zones.keys()))
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
diffs = [
|
|
45
|
+
Diff.from_zones(
|
|
46
|
+
domain=domain,
|
|
47
|
+
zone_id=zone_id,
|
|
48
|
+
local_zone=local_zones[domain],
|
|
49
|
+
remote_zone=remote_zone,
|
|
50
|
+
)
|
|
51
|
+
for (domain, zone_id), remote_zone in remote_zones.items()
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
for diff in diffs:
|
|
55
|
+
diff.print()
|
|
56
|
+
|
|
57
|
+
if any(diffs) and _user_confirmation():
|
|
58
|
+
reconcile_diffs(diffs, client)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _user_confirmation() -> bool:
|
|
62
|
+
while True:
|
|
63
|
+
response = input("Apply these changes? [y/N] ").strip().lower()
|
|
64
|
+
if response in ("y", "yes"):
|
|
65
|
+
return True
|
|
66
|
+
elif response in ("n", "no", ""):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _client() -> AsyncCloudflare:
|
|
71
|
+
if not (api_token := os.environ.get("CLOUDFLARE_API_TOKEN")):
|
|
72
|
+
raise RuntimeError("CLOUDFLARE_API_TOKEN must be defined")
|
|
73
|
+
return AsyncCloudflare(api_token=api_token)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
|
|
4
|
+
from cloudflare import AsyncCloudflare
|
|
5
|
+
|
|
6
|
+
from .diff import Diff
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _reconcile_diff(diff: Diff, client: AsyncCloudflare) -> None:
|
|
10
|
+
async with asyncio.TaskGroup() as tg:
|
|
11
|
+
tg.create_task(_apply_creates_async(diff, client))
|
|
12
|
+
tg.create_task(_apply_updates_async(diff, client))
|
|
13
|
+
tg.create_task(_apply_deletes_async(diff, client))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reconcile_diffs(diffs: Iterable[Diff], client: AsyncCloudflare) -> None:
|
|
17
|
+
async def main():
|
|
18
|
+
async with asyncio.TaskGroup() as tg:
|
|
19
|
+
for diff in diffs:
|
|
20
|
+
tg.create_task(_reconcile_diff(diff, client))
|
|
21
|
+
|
|
22
|
+
asyncio.run(main())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _apply_creates_async(diff: Diff, client: AsyncCloudflare) -> None:
|
|
26
|
+
for record in diff.to_create:
|
|
27
|
+
record_data = {
|
|
28
|
+
"type": record.type.value,
|
|
29
|
+
"name": record.name,
|
|
30
|
+
"content": record.content,
|
|
31
|
+
"ttl": record.ttl,
|
|
32
|
+
"proxied": record.proxied,
|
|
33
|
+
"comment": record.comment,
|
|
34
|
+
}
|
|
35
|
+
if record.priority is not None:
|
|
36
|
+
record_data["priority"] = record.priority
|
|
37
|
+
|
|
38
|
+
await client.dns.records.create(zone_id=diff.zone_id, **record_data) # type: ignore[no-matching-overload]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _apply_updates_async(diff: Diff, client: AsyncCloudflare) -> None:
|
|
42
|
+
for update in diff.to_update:
|
|
43
|
+
record_data = {
|
|
44
|
+
"type": update.desired.type.value,
|
|
45
|
+
"name": update.desired.name,
|
|
46
|
+
"content": update.desired.content,
|
|
47
|
+
"ttl": update.desired.ttl,
|
|
48
|
+
"proxied": update.desired.proxied,
|
|
49
|
+
"comment": update.desired.comment,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if update.desired.priority is not None:
|
|
53
|
+
record_data["priority"] = update.desired.priority
|
|
54
|
+
|
|
55
|
+
await client.dns.records.update( # type: ignore[no-matching-overload]
|
|
56
|
+
update.initial.cloudflare_id,
|
|
57
|
+
zone_id=diff.zone_id,
|
|
58
|
+
**record_data,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _apply_deletes_async(diff: Diff, client: AsyncCloudflare) -> None:
|
|
63
|
+
for record in diff.to_delete:
|
|
64
|
+
assert record.cloudflare_id
|
|
65
|
+
await client.dns.records.delete(
|
|
66
|
+
record.cloudflare_id,
|
|
67
|
+
zone_id=diff.zone_id,
|
|
68
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum, auto
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RecordType(StrEnum):
|
|
9
|
+
a = auto()
|
|
10
|
+
aaaa = auto()
|
|
11
|
+
mx = auto()
|
|
12
|
+
txt = auto()
|
|
13
|
+
cname = auto()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Record:
|
|
18
|
+
type: RecordType
|
|
19
|
+
name: str
|
|
20
|
+
content: str
|
|
21
|
+
ttl: int
|
|
22
|
+
proxied: bool
|
|
23
|
+
comment: str
|
|
24
|
+
priority: int | None = None
|
|
25
|
+
cloudflare_id: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if self.priority and not self.type == "mx":
|
|
29
|
+
raise ValueError("priority can only be set for mx records")
|
|
30
|
+
|
|
31
|
+
def __eq__(self, other: Any) -> bool:
|
|
32
|
+
"""Equality should ignore a `None` cloudflare_id."""
|
|
33
|
+
if not isinstance(other, Record):
|
|
34
|
+
return False
|
|
35
|
+
return (
|
|
36
|
+
self.type == other.type
|
|
37
|
+
and self.name == other.name
|
|
38
|
+
and self.content == other.content
|
|
39
|
+
and self.ttl == other.ttl
|
|
40
|
+
and self.proxied == other.proxied
|
|
41
|
+
and self.comment == other.comment
|
|
42
|
+
and self.priority == other.priority
|
|
43
|
+
and (
|
|
44
|
+
self.cloudflare_id == other.cloudflare_id
|
|
45
|
+
or self.cloudflare_id is None
|
|
46
|
+
or other.cloudflare_id is None
|
|
47
|
+
)
|
|
48
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from cloudflare import AsyncCloudflare
|
|
6
|
+
|
|
7
|
+
from .record import Record, RecordType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def load_remote_zones(
|
|
11
|
+
client: AsyncCloudflare, domains: set[str]
|
|
12
|
+
) -> dict[tuple[str, str], set[Record]]:
|
|
13
|
+
"""Map of (domain, zone_id) to records in cloudflare"""
|
|
14
|
+
domain_to_zone_id = {
|
|
15
|
+
zone.name: zone.id
|
|
16
|
+
for zone in _unpage(await client.zones.list())
|
|
17
|
+
if zone.name in domains
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
tasks = []
|
|
21
|
+
async with asyncio.TaskGroup() as tg:
|
|
22
|
+
for domain, zone_id in domain_to_zone_id.items():
|
|
23
|
+
task = tg.create_task(_load_remote_zone(client, domain, zone_id))
|
|
24
|
+
tasks.append(task)
|
|
25
|
+
|
|
26
|
+
remote_zones: dict[tuple[str, str], set[Record]] = {}
|
|
27
|
+
for task in tasks:
|
|
28
|
+
domain, records = task.result()
|
|
29
|
+
remote_zones[(domain, domain_to_zone_id[domain])] = records
|
|
30
|
+
|
|
31
|
+
return remote_zones
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _load_remote_zone(
|
|
35
|
+
client, domain: str, zone_id: str
|
|
36
|
+
) -> tuple[str, set[Record]]:
|
|
37
|
+
records_page = await client.dns.records.list(zone_id=zone_id)
|
|
38
|
+
return (
|
|
39
|
+
domain,
|
|
40
|
+
{
|
|
41
|
+
Record(
|
|
42
|
+
type=RecordType(record.type.lower()),
|
|
43
|
+
name=record.name,
|
|
44
|
+
content=record.content,
|
|
45
|
+
ttl=record.ttl,
|
|
46
|
+
proxied=record.proxied,
|
|
47
|
+
comment=record.comment,
|
|
48
|
+
priority=int(record.priority) if hasattr(record, "priority") else None,
|
|
49
|
+
cloudflare_id=record.id,
|
|
50
|
+
)
|
|
51
|
+
for record in _unpage(records_page)
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unpage(page):
|
|
57
|
+
for item in page:
|
|
58
|
+
values_type, values = item
|
|
59
|
+
if values_type != "result":
|
|
60
|
+
continue
|
|
61
|
+
yield from values
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cf53
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cloudflare DNS client for humans
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: cloudflare>=4.3.1
|
|
9
|
+
Requires-Dist: rich>=14.2.0
|
|
10
|
+
|
|
11
|
+
# cf53
|
|
12
|
+
|
|
13
|
+
A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
|
|
14
|
+
|
|
15
|
+
Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
readme.md
|
|
3
|
+
cf53/__init__.py
|
|
4
|
+
cf53/__main__.py
|
|
5
|
+
cf53/console.py
|
|
6
|
+
cf53/diff.py
|
|
7
|
+
cf53/local.py
|
|
8
|
+
cf53/main.py
|
|
9
|
+
cf53/reconcile.py
|
|
10
|
+
cf53/record.py
|
|
11
|
+
cf53/remote.py
|
|
12
|
+
cf53.egg-info/PKG-INFO
|
|
13
|
+
cf53.egg-info/SOURCES.txt
|
|
14
|
+
cf53.egg-info/dependency_links.txt
|
|
15
|
+
cf53.egg-info/requires.txt
|
|
16
|
+
cf53.egg-info/top_level.txt
|
|
17
|
+
tests/test_diff.py
|
|
18
|
+
tests/test_local.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cf53
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cf53"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Cloudflare DNS client for humans"
|
|
5
|
+
readme = "readme.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"click>=8.3.1",
|
|
9
|
+
"cloudflare>=4.3.1",
|
|
10
|
+
"rich>=14.2.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"pyrefly>=0.45.2",
|
|
16
|
+
"pytest>=9.0.2",
|
|
17
|
+
"ruff>=0.14.9",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.ruff]
|
|
21
|
+
line-length = 88
|
|
22
|
+
src = ["cf53"]
|
|
23
|
+
|
|
24
|
+
[tool.ruff.lint]
|
|
25
|
+
select = ["E", "F", "I", "UP", "B", "C4", "T20", "RUF"]
|
|
26
|
+
ignore = []
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint.isort]
|
|
29
|
+
force-sort-within-sections = true
|
|
30
|
+
force-to-top = ["from __future__ import annotations"]
|
cf53-0.1.0/readme.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# cf53
|
|
2
|
+
|
|
3
|
+
A human-oriented cloudflare DNS updater. This provide a way to IaC cloudflare DNS records with a simple toml file, and get a good visual feedback of the diffs to make it actual.
|
|
4
|
+
|
|
5
|
+
Think terraform, but smarter. No need to keep a local state or copy/paste ids around, just say what records you want, provide an API token, that's it.
|
cf53-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from copy import replace
|
|
2
|
+
|
|
3
|
+
from cf53.diff import Diff
|
|
4
|
+
from cf53.record import Record, RecordType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_trivial_add():
|
|
8
|
+
local_records = {
|
|
9
|
+
Record(
|
|
10
|
+
type=RecordType.a,
|
|
11
|
+
name="example.com",
|
|
12
|
+
content="1.2.3.4",
|
|
13
|
+
ttl=3600,
|
|
14
|
+
proxied=False,
|
|
15
|
+
comment="test record",
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
remote_records = set()
|
|
19
|
+
|
|
20
|
+
diff = Diff.from_zones(
|
|
21
|
+
domain="example.com",
|
|
22
|
+
zone_id="test_zone_id",
|
|
23
|
+
local_zone=local_records,
|
|
24
|
+
remote_zone=remote_records,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert len(diff.to_create) == 1
|
|
28
|
+
assert len(diff.to_update) == 0
|
|
29
|
+
assert len(diff.to_delete) == 0
|
|
30
|
+
assert len(diff.in_sync) == 0
|
|
31
|
+
created_record = next(iter(diff.to_create))
|
|
32
|
+
assert created_record.type == RecordType.a
|
|
33
|
+
assert created_record.name == "example.com"
|
|
34
|
+
assert created_record.content == "1.2.3.4"
|
|
35
|
+
assert created_record.ttl == 3600
|
|
36
|
+
assert created_record.proxied is False
|
|
37
|
+
assert created_record.comment == "test record"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_trivial_delete():
|
|
41
|
+
local_records = set()
|
|
42
|
+
remote_records = {
|
|
43
|
+
Record(
|
|
44
|
+
type=RecordType.a,
|
|
45
|
+
name="example.com",
|
|
46
|
+
content="1.2.3.4",
|
|
47
|
+
ttl=3600,
|
|
48
|
+
proxied=False,
|
|
49
|
+
comment="test record",
|
|
50
|
+
cloudflare_id="foo",
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
diff = Diff.from_zones(
|
|
55
|
+
domain="example.com",
|
|
56
|
+
zone_id="test_zone_id",
|
|
57
|
+
local_zone=local_records,
|
|
58
|
+
remote_zone=remote_records,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert len(diff.to_create) == 0
|
|
62
|
+
assert len(diff.to_update) == 0
|
|
63
|
+
assert len(diff.to_delete) == 1
|
|
64
|
+
assert len(diff.in_sync) == 0
|
|
65
|
+
delete_record = next(iter(diff.to_delete))
|
|
66
|
+
assert delete_record.type == RecordType.a
|
|
67
|
+
assert delete_record.name == "example.com"
|
|
68
|
+
assert delete_record.content == "1.2.3.4"
|
|
69
|
+
assert delete_record.ttl == 3600
|
|
70
|
+
assert delete_record.proxied is False
|
|
71
|
+
assert delete_record.comment == "test record"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_trivial_match_in_sync():
|
|
75
|
+
matching_record = Record(
|
|
76
|
+
type=RecordType.a,
|
|
77
|
+
name="example.com",
|
|
78
|
+
content="1.2.3.4",
|
|
79
|
+
ttl=3600,
|
|
80
|
+
proxied=False,
|
|
81
|
+
comment="test record",
|
|
82
|
+
)
|
|
83
|
+
with_id = replace(
|
|
84
|
+
matching_record, # type: ignore[bad-argument-type]
|
|
85
|
+
cloudflare_id="foo",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
local_records = {matching_record}
|
|
89
|
+
remote_records = {with_id}
|
|
90
|
+
|
|
91
|
+
diff = Diff.from_zones(
|
|
92
|
+
domain="example.com",
|
|
93
|
+
zone_id="test_zone_id",
|
|
94
|
+
local_zone=local_records,
|
|
95
|
+
remote_zone=remote_records,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert len(diff.to_create) == 0
|
|
99
|
+
assert len(diff.to_update) == 0
|
|
100
|
+
assert len(diff.to_delete) == 0
|
|
101
|
+
assert len(diff.in_sync) == 1
|
|
102
|
+
synced_record = next(iter(diff.in_sync))
|
|
103
|
+
assert synced_record == with_id
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_trivial_match_to_update():
|
|
107
|
+
local_record = Record(
|
|
108
|
+
type=RecordType.a,
|
|
109
|
+
name="example.com",
|
|
110
|
+
content="1.2.3.4",
|
|
111
|
+
ttl=3600,
|
|
112
|
+
proxied=False,
|
|
113
|
+
comment="test record",
|
|
114
|
+
)
|
|
115
|
+
remote_record = replace(
|
|
116
|
+
local_record, # type: ignore[bad-argument-type]
|
|
117
|
+
cloudflare_id="foo",
|
|
118
|
+
comment="something else",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
diff = Diff.from_zones(
|
|
122
|
+
domain="example.com",
|
|
123
|
+
zone_id="test_zone_id",
|
|
124
|
+
local_zone={local_record},
|
|
125
|
+
remote_zone={remote_record},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert len(diff.to_create) == 0
|
|
129
|
+
assert len(diff.to_update) == 1
|
|
130
|
+
assert len(diff.to_delete) == 0
|
|
131
|
+
assert len(diff.in_sync) == 0
|
|
132
|
+
update = next(iter(diff.to_update))
|
|
133
|
+
assert update.initial == remote_record
|
|
134
|
+
assert update.desired == local_record
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from cf53 import Record, RecordType, load_zones_dir
|
|
2
|
+
|
|
3
|
+
single_record_content = """
|
|
4
|
+
[[a]]
|
|
5
|
+
name = "example.com"
|
|
6
|
+
content = "127.0.0.1"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
single_record_expected = {
|
|
10
|
+
Record(
|
|
11
|
+
type=RecordType.a,
|
|
12
|
+
name="example.com",
|
|
13
|
+
content="127.0.0.1",
|
|
14
|
+
ttl=1,
|
|
15
|
+
proxied=False,
|
|
16
|
+
comment="",
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
many_records_content = """
|
|
22
|
+
[[a]]
|
|
23
|
+
name = "example.net"
|
|
24
|
+
content = "192.168.1.1"
|
|
25
|
+
ttl=500
|
|
26
|
+
|
|
27
|
+
[[a]]
|
|
28
|
+
name = "foo.example.net"
|
|
29
|
+
content = "192.168.1.1"
|
|
30
|
+
proxied = true
|
|
31
|
+
comment = "for foo"
|
|
32
|
+
|
|
33
|
+
[[aaaa]]
|
|
34
|
+
name = "example.net"
|
|
35
|
+
content = "2001:db8::1"
|
|
36
|
+
|
|
37
|
+
[[cname]]
|
|
38
|
+
name = "www"
|
|
39
|
+
content = "example.net"
|
|
40
|
+
|
|
41
|
+
[[txt]]
|
|
42
|
+
name = "example.net"
|
|
43
|
+
content = "v=spf1 include:_spf.google.com ~all"
|
|
44
|
+
|
|
45
|
+
[[mx]]
|
|
46
|
+
name = "example.net"
|
|
47
|
+
content = "mail.example.net"
|
|
48
|
+
priority = 10
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
many_records_expected = {
|
|
52
|
+
Record(
|
|
53
|
+
type=RecordType.a,
|
|
54
|
+
name="example.net",
|
|
55
|
+
content="192.168.1.1",
|
|
56
|
+
ttl=500,
|
|
57
|
+
proxied=False,
|
|
58
|
+
comment="",
|
|
59
|
+
),
|
|
60
|
+
Record(
|
|
61
|
+
type=RecordType.a,
|
|
62
|
+
name="foo.example.net",
|
|
63
|
+
content="192.168.1.1",
|
|
64
|
+
ttl=1,
|
|
65
|
+
proxied=True,
|
|
66
|
+
comment="for foo",
|
|
67
|
+
),
|
|
68
|
+
Record(
|
|
69
|
+
type=RecordType.aaaa,
|
|
70
|
+
name="example.net",
|
|
71
|
+
content="2001:db8::1",
|
|
72
|
+
ttl=1,
|
|
73
|
+
proxied=False,
|
|
74
|
+
comment="",
|
|
75
|
+
),
|
|
76
|
+
Record(
|
|
77
|
+
type=RecordType.cname,
|
|
78
|
+
name="www.example.net",
|
|
79
|
+
content="example.net",
|
|
80
|
+
ttl=1,
|
|
81
|
+
proxied=False,
|
|
82
|
+
comment="",
|
|
83
|
+
),
|
|
84
|
+
Record(
|
|
85
|
+
type=RecordType.txt,
|
|
86
|
+
name="example.net",
|
|
87
|
+
content='"v=spf1 include:_spf.google.com ~all"',
|
|
88
|
+
ttl=1,
|
|
89
|
+
proxied=False,
|
|
90
|
+
comment="",
|
|
91
|
+
),
|
|
92
|
+
Record(
|
|
93
|
+
type=RecordType.mx,
|
|
94
|
+
name="example.net",
|
|
95
|
+
content="mail.example.net",
|
|
96
|
+
ttl=1,
|
|
97
|
+
proxied=False,
|
|
98
|
+
comment="",
|
|
99
|
+
priority=10,
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
denormalized_name_records = """
|
|
104
|
+
[[a]]
|
|
105
|
+
name = "@"
|
|
106
|
+
content = "127.0.0.1"
|
|
107
|
+
|
|
108
|
+
[[txt]]
|
|
109
|
+
name = "_dmarc"
|
|
110
|
+
content = "v=DMARC1; p=quarantine;"
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
denormalized_name_expected = {
|
|
114
|
+
Record(
|
|
115
|
+
type=RecordType.a,
|
|
116
|
+
name="example.org",
|
|
117
|
+
content="127.0.0.1",
|
|
118
|
+
ttl=1,
|
|
119
|
+
proxied=False,
|
|
120
|
+
comment="",
|
|
121
|
+
),
|
|
122
|
+
Record(
|
|
123
|
+
type=RecordType.txt,
|
|
124
|
+
name="_dmarc.example.org",
|
|
125
|
+
content='"v=DMARC1; p=quarantine;"',
|
|
126
|
+
ttl=1,
|
|
127
|
+
proxied=False,
|
|
128
|
+
comment="",
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
default_comment_records = """
|
|
133
|
+
[[a]]
|
|
134
|
+
name = "example.ca"
|
|
135
|
+
content = "1.2.3.4"
|
|
136
|
+
comment = "domain apex"
|
|
137
|
+
[[a]]
|
|
138
|
+
name = "foo.example.ca"
|
|
139
|
+
content = "1.2.3.4"
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
default_comment_expected = {
|
|
143
|
+
Record(
|
|
144
|
+
type=RecordType.a,
|
|
145
|
+
name="example.ca",
|
|
146
|
+
content="1.2.3.4",
|
|
147
|
+
ttl=1,
|
|
148
|
+
proxied=False,
|
|
149
|
+
comment="domain apex",
|
|
150
|
+
),
|
|
151
|
+
Record(
|
|
152
|
+
type=RecordType.a,
|
|
153
|
+
name="foo.example.ca",
|
|
154
|
+
content="1.2.3.4",
|
|
155
|
+
ttl=1,
|
|
156
|
+
proxied=False,
|
|
157
|
+
comment="default comment",
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_empty(build_zones_dir):
|
|
163
|
+
zones_dir = build_zones_dir({"example.com.toml": ""})
|
|
164
|
+
zones = load_zones_dir(zones_dir=zones_dir)
|
|
165
|
+
assert zones == {"example.com": set()}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_single_record(build_zones_dir):
|
|
169
|
+
zones_dir = build_zones_dir({"example.com.toml": single_record_content})
|
|
170
|
+
zones = load_zones_dir(zones_dir=zones_dir)
|
|
171
|
+
assert zones == {"example.com": single_record_expected}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_many_records(build_zones_dir):
|
|
175
|
+
zones_dir = build_zones_dir({"example.net.toml": many_records_content})
|
|
176
|
+
zones = load_zones_dir(zones_dir=zones_dir)
|
|
177
|
+
assert zones == {"example.net": many_records_expected}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_denormalized_names(build_zones_dir):
|
|
181
|
+
zones_dir = build_zones_dir({"example.org.toml": denormalized_name_records})
|
|
182
|
+
zones = load_zones_dir(zones_dir=zones_dir)
|
|
183
|
+
assert zones == {"example.org": denormalized_name_expected}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_many_zone_files(build_zones_dir):
|
|
187
|
+
zones_dir = build_zones_dir(
|
|
188
|
+
{
|
|
189
|
+
"example.com.toml": single_record_content,
|
|
190
|
+
"example.net.toml": many_records_content,
|
|
191
|
+
"example.org.toml": denormalized_name_records,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
zones = load_zones_dir(zones_dir=zones_dir)
|
|
195
|
+
assert zones == {
|
|
196
|
+
"example.com": single_record_expected,
|
|
197
|
+
"example.net": many_records_expected,
|
|
198
|
+
"example.org": denormalized_name_expected,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_default_comment(build_zones_dir):
|
|
203
|
+
zones_dir = build_zones_dir({"example.ca.toml": default_comment_records})
|
|
204
|
+
zones = load_zones_dir(zones_dir=zones_dir, default_comment="default comment")
|
|
205
|
+
assert zones == {"example.ca": default_comment_expected}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_domain_seletion(build_zones_dir):
|
|
209
|
+
zones_dir = build_zones_dir(
|
|
210
|
+
{
|
|
211
|
+
"example.net.toml": many_records_content,
|
|
212
|
+
"example.ca.toml": default_comment_records,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
zones = load_zones_dir(
|
|
216
|
+
zones_dir=zones_dir,
|
|
217
|
+
only_domains={"example.net"},
|
|
218
|
+
)
|
|
219
|
+
assert zones == {"example.net": many_records_expected}
|