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.
- rndc_python/__init__.py +36 -0
- rndc_python/cli.py +142 -0
- rndc_python/config.py +72 -0
- rndc_python/enums.py +26 -0
- rndc_python/exceptions.py +35 -0
- rndc_python/py.typed +0 -0
- rndc_python/rndc_client.py +328 -0
- rndc_python/rndc_config.py +91 -0
- rndc_python/rndc_protocol.py +98 -0
- rndc_python-0.1.0.dist-info/METADATA +123 -0
- rndc_python-0.1.0.dist-info/RECORD +14 -0
- rndc_python-0.1.0.dist-info/WHEEL +4 -0
- rndc_python-0.1.0.dist-info/entry_points.txt +2 -0
- rndc_python-0.1.0.dist-info/licenses/LICENSE +22 -0
rndc_python/__init__.py
ADDED
|
@@ -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,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
|
+
|