rndc-python 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.
@@ -0,0 +1,36 @@
1
+ """
2
+ rndc-python - A Python client library for ISC BIND's RNDC
3
+
4
+ This library provides a Python interface to ISC BIND's Remote Name Daemon Control (RNDC).
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "David Groves"
9
+ __email__ = "dave@fibrecat.org"
10
+
11
+ # Import main classes and enums
12
+
13
+ from .enums import RNDCDataType, TSIGAlgorithm
14
+ from .exceptions import (
15
+ RNDCAuthenticationError,
16
+ RNDCConnectionError,
17
+ RNDCError,
18
+ RNDCZoneAlreadyExistsError,
19
+ RNDCZoneNotFoundError,
20
+ )
21
+ from .rndc_client import RNDCClient
22
+ from .rndc_config import RNDCConfig, rndc_config
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ "RNDCClient",
27
+ "TSIGAlgorithm",
28
+ "RNDCDataType",
29
+ "RNDCError",
30
+ "RNDCAuthenticationError",
31
+ "RNDCConnectionError",
32
+ "RNDCZoneNotFoundError",
33
+ "RNDCZoneAlreadyExistsError",
34
+ "RNDCConfig",
35
+ "rndc_config",
36
+ ]
rndc_python/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """
2
+ Command-line interface for rndc-python.
3
+
4
+ Usage:
5
+ rndc-python-cli [options] <command>
6
+
7
+ Examples:
8
+ rndc-python-cli status
9
+ rndc-python-cli reload
10
+ rndc-python-cli zonestatus example.com
11
+ rndc-python-cli --host 127.0.0.1 --port 953 status
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+
18
+ import click
19
+
20
+ ALGORITHM_CHOICES = [
21
+ "md5",
22
+ "sha1",
23
+ "sha224",
24
+ "sha256",
25
+ "sha384",
26
+ "sha512",
27
+ "hmac-md5",
28
+ "hmac-sha1",
29
+ "hmac-sha224",
30
+ "hmac-sha256",
31
+ "hmac-sha384",
32
+ "hmac-sha512",
33
+ ]
34
+
35
+
36
+ @click.command()
37
+ @click.option(
38
+ "-s",
39
+ "--host",
40
+ envvar="ZPAPI_RNDC_HOST",
41
+ help="RNDC server hostname or IP",
42
+ )
43
+ @click.option(
44
+ "-p",
45
+ "--port",
46
+ type=int,
47
+ envvar="ZPAPI_RNDC_PORT",
48
+ help="RNDC server port",
49
+ )
50
+ @click.option(
51
+ "-a",
52
+ "--algorithm",
53
+ type=click.Choice(ALGORITHM_CHOICES, case_sensitive=False),
54
+ envvar="ZPAPI_RNDC_ALGORITHM",
55
+ help="TSIG algorithm",
56
+ )
57
+ @click.option(
58
+ "-k",
59
+ "--secret",
60
+ envvar="ZPAPI_RNDC_SECRET",
61
+ help="Base64-encoded RNDC secret key",
62
+ )
63
+ @click.option(
64
+ "-t",
65
+ "--timeout",
66
+ type=int,
67
+ envvar="ZPAPI_RNDC_TIMEOUT",
68
+ default=10,
69
+ help="Connection timeout in seconds",
70
+ )
71
+ @click.argument("command", nargs=-1, required=True)
72
+ def main(
73
+ host: str | None,
74
+ port: int | None,
75
+ algorithm: str | None,
76
+ secret: str | None,
77
+ timeout: int,
78
+ command: tuple[str, ...],
79
+ ) -> None:
80
+ """Python client for ISC BIND's RNDC.
81
+
82
+ COMMAND is the RNDC command to execute (e.g., status, reload, zonestatus example.com).
83
+ """
84
+ # Lazy imports to avoid triggering global config at import time
85
+ from .rndc_client import RNDCClient
86
+ from .rndc_config import _parse_algorithm
87
+
88
+ # Validate required options
89
+ if not host:
90
+ raise click.ClickException("Missing --host or ZPAPI_RNDC_HOST environment variable")
91
+ if not port:
92
+ raise click.ClickException("Missing --port or ZPAPI_RNDC_PORT environment variable")
93
+ if not algorithm:
94
+ raise click.ClickException(
95
+ "Missing --algorithm or ZPAPI_RNDC_ALGORITHM environment variable"
96
+ )
97
+ if not secret:
98
+ raise click.ClickException("Missing --secret or ZPAPI_RNDC_SECRET environment variable")
99
+
100
+ # Build client kwargs
101
+ client_kwargs: dict = {
102
+ "host": host,
103
+ "port": port,
104
+ "algorithm": _parse_algorithm(algorithm),
105
+ "secret": secret,
106
+ "timeout": timeout,
107
+ }
108
+
109
+ # Join command parts into a single RNDC command string
110
+ rndc_command = " ".join(command)
111
+
112
+ try:
113
+ with RNDCClient(**client_kwargs) as client:
114
+ result = client.call(rndc_command)
115
+
116
+ # Print the response
117
+ if "text" in result:
118
+ click.echo(result["text"])
119
+ elif "err" in result and result["err"]:
120
+ raise click.ClickException(result["err"])
121
+ elif result:
122
+ # Print any other response data
123
+ for key, value in result.items():
124
+ if key not in ("type", "result"):
125
+ click.echo(f"{key}: {value}")
126
+
127
+ # Check result code if present
128
+ if result.get("result") and result["result"] != "0":
129
+ sys.exit(int(result["result"]))
130
+
131
+ except click.ClickException:
132
+ raise
133
+ except ValueError as e:
134
+ raise click.ClickException(f"Configuration error: {e}") from None
135
+ except ConnectionError as e:
136
+ raise click.ClickException(f"Connection error: {e}") from None
137
+ except Exception as e:
138
+ raise click.ClickException(str(e)) from None
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
rndc_python/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ Configuration utilities.
3
+
4
+ This module contains shared configuration functions and utilities used by both
5
+ RNDC and DDNS configuration modules.
6
+ """
7
+
8
+ import os
9
+
10
+ from dotenv import load_dotenv
11
+
12
+
13
+ def _load_env_file() -> None:
14
+ """Load environment variables from .env file if it exists."""
15
+ if os.path.exists(".env"):
16
+ load_dotenv(".env")
17
+
18
+
19
+ def _get_required_env_var(key: str) -> str:
20
+ """Get required environment variable."""
21
+ value = os.getenv(key)
22
+ if value is None:
23
+ raise ValueError(f"Required environment variable {key} is not set")
24
+ return value
25
+
26
+
27
+ def _parse_port(port_str: str) -> int:
28
+ """Parse port string to integer."""
29
+ try:
30
+ port = int(port_str)
31
+ if not (1 <= port <= 65535):
32
+ raise ValueError(f"Port must be between 1 and 65535, got {port}")
33
+ return port
34
+ except ValueError as e:
35
+ if isinstance(e, ValueError) and "Port must be between" in str(e):
36
+ raise
37
+ raise ValueError(f"Invalid port number: {port_str}") from e
38
+
39
+
40
+ def _parse_timeout(timeout_str: str) -> int:
41
+ """Parse timeout string to integer."""
42
+ try:
43
+ timeout = int(timeout_str)
44
+ if timeout <= 0:
45
+ raise ValueError(f"Timeout must be positive, got {timeout}")
46
+ return timeout
47
+ except ValueError as e:
48
+ if isinstance(e, ValueError) and "Timeout must be positive" in str(e):
49
+ raise
50
+ raise ValueError(f"Invalid timeout value: {timeout_str}") from e
51
+
52
+
53
+ def _parse_int_env_var(key: str, default: int) -> int:
54
+ """Parse integer environment variable with default value."""
55
+ value = os.getenv(key)
56
+ if value is None:
57
+ return default
58
+ try:
59
+ return int(value)
60
+ except ValueError:
61
+ raise ValueError(f"Invalid integer value for {key}: {value}") from None
62
+
63
+
64
+ def _parse_float_env_var(key: str, default: float) -> float:
65
+ """Parse float environment variable with default value."""
66
+ value = os.getenv(key)
67
+ if value is None:
68
+ return default
69
+ try:
70
+ return float(value)
71
+ except ValueError:
72
+ raise ValueError(f"Invalid float value for {key}: {value}") from None
rndc_python/enums.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ RNDC enums and constants.
3
+
4
+ This module contains the enumeration classes and constants used by the RNDC protocol.
5
+ """
6
+
7
+ import enum
8
+
9
+
10
+ class TSIGAlgorithm(enum.IntEnum):
11
+ """TSIG authentication algorithms."""
12
+
13
+ MD5 = 157
14
+ SHA1 = 161
15
+ SHA224 = 162
16
+ SHA256 = 163
17
+ SHA384 = 164
18
+ SHA512 = 165
19
+
20
+
21
+ class RNDCDataType(enum.IntEnum):
22
+ """RNDC data types for message serialization."""
23
+
24
+ RAW = 1
25
+ DICT = 2
26
+ LIST = 3
@@ -0,0 +1,35 @@
1
+ """
2
+ RNDC exceptions.
3
+
4
+ This module contains custom exceptions used by the RNDC client.
5
+ """
6
+
7
+
8
+ class RNDCError(Exception):
9
+ """Base exception for RNDC-related errors."""
10
+
11
+ pass
12
+
13
+
14
+ class RNDCAuthenticationError(RNDCError):
15
+ """Raised when authentication fails."""
16
+
17
+ pass
18
+
19
+
20
+ class RNDCConnectionError(RNDCError):
21
+ """Raised when connection or communication fails."""
22
+
23
+ pass
24
+
25
+
26
+ class RNDCZoneNotFoundError(RNDCError):
27
+ """Raised when a zone is not found."""
28
+
29
+ pass
30
+
31
+
32
+ class RNDCZoneAlreadyExistsError(RNDCError):
33
+ """Raised when a zone already exists."""
34
+
35
+ pass
rndc_python/py.typed ADDED
File without changes
@@ -0,0 +1,328 @@
1
+ """
2
+ RNDC client implementation.
3
+
4
+ This module contains the main RNDC client class for communicating with BIND DNS servers.
5
+ """
6
+
7
+ import base64
8
+ import ipaddress
9
+ import logging
10
+ import random
11
+ import socket
12
+ import struct
13
+ import time
14
+ import typing
15
+
16
+ import dns
17
+ import dns.rdataclass
18
+
19
+ from . import rndc_protocol
20
+ from .enums import TSIGAlgorithm
21
+ from .exceptions import (
22
+ RNDCAuthenticationError,
23
+ RNDCConnectionError,
24
+ RNDCZoneAlreadyExistsError,
25
+ RNDCZoneNotFoundError,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+ logger.setLevel(logging.DEBUG)
30
+
31
+
32
+ class RNDCClient:
33
+ """RNDC client for communicating with BIND DNS servers."""
34
+
35
+ def __init__(
36
+ self,
37
+ host: str | ipaddress.IPv4Address | ipaddress.IPv6Address | None = None,
38
+ port: int | None = None,
39
+ algorithm: TSIGAlgorithm | None = None,
40
+ secret: str | None = None,
41
+ timeout: int | None = None,
42
+ max_retries: int | None = None,
43
+ retry_delay: float | None = None,
44
+ ) -> None:
45
+ """Initialize RNDC client."""
46
+
47
+ # Import here to avoid circular imports
48
+ from .rndc_config import rndc_config
49
+
50
+ # Use provided values or fall back to environment configuration (if available)
51
+ # rndc_config may be None if env vars are not set
52
+ if host is not None:
53
+ self.host = host
54
+ elif rndc_config is not None:
55
+ self.host = rndc_config.host
56
+ else:
57
+ raise ValueError("host is required (provide it or set ZPAPI_RNDC_HOST)")
58
+
59
+ if port is not None:
60
+ self.port = port
61
+ elif rndc_config is not None:
62
+ self.port = rndc_config.port
63
+ else:
64
+ raise ValueError("port is required (provide it or set ZPAPI_RNDC_PORT)")
65
+
66
+ if algorithm is not None:
67
+ self.algorithm = algorithm
68
+ elif rndc_config is not None:
69
+ self.algorithm = rndc_config.algorithm
70
+ else:
71
+ raise ValueError("algorithm is required (provide it or set ZPAPI_RNDC_ALGORITHM)")
72
+
73
+ if secret is not None:
74
+ self.secret = base64.b64decode(secret)
75
+ elif rndc_config is not None:
76
+ self.secret = base64.b64decode(rndc_config.secret)
77
+ else:
78
+ raise ValueError("secret is required (provide it or set ZPAPI_RNDC_SECRET)")
79
+
80
+ if timeout is not None:
81
+ self.timeout = timeout
82
+ elif rndc_config is not None:
83
+ self.timeout = rndc_config.timeout
84
+ else:
85
+ self.timeout = 10 # Default timeout
86
+
87
+ if max_retries is not None:
88
+ self.max_retries = max_retries
89
+ elif rndc_config is not None:
90
+ self.max_retries = rndc_config.max_retries
91
+ else:
92
+ self.max_retries = 3 # Default max retries
93
+
94
+ if retry_delay is not None:
95
+ self.retry_delay = retry_delay
96
+ elif rndc_config is not None:
97
+ self.retry_delay = rndc_config.retry_delay
98
+ else:
99
+ self.retry_delay = 1.0 # Default retry delay
100
+
101
+ # Internal state
102
+ self._serial = random.randint(0, 1 << 24)
103
+ self._nonce: str | None = None
104
+ self._socket: socket.socket | None = None
105
+
106
+ # Establish connection
107
+ self._connect()
108
+
109
+ def _connect(self) -> None:
110
+ """Establish connection to RNDC server and perform initial handshake."""
111
+ logger.info(f"Connecting to RNDC server at {self.host}:{self.port}")
112
+ try:
113
+ self._socket = socket.create_connection(
114
+ (str(self.host), self.port), timeout=self.timeout
115
+ )
116
+ self._nonce = None
117
+
118
+ # Perform null command to get nonce
119
+ response = self._command(type="null")
120
+ self._nonce = response["_ctrl"]["_nonce"]
121
+
122
+ except OSError as e:
123
+ raise RNDCConnectionError(f"Failed to connect to {self.host}:{self.port}: {e}") from e
124
+
125
+ def _ensure_connected(self) -> None:
126
+ """Ensure we have a valid connection, reconnecting if necessary."""
127
+ if self._socket is None:
128
+ logger.info("No socket connection, reconnecting...")
129
+ self._connect()
130
+ return
131
+
132
+ # Test if the connection is still alive by trying to get socket info
133
+ try:
134
+ # This will raise an exception if the socket is closed
135
+ self._socket.getpeername()
136
+ except OSError:
137
+ logger.info("Socket connection lost, reconnecting...")
138
+ self._socket.close()
139
+ self._socket = None
140
+ self._nonce = None
141
+ self._connect()
142
+
143
+ def _prepare_message(self, **kwargs: typing.Any) -> bytes:
144
+ """Prepare RNDC message with authentication."""
145
+ self._serial += 1
146
+ now = int(time.time())
147
+
148
+ # Build message structure
149
+ message = {
150
+ "_auth": {},
151
+ "_ctrl": {
152
+ "_ser": str(self._serial),
153
+ "_tim": str(now),
154
+ "_exp": str(now + 60),
155
+ },
156
+ "_data": kwargs,
157
+ }
158
+
159
+ if self._nonce is not None:
160
+ message["_ctrl"]["_nonce"] = self._nonce
161
+
162
+ # Serialize without auth for hashing
163
+ serialized = rndc_protocol.serialize_dict(message, ignore_auth=True)
164
+
165
+ # Create HMAC
166
+ hash_digest = rndc_protocol.create_hmac(self.secret, serialized, self.algorithm)
167
+ b64_hash = base64.b64encode(hash_digest)
168
+
169
+ # Add authentication
170
+ if self.algorithm == TSIGAlgorithm.MD5:
171
+ message["_auth"]["hmd5"] = struct.pack("22s", b64_hash)
172
+ else:
173
+ message["_auth"]["hsha"] = struct.pack("B88s", self.algorithm, b64_hash)
174
+
175
+ # Final serialization
176
+ final_msg = rndc_protocol.serialize_dict(message)
177
+ return struct.pack(">II", len(final_msg) + 4, 1) + final_msg
178
+
179
+ def _verify_message(self, message: dict) -> bool:
180
+ """Verify message authentication."""
181
+ if self._nonce is not None and message["_ctrl"]["_nonce"] != self._nonce:
182
+ return False
183
+
184
+ # Extract hash
185
+ auth_key = "hmd5" if self.algorithm == TSIGAlgorithm.MD5 else "hsha"
186
+ hash_data = message["_auth"][auth_key]
187
+
188
+ # For SHA algorithms, the first byte is the algorithm ID, followed by base64 hash
189
+ if self.algorithm != TSIGAlgorithm.MD5:
190
+ # Skip the algorithm ID byte and extract the base64 hash
191
+ b64_hash = hash_data[1:].rstrip(b"\x00").decode("ascii")
192
+ else:
193
+ # For MD5, it's just the base64 hash
194
+ b64_hash = hash_data.rstrip(b"\x00").decode("ascii")
195
+
196
+ # Pad base64 if needed
197
+ b64_hash += "=" * (4 - (len(b64_hash) % 4))
198
+
199
+ try:
200
+ remote_hash = base64.b64decode(b64_hash)
201
+ except Exception:
202
+ return False
203
+
204
+ # Verify hash
205
+ my_msg = rndc_protocol.serialize_dict(message, ignore_auth=True)
206
+ return rndc_protocol.verify_hmac(self.secret, my_msg, self.algorithm, remote_hash)
207
+
208
+ def _command(self, **kwargs: typing.Any) -> dict:
209
+ """Send command to RNDC server and receive response."""
210
+ # Ensure we have a valid connection before proceeding
211
+ self._ensure_connected()
212
+
213
+ if self._socket is None:
214
+ raise RNDCConnectionError("Not connected to RNDC server")
215
+
216
+ # Prepare and send message
217
+ message = self._prepare_message(**kwargs)
218
+ sent = self._socket.send(message)
219
+ if sent != len(message):
220
+ raise RNDCConnectionError("Failed to send complete message")
221
+
222
+ # Receive header
223
+ header = self._socket.recv(8)
224
+ if len(header) != 8:
225
+ raise RNDCAuthenticationError("Failed to read response header")
226
+
227
+ # Parse header
228
+ msg_len, version = struct.unpack(">II", header)
229
+ if version != 1:
230
+ raise NotImplementedError(f"Unsupported message version: {version}")
231
+
232
+ # Receive message body
233
+ msg_len -= 4 # Remove header size
234
+ data = self._socket.recv(msg_len, socket.MSG_WAITALL)
235
+ if len(data) != msg_len:
236
+ raise RNDCConnectionError("Failed to read complete response")
237
+
238
+ # Parse and verify message
239
+ parsed_msg = rndc_protocol.parse_message(data)
240
+ if not self._verify_message(parsed_msg):
241
+ raise RNDCAuthenticationError("Message authentication failed")
242
+
243
+ return parsed_msg
244
+
245
+ def call(self, command: str) -> dict[str, str]:
246
+ """
247
+ Execute RNDC command.
248
+
249
+ Args:
250
+ command: RNDC command string (e.g., 'status', 'reload zone example.com')
251
+
252
+ Returns:
253
+ Command response data with values decoded from ASCII bytes to strings
254
+ """
255
+ logger.info(f"Running command {command}")
256
+ response = self._command(type=command)
257
+ logger.info(f"Response: {response}")
258
+
259
+ # Decode response as ASCII if appropriate.
260
+ return {
261
+ k: v.decode("ascii") if isinstance(v, bytes) else v
262
+ for k, v in response["_data"].items()
263
+ }
264
+
265
+ def close(self) -> None:
266
+ """Close the RNDC connection."""
267
+ if self._socket:
268
+ self._socket.close()
269
+ self._socket = None
270
+
271
+ def __enter__(self) -> "RNDCClient":
272
+ """Context manager entry."""
273
+ return self
274
+
275
+ def __exit__(self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any) -> None:
276
+ """Context manager exit."""
277
+ self.close()
278
+
279
+ def add_zone(
280
+ self,
281
+ name: str,
282
+ dnsclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
283
+ view: str | None = None,
284
+ template: str | None = "primary",
285
+ ) -> None:
286
+ """Add a zone to the RNDC server."""
287
+ cmd = f"addzone {name} {dnsclass.name} {view or ''} {{template {template};}};"
288
+ result = self.call(cmd)
289
+
290
+ # Safely check if result exists and has the expected structure
291
+ if result.get("result") == "16" and result.get("err") == "already exists":
292
+ raise RNDCZoneAlreadyExistsError(f"Zone {name} already exists")
293
+
294
+ def del_zone(
295
+ self,
296
+ name: str,
297
+ clean: bool = False,
298
+ dnsclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
299
+ view: str | None = None,
300
+ ) -> None:
301
+ """Delete a zone from the RNDC server."""
302
+ suffix = f"{name} {dnsclass.name} {view or ''}"
303
+
304
+ if clean:
305
+ result = self.call(f"delzone -clean {suffix}")
306
+ else:
307
+ result = self.call(f"delzone {suffix}")
308
+
309
+ if result.get("result") == "20" and result.get("err") == "not found":
310
+ raise RNDCZoneNotFoundError(f"Zone {name} not found")
311
+
312
+ def set_trace_level(self, level: int) -> None:
313
+ if level < 0 or level > 99:
314
+ raise ValueError("Trace level must be an integer between 0 and 99")
315
+
316
+ """Set the trace level for the RNDC server."""
317
+ self.call(f"trace {level}")
318
+
319
+ def send_notify(
320
+ self,
321
+ zone: str,
322
+ view: str | None = None,
323
+ dnsclass: dns.rdataclass.RdataClass = dns.rdataclass.IN,
324
+ ) -> None:
325
+ """Forces server to send a notify for a zone."""
326
+ result = self.call(f"notify {zone} {view or ''} {dnsclass.name}")
327
+ if result.get("result") == "20" and result.get("err") == "not found":
328
+ raise RNDCZoneNotFoundError(f"Zone {zone} not found")
@@ -0,0 +1,91 @@
1
+ """
2
+ RNDC configuration management.
3
+
4
+ This module handles loading RNDC configuration from environment variables.
5
+ Assumes .env file contains all required defaults if they aren't set in the environment.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from .config import (
11
+ _get_required_env_var,
12
+ _load_env_file,
13
+ _parse_float_env_var,
14
+ _parse_int_env_var,
15
+ _parse_port,
16
+ _parse_timeout,
17
+ )
18
+ from .enums import TSIGAlgorithm
19
+
20
+
21
+ def _parse_algorithm(algorithm_str: str) -> TSIGAlgorithm:
22
+ """Parse algorithm string to RNDCAlgorithm enum."""
23
+ algorithm_map = {
24
+ "md5": TSIGAlgorithm.MD5,
25
+ "sha1": TSIGAlgorithm.SHA1,
26
+ "sha224": TSIGAlgorithm.SHA224,
27
+ "sha256": TSIGAlgorithm.SHA256,
28
+ "sha384": TSIGAlgorithm.SHA384,
29
+ "sha512": TSIGAlgorithm.SHA512,
30
+ }
31
+ algorithm_lower_and_stripped = algorithm_str.lower().removeprefix("hmac-")
32
+ if algorithm_lower_and_stripped not in algorithm_map:
33
+ raise ValueError(f"Unsupported algorithm: {algorithm_str}")
34
+ return algorithm_map[algorithm_lower_and_stripped]
35
+
36
+
37
+ class RNDCConfig:
38
+ """RNDC configuration loaded from environment variables."""
39
+
40
+ def __init__(
41
+ self,
42
+ host: str | None = None,
43
+ port: int | None = None,
44
+ algorithm: TSIGAlgorithm | None = None,
45
+ secret: str | None = None,
46
+ timeout: int | None = None,
47
+ max_retries: int | None = None,
48
+ retry_delay: float | None = None,
49
+ ) -> None:
50
+ _load_env_file()
51
+ self.host = host or _get_required_env_var("ZPAPI_RNDC_HOST")
52
+ self.port = port or _parse_port(_get_required_env_var("ZPAPI_RNDC_PORT"))
53
+ self.algorithm = algorithm or _parse_algorithm(
54
+ _get_required_env_var("ZPAPI_RNDC_ALGORITHM")
55
+ )
56
+ self.secret = secret or _get_required_env_var("ZPAPI_RNDC_SECRET")
57
+ self.timeout = timeout or _parse_timeout(_get_required_env_var("ZPAPI_RNDC_TIMEOUT"))
58
+ self.max_retries = max_retries or _parse_int_env_var("ZPAPI_RNDC_MAX_RETRIES", 3)
59
+ self.retry_delay = retry_delay or _parse_float_env_var("ZPAPI_RNDC_RETRY_DELAY", 1.0)
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "host": self.host,
64
+ "port": self.port,
65
+ "algorithm": self.algorithm,
66
+ "secret": self.secret,
67
+ "timeout": self.timeout,
68
+ "max_retries": self.max_retries,
69
+ "retry_delay": self.retry_delay,
70
+ }
71
+
72
+ def __repr__(self) -> str:
73
+ return (
74
+ f"RNDCConfig(host='{self.host}', port={self.port}, "
75
+ f"algorithm={self.algorithm.name}, timeout={self.timeout}, "
76
+ f"max_retries={self.max_retries}, retry_delay={self.retry_delay})"
77
+ )
78
+
79
+
80
+ # Global RNDC config instance (lazy - only created when env vars are available)
81
+ def _create_default_config() -> RNDCConfig | None:
82
+ """Create a default config if environment variables are set."""
83
+ try:
84
+ return RNDCConfig()
85
+ except ValueError:
86
+ # Environment variables not set - return None
87
+ # Users should create RNDCConfig explicitly with parameters
88
+ return None
89
+
90
+
91
+ rndc_config: RNDCConfig | None = _create_default_config()
@@ -0,0 +1,98 @@
1
+ """
2
+ RNDC protocol implementation.
3
+
4
+ This module handles the low-level RNDC protocol message serialization and deserialization.
5
+ """
6
+
7
+ import hashlib
8
+ import hmac
9
+ import struct
10
+
11
+ from .enums import RNDCDataType, TSIGAlgorithm
12
+ from .exceptions import RNDCConnectionError
13
+
14
+
15
+ def serialize_dict(data: dict, ignore_auth: bool = False) -> bytes:
16
+ """Serialize dictionary to RNDC message format."""
17
+ result = []
18
+
19
+ for key, value in data.items():
20
+ if ignore_auth and key == "_auth":
21
+ continue
22
+
23
+ result.append(chr(len(key)).encode())
24
+ result.append(key.encode())
25
+
26
+ if isinstance(value, str):
27
+ result.append(struct.pack(">BI", RNDCDataType.RAW, len(value)))
28
+ result.append(value.encode())
29
+ elif isinstance(value, bytes):
30
+ result.append(struct.pack(">BI", RNDCDataType.RAW, len(value)))
31
+ result.append(value)
32
+ elif isinstance(value, dict):
33
+ serialized = serialize_dict(value)
34
+ result.append(struct.pack(">BI", RNDCDataType.DICT, len(serialized)))
35
+ result.append(serialized)
36
+ else:
37
+ raise ValueError(f"Cannot serialize type {type(value)}")
38
+
39
+ return b"".join(result)
40
+
41
+
42
+ def parse_element(data: bytes) -> tuple[str, bytes | dict, bytes]:
43
+ """Parse a single element from RNDC message."""
44
+ if len(data) < 2:
45
+ raise RNDCConnectionError("Incomplete message data")
46
+
47
+ # Parse label
48
+ label_len = data[0]
49
+ if len(data) < 1 + label_len + 5:
50
+ raise RNDCConnectionError("Incomplete message data")
51
+
52
+ label = data[1 : 1 + label_len].decode("utf-8")
53
+ pos = 1 + label_len
54
+
55
+ # Parse type and length
56
+ data_type = data[pos]
57
+ pos += 1
58
+ data_len = struct.unpack(">I", data[pos : pos + 4])[0]
59
+ pos += 4
60
+
61
+ if len(data) < pos + data_len:
62
+ raise RNDCConnectionError("Incomplete message data")
63
+
64
+ element_data = data[pos : pos + data_len]
65
+ remaining = data[pos + data_len :]
66
+
67
+ # Parse based on type
68
+ if data_type == RNDCDataType.RAW:
69
+ return label, element_data, remaining
70
+ elif data_type == RNDCDataType.DICT:
71
+ result = {}
72
+ while element_data:
73
+ sub_label, sub_value, element_data = parse_element(element_data)
74
+ result[sub_label] = sub_value
75
+ return label, result, remaining
76
+ else:
77
+ raise NotImplementedError(f"Unsupported data type: {data_type}")
78
+
79
+
80
+ def parse_message(data: bytes) -> dict:
81
+ """Parse complete RNDC message."""
82
+ result = {}
83
+ while data:
84
+ label, value, data = parse_element(data)
85
+ result[label] = value
86
+ return result
87
+
88
+
89
+ def create_hmac(secret: bytes, data: bytes, algorithm: TSIGAlgorithm) -> bytes:
90
+ """Create HMAC for message authentication."""
91
+ hash_algorithm = getattr(hashlib, algorithm.name.lower())
92
+ return hmac.new(secret, data, hash_algorithm).digest()
93
+
94
+
95
+ def verify_hmac(secret: bytes, data: bytes, algorithm: TSIGAlgorithm, remote_hash: bytes) -> bool:
96
+ """Verify HMAC for message authentication."""
97
+ expected_hash = create_hmac(secret, data, algorithm)
98
+ return hmac.compare_digest(expected_hash, remote_hash)
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: rndc-python
3
+ Version: 0.1.0
4
+ Summary: Python client for ISC BIND's RNDC
5
+ Project-URL: Homepage, https://github.com/davidgroves/rndc-python
6
+ Project-URL: Repository, https://github.com/davidgroves/rndc-python
7
+ Project-URL: Issues, https://github.com/davidgroves/rndc-python/issues
8
+ Author-email: David Groves <dave@fibrecat.org>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: bind,dns,named,rndc,tsig
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Internet :: Name Service (DNS)
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: click>=8.1.0
28
+ Requires-Dist: dnspython>=2.6.1
29
+ Requires-Dist: python-dotenv>=1.0.1
30
+ Provides-Extra: dev
31
+ Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
33
+ Requires-Dist: ty>=0.0.3; extra == 'dev'
34
+ Provides-Extra: test
35
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'test'
36
+ Requires-Dist: pytest-mock>=3.14.0; extra == 'test'
37
+ Requires-Dist: pytest>=8.3.0; extra == 'test'
38
+ Requires-Dist: testcontainers>=4.9.0; extra == 'test'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # rndc-python
42
+
43
+ Python client library for talking to ISC BIND's RNDC service.
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.10+
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ pip install rndc-python
53
+ ```
54
+
55
+ Or using uv:
56
+
57
+ ```bash
58
+ uv add rndc-python
59
+ ```
60
+
61
+ ### Command-line Interface
62
+
63
+ The package includes a CLI tool `rndc-python-cli`:
64
+
65
+ ```bash
66
+ # Using CLI options
67
+ rndc-python-cli -s 127.0.0.1 -p 953 -a sha256 -k <base64-secret> status
68
+
69
+ # Using environment variables
70
+ export ZPAPI_RNDC_HOST=127.0.0.1
71
+ export ZPAPI_RNDC_PORT=953
72
+ export ZPAPI_RNDC_ALGORITHM=sha256
73
+ export ZPAPI_RNDC_SECRET=<base64-secret>
74
+ rndc-python-cli status
75
+
76
+ # Mix of both (CLI options override env vars)
77
+ rndc-python-cli --port 954 reload
78
+ ```
79
+
80
+ ## Configuration
81
+
82
+ The client can read its settings from environment variables (or a `.env` file):
83
+
84
+ - `ZPAPI_RNDC_HOST`
85
+ - `ZPAPI_RNDC_PORT`
86
+ - `ZPAPI_RNDC_ALGORITHM` (e.g. `hmac-sha256`)
87
+ - `ZPAPI_RNDC_SECRET`
88
+ - `ZPAPI_RNDC_TIMEOUT`
89
+ - `ZPAPI_RNDC_MAX_RETRIES`
90
+ - `ZPAPI_RNDC_RETRY_DELAY`
91
+
92
+ You can also configure the client directly in Python:
93
+
94
+ ```python
95
+ from rndc_python import RNDCClient, TSIGAlgorithm
96
+
97
+ client = RNDCClient(
98
+ host="127.0.0.1",
99
+ port=953,
100
+ algorithm=TSIGAlgorithm.SHA256,
101
+ secret="your-base64-secret-here",
102
+ timeout=10,
103
+ max_retries=3,
104
+ retry_delay=2,
105
+ )
106
+ ```
107
+
108
+ All parameters are optional if you have configured environment variables or a `.env` file.
109
+
110
+ ## Usage
111
+
112
+ ### Python API
113
+
114
+ ```python
115
+ from rndc_python import RNDCClient
116
+
117
+ with RNDCClient() as rndc_client:
118
+ print(rndc_client.call("status"))
119
+ ```
120
+
121
+ ## Development
122
+
123
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for development setup, building, and testing instructions.
@@ -0,0 +1,14 @@
1
+ rndc_python/__init__.py,sha256=VC_g3KHJK1bznl6zNXZhDmhldx6yRJWC_NKMh69t2_w,830
2
+ rndc_python/cli.py,sha256=wo6x9o5uxEwZPNy5TamFYhcn6KLVIv1eROxhzpSn1ns,3641
3
+ rndc_python/config.py,sha256=-r4e2TWJmDib2H7SspDO_3BVnVg2yv6VVR70btZfeEw,2147
4
+ rndc_python/enums.py,sha256=Vpo6Tled-iJXaVOIYf-1SUTk2hTO8LXRMjo0nizh7Og,439
5
+ rndc_python/exceptions.py,sha256=JwBFkxR2Mys7m377ziT0O751V76uIFTr7g7rIlcodI0,582
6
+ rndc_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ rndc_python/rndc_client.py,sha256=OKZMHa_w3SAjYAC9YTYAb6dOE1W-cZ4NLa6pL1I1Oic,11271
8
+ rndc_python/rndc_config.py,sha256=Nwx4KeW9OdPkq0sS7N7cQk2MhJ5ompOXVYvGnWSz1zc,3179
9
+ rndc_python/rndc_protocol.py,sha256=8A8wlQgrVgwjGIVHksY8ckejL6FV9BX2u4BjW8XiPsE,3126
10
+ rndc_python-0.1.0.dist-info/METADATA,sha256=VTNDYqXbowcDOg1-ewfGee62l4SdNj6hTBUEdBOsY4w,3258
11
+ rndc_python-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ rndc_python-0.1.0.dist-info/entry_points.txt,sha256=1OR-uWgBtXxPcdXqCEgQhH_5rLH1d8dVvMFV-cAwe5A,57
13
+ rndc_python-0.1.0.dist-info/licenses/LICENSE,sha256=z_Yb9eEaKhZqB25-54B5ulb8OrLsTPE_LGsObcEMXIs,1070
14
+ rndc_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rndc-python-cli = rndc_python.cli:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David Groves
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+