Add service status detection and improve tray icon feedback
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -93,3 +93,23 @@ chmod +x ~/.local/bin/nextdns-tray
|
|||||||
mkdir -p ~/.config/autostart
|
mkdir -p ~/.config/autostart
|
||||||
cp ~/.local/share/applications/nextdns-tray.desktop ~/.config/autostart/
|
cp ~/.local/share/applications/nextdns-tray.desktop ~/.config/autostart/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Status Detection
|
||||||
|
|
||||||
|
The tray distinguishes between service state and real DNS usage.
|
||||||
|
|
||||||
|
- `Stopped`: NextDNS service is not running.
|
||||||
|
- `Running / Not In Use`: service is running, but resolver settings do not point to NextDNS.
|
||||||
|
- `Running / Usage Unknown`: service is running and resolver looks correct, but live verification is temporarily unavailable.
|
||||||
|
- `Running / In Use`: service is running and usage is verified.
|
||||||
|
|
||||||
|
Verification model:
|
||||||
|
|
||||||
|
- Fast local checks run on each refresh cycle.
|
||||||
|
- End-to-end validation uses `https://test.nextdns.io` and is cached for 30 seconds.
|
||||||
|
- A start/restart action forces an immediate end-to-end re-check.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This validates host DNS routing; application-specific DoH settings may bypass host resolver behavior.
|
||||||
|
- `curl` is used for the end-to-end check. If unavailable, status may appear as `Usage Unknown`.
|
||||||
|
|||||||
+197
-12
@@ -6,6 +6,8 @@ import time
|
|||||||
import shutil
|
import shutil
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
|
import ipaddress
|
||||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
|
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
|
||||||
from PyQt6.QtGui import QIcon, QAction, QPixmap
|
from PyQt6.QtGui import QIcon, QAction, QPixmap
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer
|
||||||
@@ -35,6 +37,13 @@ class NextDNSTray:
|
|||||||
self.tray = QSystemTrayIcon()
|
self.tray = QSystemTrayIcon()
|
||||||
self.menu = QMenu()
|
self.menu = QMenu()
|
||||||
|
|
||||||
|
# End-to-end validation cache (seconds)
|
||||||
|
self.e2e_interval = 30
|
||||||
|
self.last_e2e_check = 0.0
|
||||||
|
self.last_e2e_state = None
|
||||||
|
self.last_e2e_reason = "usage check not run yet"
|
||||||
|
self.force_e2e_check = True
|
||||||
|
|
||||||
# Poll status every 5 seconds
|
# Poll status every 5 seconds
|
||||||
self.timer = QTimer()
|
self.timer = QTimer()
|
||||||
self.timer.timeout.connect(self.refresh_ui)
|
self.timer.timeout.connect(self.refresh_ui)
|
||||||
@@ -53,19 +62,180 @@ class NextDNSTray:
|
|||||||
# Fallback to a system icon if decoding fails
|
# Fallback to a system icon if decoding fails
|
||||||
return QIcon.fromTheme("yast-security")
|
return QIcon.fromTheme("yast-security")
|
||||||
|
|
||||||
def is_running(self):
|
def service_status(self):
|
||||||
"""Check if nextdns service is active."""
|
"""Return daemon status and a human-readable reason."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([NEXTDNS_PATH, "status"], capture_output=True, text=True)
|
result = subprocess.run(
|
||||||
return "running" in result.stdout.lower()
|
[NEXTDNS_PATH, "status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=4,
|
||||||
|
)
|
||||||
|
running = "running" in (result.stdout or "").lower()
|
||||||
|
if running:
|
||||||
|
return True, "service is running"
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, "service is not running"
|
||||||
|
return False, "service is not running"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "nextdns command not found"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "service status check timed out"
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False, "service status check failed"
|
||||||
|
|
||||||
|
def _extract_ips(self, text):
|
||||||
|
"""Extract valid IP addresses from command output."""
|
||||||
|
ips = []
|
||||||
|
for token in text.replace("\n", " ").split():
|
||||||
|
value = token.strip("[](),;")
|
||||||
|
if "%" in value:
|
||||||
|
value = value.split("%", 1)[0]
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
ips.append(value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ips
|
||||||
|
|
||||||
|
def resolver_uses_nextdns(self):
|
||||||
|
"""Check whether system resolver targets local/NextDNS endpoints."""
|
||||||
|
candidate_ips = []
|
||||||
|
resolvectl = shutil.which("resolvectl")
|
||||||
|
|
||||||
|
if resolvectl:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[resolvectl, "dns"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=4,
|
||||||
|
)
|
||||||
|
candidate_ips = self._extract_ips(result.stdout or "")
|
||||||
|
except Exception:
|
||||||
|
candidate_ips = []
|
||||||
|
|
||||||
|
if not candidate_ips:
|
||||||
|
try:
|
||||||
|
with open("/etc/resolv.conf", "r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if line.lower().startswith("nameserver "):
|
||||||
|
token = line.split(None, 1)[1].strip()
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(token)
|
||||||
|
candidate_ips.append(token)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
return False, "unable to read resolver configuration"
|
||||||
|
|
||||||
|
if not candidate_ips:
|
||||||
|
return False, "no DNS nameserver found in resolver configuration"
|
||||||
|
|
||||||
|
for ip_raw in candidate_ips:
|
||||||
|
ip = ipaddress.ip_address(ip_raw)
|
||||||
|
if ip.is_loopback:
|
||||||
|
return True, "resolver points to local DNS listener"
|
||||||
|
if ip.version == 4:
|
||||||
|
text = str(ip)
|
||||||
|
if text.startswith("45.90.28.") or text.startswith("45.90.30."):
|
||||||
|
return True, f"resolver points to NextDNS endpoint ({text})"
|
||||||
|
|
||||||
|
first = candidate_ips[0]
|
||||||
|
return False, f"resolver points elsewhere ({first})"
|
||||||
|
|
||||||
|
def check_nextdns_usage(self):
|
||||||
|
"""Validate if DNS resolution is actively going through NextDNS."""
|
||||||
|
curl = shutil.which("curl")
|
||||||
|
if not curl:
|
||||||
|
return None, "curl is not installed"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
curl,
|
||||||
|
"-L",
|
||||||
|
"-fsS",
|
||||||
|
"--max-time",
|
||||||
|
"5",
|
||||||
|
"https://test.nextdns.io",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=6,
|
||||||
|
)
|
||||||
|
payload = json.loads(result.stdout or "{}")
|
||||||
|
status = str(payload.get("status", "")).lower()
|
||||||
|
if status == "ok":
|
||||||
|
return True, "verified by test.nextdns.io"
|
||||||
|
if status:
|
||||||
|
return False, f"test.nextdns.io returned status '{status}'"
|
||||||
|
return None, "test.nextdns.io returned an unexpected response"
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None, "unable to reach test.nextdns.io"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "test.nextdns.io check timed out"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "test.nextdns.io returned invalid JSON"
|
||||||
|
except Exception:
|
||||||
|
return None, "usage check failed"
|
||||||
|
|
||||||
|
def evaluate_health(self):
|
||||||
|
"""Compute tray health state and explanatory reason."""
|
||||||
|
service_running, service_reason = self.service_status()
|
||||||
|
if not service_running:
|
||||||
|
return {
|
||||||
|
"state": "stopped",
|
||||||
|
"service_running": False,
|
||||||
|
"reason": service_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver_ok, resolver_reason = self.resolver_uses_nextdns()
|
||||||
|
if not resolver_ok:
|
||||||
|
return {
|
||||||
|
"state": "running_not_in_use",
|
||||||
|
"service_running": True,
|
||||||
|
"reason": resolver_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
should_check = (
|
||||||
|
self.force_e2e_check
|
||||||
|
or self.last_e2e_state is None
|
||||||
|
or (now - self.last_e2e_check) >= self.e2e_interval
|
||||||
|
)
|
||||||
|
if should_check:
|
||||||
|
self.last_e2e_state, self.last_e2e_reason = self.check_nextdns_usage()
|
||||||
|
self.last_e2e_check = now
|
||||||
|
self.force_e2e_check = False
|
||||||
|
|
||||||
|
if self.last_e2e_state is True:
|
||||||
|
return {
|
||||||
|
"state": "healthy_in_use",
|
||||||
|
"service_running": True,
|
||||||
|
"reason": self.last_e2e_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.last_e2e_state is False:
|
||||||
|
return {
|
||||||
|
"state": "running_not_in_use",
|
||||||
|
"service_running": True,
|
||||||
|
"reason": self.last_e2e_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": "running_unknown",
|
||||||
|
"service_running": True,
|
||||||
|
"reason": self.last_e2e_reason,
|
||||||
|
}
|
||||||
|
|
||||||
def run_cmd(self, cmd):
|
def run_cmd(self, cmd):
|
||||||
"""Execute start/stop/restart via pkexec."""
|
"""Execute start/stop/restart via pkexec."""
|
||||||
try:
|
try:
|
||||||
# Only prompts for password when an action is taken
|
# Only prompts for password when an action is taken
|
||||||
subprocess.run(f"pkexec {NEXTDNS_PATH} {cmd}", shell=True, check=True)
|
subprocess.run(f"pkexec {NEXTDNS_PATH} {cmd}", shell=True, check=True)
|
||||||
|
self.force_e2e_check = True
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
self.refresh_ui()
|
self.refresh_ui()
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
@@ -74,15 +244,24 @@ class NextDNSTray:
|
|||||||
|
|
||||||
def refresh_ui(self):
|
def refresh_ui(self):
|
||||||
"""Updates icons and menu with a status header."""
|
"""Updates icons and menu with a status header."""
|
||||||
running = self.is_running()
|
health = self.evaluate_health()
|
||||||
|
running = health["service_running"]
|
||||||
|
state = health["state"]
|
||||||
|
reason = health["reason"]
|
||||||
|
|
||||||
# Main Tray Icon (The Shield)
|
# Main Tray Icon (The Shield)
|
||||||
if running:
|
if state == "healthy_in_use":
|
||||||
self.tray.setIcon(self.custom_icon)
|
self.tray.setIcon(self.custom_icon)
|
||||||
self.tray.setToolTip("NextDNS: Running")
|
self.tray.setToolTip("NextDNS: Running and in use")
|
||||||
|
elif state == "stopped":
|
||||||
|
self.tray.setIcon(self.custom_icon_disabled)
|
||||||
|
self.tray.setToolTip(f"NextDNS: Stopped ({reason})")
|
||||||
|
elif state == "running_not_in_use":
|
||||||
|
self.tray.setIcon(self.custom_icon_disabled)
|
||||||
|
self.tray.setToolTip(f"NextDNS: Running, but DNS not using NextDNS ({reason})")
|
||||||
else:
|
else:
|
||||||
self.tray.setIcon(self.custom_icon_disabled)
|
self.tray.setIcon(self.custom_icon_disabled)
|
||||||
self.tray.setToolTip("NextDNS: Stopped")
|
self.tray.setToolTip(f"NextDNS: Running (usage check unavailable: {reason})")
|
||||||
|
|
||||||
self.menu.clear()
|
self.menu.clear()
|
||||||
|
|
||||||
@@ -92,9 +271,15 @@ class NextDNSTray:
|
|||||||
# title_act.setEnabled(False)
|
# title_act.setEnabled(False)
|
||||||
self.menu.addAction(title_act)
|
self.menu.addAction(title_act)
|
||||||
|
|
||||||
status = " Enabled/Running" if running else " Disabled/Stopped"
|
status_map = {
|
||||||
status_icon = "emblem-checked" if running else "emblem-unmounted"
|
"healthy_in_use": ("Running / In Use", "emblem-checked"),
|
||||||
toggle_status = QAction(QIcon.fromTheme(status_icon), status, self.menu)
|
"stopped": ("Stopped", "emblem-unmounted"),
|
||||||
|
"running_not_in_use": ("Running / Not In Use", "emblem-important"),
|
||||||
|
"running_unknown": ("Running / Usage Unknown", "dialog-question"),
|
||||||
|
}
|
||||||
|
status, status_icon = status_map.get(state, ("Unknown", "dialog-question"))
|
||||||
|
status_text = f" {status}\n {reason}"
|
||||||
|
toggle_status = QAction(QIcon.fromTheme(status_icon), status_text, self.menu)
|
||||||
toggle_status.setEnabled(False)
|
toggle_status.setEnabled(False)
|
||||||
self.menu.addAction(toggle_status)
|
self.menu.addAction(toggle_status)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user