cf53 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .main import main
4
+
5
+ main()
cf53/console.py ADDED
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
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/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/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)
cf53/reconcile.py ADDED
@@ -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
+ )
cf53/record.py ADDED
@@ -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
+ )
cf53/remote.py ADDED
@@ -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,13 @@
1
+ cf53/__init__.py,sha256=ha6P1ewET-Ry0vAHGAAXAtpwFtZVK5j2BtPvUlsaDn0,330
2
+ cf53/__main__.py,sha256=yJhoRW6QD7AkgTIh5zXngIO8HjbJek4rgBnkzhCym88,67
3
+ cf53/console.py,sha256=UpMqeJ0C8i0pkue1AHnnyyX0bFJ9zZeJ7HBR6yhuA8A,54
4
+ cf53/diff.py,sha256=5gkGXZJgXRHZ8Gp47jhwwe3aiaAJsJCx1y4aZFEK1IM,3374
5
+ cf53/local.py,sha256=UyUtqPQARwnDO4tr-0FSuotW35fIzMKWs1NsB1lLdk8,1538
6
+ cf53/main.py,sha256=lrGgIRobIB9_Gh21UrMqpqm-2yzwy6QxEqn5-HGB7as,2013
7
+ cf53/reconcile.py,sha256=5Ae30CwX5mtHLjpAkgwcWOuXPd9jeg9leqrFcciVCkI,2238
8
+ cf53/record.py,sha256=lfYtRqrkXSTOm1VzshxHEJlZJnbeVCtpMgVWhgdww8Y,1302
9
+ cf53/remote.py,sha256=0Rj4dZDbcHzYb8HvSR6cazpHyU4reOFsSuX4FUwHyRk,1729
10
+ cf53-0.1.0.dist-info/METADATA,sha256=EF1R0tO0HrWATqV2lk93H8IjhL3yugwCpd-diOTpmxI,582
11
+ cf53-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ cf53-0.1.0.dist-info/top_level.txt,sha256=q9lyH7cRV3yAHA7thCX_RLr3Xo_u0a7a7Ajp4ijhbp8,5
13
+ cf53-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cf53