diff --git a/dns/bind/Makefile b/dns/bind/Makefile
index 334c2af720..f5728bb0bb 100644
--- a/dns/bind/Makefile
+++ b/dns/bind/Makefile
@@ -1,6 +1,6 @@
PLUGIN_NAME= bind-rp
PLUGIN_VERSION= 26.7
-PLUGIN_REVISION= 5
+PLUGIN_REVISION= 6
PLUGIN_COMMENT= BIND domain name service
PLUGIN_DEPENDS= bind920
PLUGIN_DEPEND_FORMULA= bind920 >= 9.20.26
diff --git a/dns/bind/pkg-descr b/dns/bind/pkg-descr
index a9ab2d9111..e2e5d4fc46 100644
--- a/dns/bind/pkg-descr
+++ b/dns/bind/pkg-descr
@@ -9,6 +9,7 @@ Plugin Changelog
1.36
+* Bound BIND shutdown and log forced recovery steps in the General Log
* Preserve dynamic BIND records while package upgrades regenerate zone files
* Add DHCP lease watcher and scoped dynamic DNS mappings
* Add reverse DNS zone management
diff --git a/dns/bind/src/opnsense/mvc/app/views/OPNsense/Bind/logs.volt b/dns/bind/src/opnsense/mvc/app/views/OPNsense/Bind/logs.volt
index e00a424717..d9e689549e 100644
--- a/dns/bind/src/opnsense/mvc/app/views/OPNsense/Bind/logs.volt
+++ b/dns/bind/src/opnsense/mvc/app/views/OPNsense/Bind/logs.volt
@@ -111,7 +111,11 @@
- {{ partial("OPNsense/Diagnostics/log",['module':'core','scope':'named'])}}
+ {{ partial("OPNsense/Diagnostics/log",[
+ 'module':'core',
+ 'scope':'named',
+ 'default_log_severity':'Informational'
+ ])}}
diff --git a/dns/bind/src/opnsense/scripts/OPNsense/Bind/bindStop.py b/dns/bind/src/opnsense/scripts/OPNsense/Bind/bindStop.py
index 63e2b1c01f..8accd03540 100755
--- a/dns/bind/src/opnsense/scripts/OPNsense/Bind/bindStop.py
+++ b/dns/bind/src/opnsense/scripts/OPNsense/Bind/bindStop.py
@@ -43,11 +43,15 @@
STATE_FILE = Path(ENV.get("BIND_STOP_STATE_FILE", "/var/cache/bind/dhcplease_state.json"))
CONFIG = Path(ENV.get("BIND_STOP_CONFIG", "/conf/config.xml"))
NAMED_RC = ENV.get("BIND_STOP_NAMED_RC", "/usr/local/etc/rc.d/named")
+NAMED_PIDFILE = Path(ENV.get("BIND_STOP_NAMED_PIDFILE", "/var/run/named/pid"))
+RNDC = ENV.get("BIND_STOP_RNDC", "/usr/local/sbin/rndc")
+GRACE_TIMEOUT = float(ENV.get("BIND_STOP_GRACE_TIMEOUT", "30"))
+FORCE_TIMEOUT = float(ENV.get("BIND_STOP_FORCE_TIMEOUT", "5"))
ZONE_NAME = re.compile(r"[A-Za-z0-9.-]+$")
def log(priority, message):
- syslog.openlog("bind")
+ syslog.openlog("named")
syslog.syslog(priority, message)
@@ -204,26 +208,120 @@ def clear_journals(zones):
pass
-def stop_named():
+def run(command, **kwargs):
try:
- result = subprocess.run([NAMED_RC, "stop"], check=False)
+ return subprocess.run(command, check=False, timeout=FORCE_TIMEOUT, **kwargs)
+ except subprocess.TimeoutExpired:
+ log(syslog.LOG_ERR, f"command timed out after {FORCE_TIMEOUT:g} seconds: {command[0]}")
except OSError as error:
- log(syslog.LOG_ERR, f"unable to stop named: {error}")
+ log(syslog.LOG_ERR, f"unable to run {command[0]}: {error}")
+ return None
+
+
+def named_status():
+ result = run(
+ [NAMED_RC, "status"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return None if result is None else result.returncode
+
+
+def named_pid():
+ try:
+ pid = int(NAMED_PIDFILE.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ return None
+ return pid if pid > 1 else None
+
+
+def process_exists(pid):
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
return False
- if result.returncode == 0:
+ except PermissionError:
+ return True
+ return True
+
+
+def wait_for_exit(pid, timeout):
+ deadline = time.monotonic() + timeout
+ while process_exists(pid):
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return False
+ time.sleep(min(0.1, remaining))
+ return True
+
+
+def signal_named(pid, sig):
+ if not process_exists(pid):
return True
+ if named_pid() != pid or named_status() != 0:
+ log(syslog.LOG_ERR, f"refusing to signal unverified named pid {pid}")
+ return False
try:
- status = subprocess.run([NAMED_RC, "status"], check=False)
+ os.kill(pid, sig)
+ except ProcessLookupError:
+ return True
except OSError as error:
- log(syslog.LOG_ERR, f"unable to verify named status: {error}")
+ log(syslog.LOG_ERR, f"unable to signal named pid {pid}: {error}")
+ return False
+ return True
+
+
+def named_target():
+ status = named_status()
+ if status == 1:
+ return status, None
+ if status != 0:
+ log(syslog.LOG_ERR, f"unable to verify named status (exit {status})")
+ return None
+
+ pid = named_pid()
+ if pid is None:
+ log(syslog.LOG_ERR, f"unable to read a valid named pid from {NAMED_PIDFILE}")
+ return None
+ return status, pid
+
+
+def stop_named():
+ target = named_target()
+ if target is None:
return False
- if status.returncode == 1:
+ status, pid = target
+ if status == 1:
log(syslog.LOG_NOTICE, "named was already stopped")
return True
+
+ log(syslog.LOG_NOTICE, f"requesting graceful shutdown of named pid {pid}")
+ result = run([RNDC, "stop"])
+ if result is not None and result.returncode == 0:
+ if wait_for_exit(pid, GRACE_TIMEOUT):
+ log(syslog.LOG_NOTICE, f"named pid {pid} stopped gracefully")
+ return True
+ else:
+ exit_status = "timeout" if result is None else f"exit {result.returncode}"
+ log(syslog.LOG_WARNING, f"rndc stop failed ({exit_status}); escalating shutdown")
+
+ log(syslog.LOG_ERR, f"named pid {pid} did not stop gracefully; sending SIGTERM")
+ if not signal_named(pid, signal.SIGTERM):
+ return False
+ if wait_for_exit(pid, FORCE_TIMEOUT):
+ log(syslog.LOG_WARNING, f"named pid {pid} stopped after SIGTERM")
+ return True
+
+ log(syslog.LOG_CRIT, f"named pid {pid} ignored SIGTERM; sending SIGKILL")
+ if not signal_named(pid, signal.SIGKILL):
+ return False
+ if wait_for_exit(pid, FORCE_TIMEOUT):
+ log(syslog.LOG_CRIT, f"named pid {pid} required SIGKILL to stop")
+ return True
+
log(
syslog.LOG_ERR,
- f"named stop failed with exit status {result.returncode}; "
- f"status exited {status.returncode}",
+ f"named pid {pid} is still present after SIGKILL",
)
return False