diff options
| -rw-r--r-- | .github/workflows/docs_publish.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/docs_source_update.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/formatting_check.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/linting_check.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/pypi-publish-release.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/unit_tests.yml | 2 | ||||
| -rw-r--r-- | pkb_client/__init__.py | 2 | ||||
| -rw-r--r-- | pkb_client/client/bind_file.py | 45 | ||||
| -rw-r--r-- | pkb_client/client/client.py | 64 | ||||
| -rw-r--r-- | pkb_client/client/dns.py | 43 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | tests/bind_file.py | 52 | ||||
| -rw-r--r-- | tests/client.py | 16 | ||||
| -rw-r--r-- | tests/data/test.bind | 12 | ||||
| -rw-r--r-- | tests/data/test_no_ttl.bind | 12 |
15 files changed, 194 insertions, 66 deletions
diff --git a/.github/workflows/docs_publish.yml b/.github/workflows/docs_publish.yml index 9b6a687..719e23a 100644 --- a/.github/workflows/docs_publish.yml +++ b/.github/workflows/docs_publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: 3.12 diff --git a/.github/workflows/docs_source_update.yml b/.github/workflows/docs_source_update.yml index f90a4c3..443943a 100644 --- a/.github/workflows/docs_source_update.yml +++ b/.github/workflows/docs_source_update.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python 3.12 - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: 3.12 diff --git a/.github/workflows/formatting_check.yml b/.github/workflows/formatting_check.yml index 7501ad0..f48cf74 100644 --- a/.github/workflows/formatting_check.yml +++ b/.github/workflows/formatting_check.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/linting_check.yml b/.github/workflows/linting_check.yml index 113da7f..d61d032 100644 --- a/.github/workflows/linting_check.yml +++ b/.github/workflows/linting_check.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pypi-publish-release.yml b/.github/workflows/pypi-publish-release.yml index e89dcd3..692897a 100644 --- a/.github/workflows/pypi-publish-release.yml +++ b/.github/workflows/pypi-publish-release.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python 3.9 - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: 3.9 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 32899c2..66dc638 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.5.0 + uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/pkb_client/__init__.py b/pkb_client/__init__.py index 93d9382..6cd5d0d 100644 --- a/pkb_client/__init__.py +++ b/pkb_client/__init__.py @@ -1 +1 @@ -__version__ = "v2.1.1" +__version__ = "v2.2.0" diff --git a/pkb_client/client/bind_file.py b/pkb_client/client/bind_file.py index af9abe0..17bb5ee 100644 --- a/pkb_client/client/bind_file.py +++ b/pkb_client/client/bind_file.py @@ -27,7 +27,7 @@ class BindRecord: record_string = f"{self.name} {self.ttl} {self.record_class} {self.record_type}" if self.prio is not None: record_string += f" {self.prio}" - record_string += f" {self.data}" + record_string += f' "{self.data}"' if self.comment: record_string += f" ; {self.comment}" return record_string @@ -68,18 +68,14 @@ class BindFile: # 2: name record-class ttl record-type record-data # whereby the ttl is optional - # drop any right trailing comments - line_parts = line.split(";", 1) - line = line_parts[0].strip() - comment = line_parts[1].strip() if len(line_parts) > 1 else None - prio = None + record_parts = line.strip().split() - # skip empty lines - if not line: + # skip comments + if not record_parts or record_parts[0].startswith(";"): continue - # find which format the line is - record_parts = line.split() + prio = None + if record_parts[1].isdigit(): # scheme 1 if record_parts[3] not in DNSRecordType.__members__: @@ -137,6 +133,35 @@ class BindFile: # replace @ in record name with origin record_name = record_name.replace("@", origin) + # handle comments and quoted strings as record data + comment = None + line = record_data.strip() + if line.startswith('"'): + # find rightmost double quote + rindex = line.rfind('"') + if rindex != -1: + # split at the last double quote + line_parts = line.rsplit('"', 1) + record_data = line_parts[0].strip('"') + + comment = line_parts[1].strip() if len(line_parts) > 1 else None + # left strip semicolon from comment + if comment and comment.startswith(";"): + comment = comment[1:].strip() + + if not comment: + comment = None + else: + record_data = line.strip('"') + else: + # try to split at the first semicolon for comments + if ";" in line: + record_data, comment = line.split(";", 1) + record_data = record_data.strip() + comment = comment.strip() + else: + record_data = line + records.append( BindRecord( record_name, diff --git a/pkb_client/client/client.py b/pkb_client/client/client.py index 81b946b..469751e 100644 --- a/pkb_client/client/client.py +++ b/pkb_client/client/client.py @@ -128,7 +128,7 @@ class PKBClient: req_json = { **self._get_auth_request_json(), "name": name, - "type": record_type, + "type": record_type.value, "content": content, "ttl": ttl, "prio": prio, @@ -182,7 +182,7 @@ class PKBClient: req_json = { **self._get_auth_request_json(), "name": name, - "type": record_type, + "type": record_type.value, "content": content, "ttl": ttl, "prio": prio, @@ -234,7 +234,7 @@ class PKBClient: ) req_json = { **self._get_auth_request_json(), - "type": record_type, + "type": record_type.value, "content": content, "ttl": ttl, "prio": prio, @@ -430,7 +430,7 @@ class PKBClient: logger.warning("file already exists, overwriting...") # domain header - bind_file_content = f"$ORIGIN {domain}" + bind_file_content = f"$ORIGIN {domain}." # SOA record soa_records = dns.resolver.resolve(domain, "SOA") @@ -441,7 +441,15 @@ class PKBClient: # records for record in dns_records: # name record class ttl record type record data - if record.prio: + # add trailing dot to the name if it is a supported record type, to make it a fully qualified domain name + if record.type in [ + DNSRecordType.MX, + DNSRecordType.CNAME, + DNSRecordType.NS, + DNSRecordType.SRV, + ]: + record.content += "." + if record.prio is not None: record_content = f"{record.prio} {record.content}" else: record_content = record.content @@ -498,7 +506,7 @@ class PKBClient: name = ".".join(exported_record["name"].split(".")[:-2]) self.create_dns_record( domain=domain, - record_type=exported_record["type"], + record_type=DNSRecordType(exported_record["type"]), content=exported_record["content"], name=name, ttl=exported_record["ttl"], @@ -534,7 +542,7 @@ class PKBClient: self.update_dns_record( domain=domain, record_id=existing_record.id, - record_type=record["type"], + record_type=DNSRecordType(record["type"]), content=record["content"], name=record["name"].replace(f".{domain}", ""), ttl=record["ttl"], @@ -564,7 +572,7 @@ class PKBClient: if existing_record is None: self.create_dns_record( domain=domain, - record_type=record["type"], + record_type=DNSRecordType(record["type"]), content=record["content"], name=record["name"].replace(f".{domain}", ""), ttl=record["ttl"], @@ -607,12 +615,20 @@ class PKBClient: for record in existing_dns_records: self.delete_dns_record(bind_file.origin[:-1], record.id) + nameserver_records = [] # restore all records from BIND file by creating new DNS records for record in bind_file.records: - # extract subdomain from record name - subdomain = record.name.replace(bind_file.origin, "") - # replace trailing dot - subdomain = subdomain[:-1] if subdomain.endswith(".") else subdomain + if record.record_type == DNSRecordType.NS: + # collect nameserver records to update them later in bulk + nameserver_records.append(record) + continue + if record.name.endswith("."): + # extract subdomain from record name, by removing the domain and TLD + subdomain = record.name.removesuffix(bind_file.origin) + subdomain = subdomain.removesuffix(".") + else: + subdomain = record.name + self.create_dns_record( domain=bind_file.origin[:-1], record_type=record.record_type, @@ -622,6 +638,17 @@ class PKBClient: prio=record.prio, ) + # update nameservers in bulk + if nameserver_records: + name_servers = [] + # remove trailing dot from nameserver records + for nameserver in nameserver_records: + if nameserver.data.endswith("."): + name_servers.append(nameserver.data[:-1]) + else: + name_servers.append(nameserver.data) + self.update_dns_servers(bind_file.origin[:-1], name_servers) + except Exception as e: logger.error("something went wrong: {}".format(e.__str__())) self.__handle_error_backup__(existing_dns_records) @@ -755,7 +782,7 @@ class PKBClient: **self._get_auth_request_json(), "subdomain": subdomain, "location": location, - "type": type, + "type": type.value, "includePath": include_path, "wildcard": wildcard, } @@ -950,11 +977,18 @@ class PKBClient: ) @staticmethod - def __handle_error_backup__(dns_records): + def __handle_error_backup__(dns_records: list[DNSRecord]) -> None: + """ + Handle errors when working with dns records by creating a backup of the given DNS records. + Crates a backup file in the current working directory with an incremental suffix. + + :param dns_records: the DNS records to backup + """ + # merge the single DNS records into one single dict with the record id as key dns_records_dict = dict() for record in dns_records: - dns_records_dict[record["id"]] = record + dns_records_dict[record.id] = record.to_dict() # generate filename with incremental suffix base_backup_filename = "pkb_client_dns_records_backup" diff --git a/pkb_client/client/dns.py b/pkb_client/client/dns.py index 85ae971..8bedad2 100644 --- a/pkb_client/client/dns.py +++ b/pkb_client/client/dns.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional +from typing import Optional, Any class DNSRecordType(str, Enum): @@ -33,7 +33,14 @@ class DNSRecord: notes: str @staticmethod - def from_dict(d): + def from_dict(d: dict[str, Any]) -> "DNSRecord": + """ + Create a DNSRecord instance from a dictionary representation. + + :param d: Dictionary containing DNS record data. + :return: DNSRecord instance. + """ + # only use prio for supported record types since the API returns it for all records with default value 0 prio = int(d["prio"]) if d["type"] in DNS_RECORDS_WITH_PRIORITY else None return DNSRecord( @@ -46,6 +53,23 @@ class DNSRecord: notes=d["notes"], ) + def to_dict(self) -> dict[str, Any]: + """ + Convert the DNSRecord instance to a dictionary representation. + + :return: Dictionary containing DNS record data. + """ + + return { + "id": self.id, + "name": self.name, + "type": str(self.type), + "content": self.content, + "ttl": self.ttl, + "prio": self.prio, + "notes": self.notes, + } + class DNSRestoreMode(Enum): clear = 0 @@ -56,8 +80,13 @@ class DNSRestoreMode(Enum): return self.name @staticmethod - def from_string(a): - try: - return DNSRestoreMode[a] - except KeyError: - return a + def from_string(a: str) -> "DNSRestoreMode": + """ + Convert a string to a DNSRestoreMode enum member. + + :param a: String representation of the restore mode. + :return: Corresponding DNSRestoreMode enum member. + :raises KeyError: If the string does not match any enum member. + """ + + return DNSRestoreMode[a] diff --git a/requirements.txt b/requirements.txt index e129f40..c9a1ccc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ requests>=2.20.0 sphinx~=7.4 dnspython~=2.7 responses~=0.25.7 -ruff~=0.11 +ruff~=0.12 diff --git a/tests/bind_file.py b/tests/bind_file.py index 4d1114f..7062146 100644 --- a/tests/bind_file.py +++ b/tests/bind_file.py @@ -15,7 +15,7 @@ class TestBindFileParsing(unittest.TestCase): self.assertEqual("test.com.", bind_file.origin) self.assertEqual(1234, bind_file.ttl) - self.assertEqual(5, len(bind_file.records)) + self.assertEqual(7, len(bind_file.records)) self.assertEqual( BindRecord( "test.com.", 600, RecordClass.IN, DNSRecordType.A, "1.2.3.4" @@ -24,12 +24,23 @@ class TestBindFileParsing(unittest.TestCase): ) self.assertEqual( BindRecord( - "sub.test.com.", 600, RecordClass.IN, DNSRecordType.A, "4.3.2.1" + "test.com.", + 600, + RecordClass.IN, + DNSRecordType.A, + "1.2.3.5", + comment="This is a comment", ), bind_file.records[1], ) self.assertEqual( BindRecord( + "sub.test.com.", 600, RecordClass.IN, DNSRecordType.A, "4.3.2.1" + ), + bind_file.records[2], + ) + self.assertEqual( + BindRecord( "test.com.", 600, RecordClass.IN, @@ -37,13 +48,13 @@ class TestBindFileParsing(unittest.TestCase): "2001:db8::1", comment="This is a comment", ), - bind_file.records[2], + bind_file.records[3], ) self.assertEqual( BindRecord( "test.com.", 1234, RecordClass.IN, DNSRecordType.TXT, "pkb-client" ), - bind_file.records[3], + bind_file.records[4], ) self.assertEqual( BindRecord( @@ -54,7 +65,7 @@ class TestBindFileParsing(unittest.TestCase): "mail.test.com.", prio=10, ), - bind_file.records[4], + bind_file.records[5], ) with self.subTest("Without default TTL"): @@ -63,7 +74,7 @@ class TestBindFileParsing(unittest.TestCase): self.assertEqual("test.com.", bind_file.origin) self.assertEqual(None, bind_file.ttl) - self.assertEqual(5, len(bind_file.records)) + self.assertEqual(7, len(bind_file.records)) self.assertEqual( BindRecord( "test.com.", 600, RecordClass.IN, DNSRecordType.A, "1.2.3.4" @@ -72,12 +83,23 @@ class TestBindFileParsing(unittest.TestCase): ) self.assertEqual( BindRecord( - "sub.test.com.", 600, RecordClass.IN, DNSRecordType.A, "4.3.2.1" + "test.com.", + 600, + RecordClass.IN, + DNSRecordType.A, + "1.2.3.5", + comment="This is a comment", ), bind_file.records[1], ) self.assertEqual( BindRecord( + "sub.test.com.", 600, RecordClass.IN, DNSRecordType.A, "4.3.2.1" + ), + bind_file.records[2], + ) + self.assertEqual( + BindRecord( "test.com.", 700, RecordClass.IN, @@ -85,13 +107,13 @@ class TestBindFileParsing(unittest.TestCase): "2001:db8::1", comment="This is a comment", ), - bind_file.records[2], + bind_file.records[3], ) self.assertEqual( BindRecord( "test.com.", 700, RecordClass.IN, DNSRecordType.TXT, "pkb-client" ), - bind_file.records[3], + bind_file.records[4], ) self.assertEqual( BindRecord( @@ -102,7 +124,7 @@ class TestBindFileParsing(unittest.TestCase): "mail.test.com.", prio=10, ), - bind_file.records[4], + bind_file.records[5], ) def test_writing_bind_file(self): @@ -131,11 +153,11 @@ class TestBindFileParsing(unittest.TestCase): file_content = ( "$ORIGIN test.com.\n" "$TTL 1234\n" - "test.com. 600 IN A 1.2.3.4\n" - "sub.test.com. 700 IN A 4.3.2.1\n" - "test.com. 600 IN AAAA 2001:db8::1\n" - "test.com. 600 IN TXT pkb-client\n" - "test.com. 600 IN MX 10 mail.test.com.\n" + 'test.com. 600 IN A "1.2.3.4"\n' + 'sub.test.com. 700 IN A "4.3.2.1"\n' + 'test.com. 600 IN AAAA "2001:db8::1"\n' + 'test.com. 600 IN TXT "pkb-client"\n' + 'test.com. 600 IN MX 10 "mail.test.com."\n' ) with tempfile.NamedTemporaryFile() as f: diff --git a/tests/client.py b/tests/client.py index 3b3a813..719ab84 100644 --- a/tests/client.py +++ b/tests/client.py @@ -1005,6 +1005,19 @@ class TestClientAuth(unittest.TestCase): ) ], ) + responses.post( + url=urljoin(API_ENDPOINT, "domain/updateNs/example.com"), + json={"status": "SUCCESS"}, + match=[ + matchers.json_params_matcher( + { + "apikey": "key", + "secretapikey": "secret", + "ns": ["ns1.example.com"], + } + ) + ], + ) with tempfile.TemporaryDirectory() as temp_dir: filename = Path(temp_dir, "records.bind") @@ -1015,7 +1028,8 @@ class TestClientAuth(unittest.TestCase): "$TTL 1234\n" "@ IN SOA dns.example.com. dns2.example.com. (100 300 100 6000 600)\n" "example.com. IN 600 A 127.0.0.3\n" - "sub.example.com. 600 IN A 127.0.0.4" + "sub.example.com. 600 IN A 127.0.0.4\n" + "example.com IN 86400 NS ns1.example.com." ) ) diff --git a/tests/data/test.bind b/tests/data/test.bind index b99dcf4..4f04129 100644 --- a/tests/data/test.bind +++ b/tests/data/test.bind @@ -2,9 +2,11 @@ $ORIGIN test.com. $TTL 1234 @ IN SOA dns.example.com. dns2.example.com. (100 300 100 6000 600) test.com. IN 600 A 1.2.3.4 -test.com. HS 600 A 1.2.3.4 -sub.test.com. 600 IN A 4.3.2.1 -@ IN 600 AAAA 2001:db8::1 ; This is a comment +test.com. IN 600 A 1.2.3.5 ; This is a comment +test.com. HS 600 A "1.2.3.4" +sub.test.com. 600 IN A "4.3.2.1" +@ IN 600 AAAA "2001:db8::1" ; This is a comment -test.com. IN TXT pkb-client -test.com. 600 IN MX 10 mail.test.com. +test.com. IN TXT "pkb-client" +test.com. 600 IN MX 10 "mail.test.com." +test.com. 600 IN TXT "test;test2" ; This is a comment diff --git a/tests/data/test_no_ttl.bind b/tests/data/test_no_ttl.bind index 7dac0ff..52e9b0f 100644 --- a/tests/data/test_no_ttl.bind +++ b/tests/data/test_no_ttl.bind @@ -1,9 +1,11 @@ $ORIGIN test.com. @ IN SOA dns.example.com. dns2.example.com. (100 300 100 6000 600) test.com. IN 600 A 1.2.3.4 -test.com. HS 600 A 1.2.3.4 -sub.test.com. 600 IN A 4.3.2.1 -@ IN 700 AAAA 2001:db8::1 ; This is a comment +test.com. IN 600 A 1.2.3.5 ; This is a comment +test.com. HS 600 A "1.2.3.4" +sub.test.com. 600 IN A "4.3.2.1" +@ IN 700 AAAA "2001:db8::1" ; This is a comment -test.com. IN TXT pkb-client -test.com. 600 IN MX 10 mail.test.com. +test.com. IN TXT "pkb-client" +test.com. 600 IN MX 10 "mail.test.com." +test.com. 600 IN TXT "test;test2" ; This is a comment |
