]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Rework the Python RNDC client API around isctest conventions
authorNicki Křížek <nicki@isc.org>
Tue, 14 Jul 2026 11:41:39 +0000 (11:41 +0000)
committerNicki Křížek <nicki@isc.org>
Thu, 23 Jul 2026 10:03:36 +0000 (12:03 +0200)
Rename the class to RNDCClient and align its interface with the
existing isctest.run helpers so that the binary and the Python RNDC
interfaces feel the same:

- take separate ip/port arguments and accept commands as str
- default the HMAC algorithm and secret to the _common/rndc.key
  values used by virtually all control channels in the system tests
- call() returns an RNDCResult mirroring CmdResult (rc/out/err) and
  raises RNDCException on a non-zero result code, with the same
  raise_on_exception escape hatch as isctest.run.cmd(); the decoded
  protocol response stays available as RNDCResult.response
- support the context manager protocol and add close(); the socket
  gets a timeout so a stuck server can't hang a test, and a failed
  login closes the socket instead of leaking it
- use sendall() and an exact-read loop to avoid short reads/writes
  (MSG_WAITALL is ineffective on a socket with a timeout, which
  operates in non-blocking mode)
- raise RNDCProtocolError for truncated, malformed, or
  unauthenticated responses instead of overloading OSError
- draw the initial message serial from the full 32-bit space like
  the rndc binary, keeping the odds of tripping named's duplicate
  message suppression negligible

Assisted-by: Claude:claude-fable-5
bin/tests/system/isctest/rndc.py

index 5f3e5fd94475b21444da4488e7398c6e93c7d7d5..03a858bccf91377e563f0b7322d7cb4cef5220af 100644 (file)
@@ -23,9 +23,47 @@ import socket
 import struct
 import time
 
+from .text import Text
 
-class rndc:
-    """RNDC protocol client library"""
+
+class RNDCException(Exception):
+    """
+    Raised when an RNDC command returns a non-zero result code.
+    """
+
+    def __init__(self, result: "RNDCResult") -> None:
+        super().__init__(f'rndc command failed with result {result.rc}: "{result.err}"')
+        self.result = result
+
+
+class RNDCProtocolError(Exception):
+    """
+    Raised when the control channel yields a truncated, malformed, or
+    unauthenticated response.
+    """
+
+
+class RNDCResult:
+    """
+    Result of an RNDC command; mirrors isctest.run.CmdResult.
+    """
+
+    def __init__(self, response: dict[str, str]) -> None:
+        self.response = response
+        self.rc = int(response.get("result", "0"))
+        self.out = Text(response.get("text", ""))
+        self.err = Text(response.get("err", ""))
+
+
+class RNDCClient:
+    """
+    RNDC protocol client.
+
+    A pure-Python alternative to controlling a server with the rndc
+    binary (`NamedInstance.rndc()`), useful when the overhead of
+    spawning the binary for every command is undesirable. Exercising
+    the rndc binary itself remains the primary interface in tests.
+    """
 
     _algos = {
         "md5": 157,
@@ -36,24 +74,55 @@ class rndc:
         "sha512": 165,
     }
 
-    def __init__(self, host: tuple[str, int], algo: str, secret: str) -> None:
-        """Creates a persistent connection to RNDC and logs in
-        host - (ip, port) tuple
+    def __init__(
+        self,
+        ip: str,
+        port: int,
+        algo: str = "sha256",
+        secret: str = "1234abcd8765",
+        timeout: float = 10,
+    ) -> None:
+        """
+        Creates a persistent connection to the control channel and logs in.
+
         algo - HMAC algorithm, one of md5, sha1, sha224, sha256, sha384, sha512
-        secret - HMAC secret, base64 encoded"""
-        self.host = host
+        secret - HMAC secret, base64 encoded
+
+        The `algo` and `secret` defaults match _common/rndc.key, which
+        virtually all named instances in the system tests use for their
+        control channel.
+        """
         self.algo = algo
         self.hlalgo = getattr(hashlib, algo)
         self.secret = base64.b64decode(secret)
-        self.ser = random.randint(0, 1 << 24)
+        self.ser = random.getrandbits(32)
         self.nonce: bytes | None = None
-        self._connect_login()
+        self.socket = socket.create_connection((ip, port), timeout=timeout)
+        try:
+            self._login()
+        except (OSError, RNDCProtocolError):
+            self.socket.close()
+            raise
+
+    def __enter__(self) -> "RNDCClient":
+        return self
+
+    def __exit__(self, *_: Any) -> None:
+        self.close()
 
-    def call(self, cmd: bytes) -> dict[bytes, bytes]:
-        """Call a RNDC command, all parsing is done on the server side
-        cmd - a complete command as bytes (eg b'reload zone example.com')
+    def close(self) -> None:
+        self.socket.close()
+
+    def call(self, command: str, *, raise_on_exception: bool = True) -> RNDCResult:
+        """
+        Call an RNDC command and check its result.
         """
-        return dict(self._command({b"type": cmd})[b"_data"])
+        response = self._command({b"type": command.encode()})
+        data = {k.decode(): v.decode() for k, v in response[b"_data"].items()}
+        result = RNDCResult(data)
+        if result.rc != 0 and raise_on_exception:
+            raise RNDCException(result)
+        return result
 
     def _serialize_dict(
         self, data: dict[bytes, Any], ignore_auth: bool = False
@@ -74,7 +143,7 @@ class rndc:
         return rv
 
     def _prep_message(self, data: dict[bytes, Any]) -> bytes:
-        self.ser += 1
+        self.ser = (self.ser + 1) & 0xFFFFFFFF
         now = int(time.time())
 
         d: dict[bytes, Any] = {}
@@ -108,38 +177,55 @@ class rndc:
         my_hash = hmac.new(self.secret, my_msg, self.hlalgo).digest()
         return my_hash == remote_hash
 
+    def _recv_exact(self, length: int) -> bytes:
+        # MSG_WAITALL would not help here: the socket timeout puts the
+        # socket in non-blocking mode, where the kernel may return
+        # partial data regardless of the flag.
+        buf = b""
+        while len(buf) < length:
+            chunk = self.socket.recv(length - len(buf))
+            if not chunk:
+                raise RNDCProtocolError(
+                    "connection closed mid-response; possible authentication failure"
+                )
+            buf += chunk
+        return buf
+
     def _command(self, data: dict[bytes, Any]) -> dict[bytes, Any]:
         msg = self._prep_message(data)
-        sent = self.socket.send(msg)
-        if sent != len(msg):
-            raise OSError("Cannot send the message")
-
-        header = self.socket.recv(8)
-        if len(header) != 8:
-            # What should we throw here? Bad auth can cause this...
-            raise OSError("Can't read response header")
+        self.socket.sendall(msg)
 
+        header = self._recv_exact(8)
         length, version = struct.unpack(">II", header)
         if version != 1:
-            raise NotImplementedError(f"Wrong message version {version}")
+            raise RNDCProtocolError(f"Unsupported message version {version}")
 
-        # it includes the header
-        length -= 4
-        payload = self.socket.recv(length, socket.MSG_WAITALL)
-        if len(payload) != length:
-            raise OSError("Can't read response data")
+        # the length field also covers the 4-byte version word
+        payload = self._recv_exact(length - 4)
 
-        response = self._parse_message(payload)
-        if not self._verify_msg(response):
-            raise OSError("Authentication failure")
+        try:
+            response = self._parse_dict(payload)
+            verified = self._verify_msg(response)
+        except (
+            KeyError,
+            IndexError,
+            ValueError,
+            struct.error,
+            NotImplementedError,
+        ) as exc:
+            raise RNDCProtocolError(f"Malformed response ({exc})") from exc
+        if not verified:
+            raise RNDCProtocolError("HMAC verification of the response failed")
 
         return response
 
-    def _connect_login(self) -> None:
-        self.socket = socket.create_connection(self.host)
+    def _login(self) -> None:
         self.nonce = None
         msg = self._command({b"type": b"null"})
-        self.nonce = msg[b"_ctrl"][b"_nonce"]
+        try:
+            self.nonce = msg[b"_ctrl"][b"_nonce"]
+        except KeyError as exc:
+            raise RNDCProtocolError("Login response is missing a nonce") from exc
 
     def _parse_element(self, buf: bytes) -> tuple[bytes, Any, bytes]:
         pos = 0
@@ -157,17 +243,12 @@ class rndc:
 
         if etype == 1:  # raw binary value
             return label, data, rest
-        elif etype == 2:  # dictionary
-            d: dict[bytes, Any] = {}
-            while len(data) > 0:
-                ilabel, value, data = self._parse_element(data)
-                d[ilabel] = value
-            return label, d, rest
-        # TODO type 3 - list
-        else:
-            raise NotImplementedError(f"Unknown element type {etype}")
+        if etype == 2:  # dictionary
+            return label, self._parse_dict(data), rest
+        # element type 3 (list) is not implemented
+        raise NotImplementedError(f"Unknown element type {etype}")
 
-    def _parse_message(self, buf: bytes) -> dict[bytes, Any]:
+    def _parse_dict(self, buf: bytes) -> dict[bytes, Any]:
         rv: dict[bytes, Any] = {}
         while len(buf) > 0:
             label, value, buf = self._parse_element(buf)