From 86e644410b2f9e9ff3c615bd7fde52e5dd61a42a Mon Sep 17 00:00:00 2001 From: C0d3v Date: Thu, 20 Aug 2026 09:32:04 +0200 Subject: [PATCH] Allow raw ip addresses --- README.md | 7 +++++++ app/checker.py | 30 +++++++++++++++++++----------- app/config.py | 21 +++++++++++++++++---- app/dns_resolver.py | 30 ++++++++++++++++++++++++++++++ config.example.yaml | 9 +++++++++ 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 6e85923..3a775b7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ For each check: At no point is the container's own (router-provided) DNS resolver consulted for endpoint hostnames. +**Literal IPs in `host`:** if `host` is already an IPv4/IPv6 address (e.g. +`192.168.0.2`), DNS resolution is skipped entirely — the monitor connects +to it directly, and `dns_server` is optional/ignored for that endpoint. +(Without this, a literal IP would be sent as a DNS query to whatever +`dns_server` you configured, which returns NXDOMAIN — it is not a valid +hostname — and the check would always fail.) + ## What gets written to InfluxDB Measurement `uptime_check`, one point per endpoint per check: diff --git a/app/checker.py b/app/checker.py index 773c190..155f1f5 100644 --- a/app/checker.py +++ b/app/checker.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import dataclasses import logging import time @@ -32,19 +33,26 @@ def check_endpoint(ep, default_timeout: float, timestamp_ns: int) -> CheckResult timeout = ep.timeout_seconds or default_timeout start = time.monotonic() - try: - resolved_ip = dns_resolver.resolve(ep.host, ep.dns_server, timeout) - except dns.exception.DNSException as e: - return CheckResult( - endpoint_name=ep.name, host=ep.host, scheme=ep.scheme, port=ep.port, - success=False, status_code=None, expected_status=ep.expected_status, - response_time_ms=None, resolved_ip=None, - error=f"dns_error: {e}", timestamp_ns=timestamp_ns, - ) + if dns_resolver.is_ip_literal(ep.host): + # Nothing to resolve — connect straight to the given IP. dns_server + # is not required/used in this case (see config.py). + resolved_ip = ep.host + pin = contextlib.nullcontext() + else: + try: + resolved_ip = dns_resolver.resolve(ep.host, ep.dns_server, timeout) + except dns.exception.DNSException as e: + return CheckResult( + endpoint_name=ep.name, host=ep.host, scheme=ep.scheme, port=ep.port, + success=False, status_code=None, expected_status=ep.expected_status, + response_time_ms=None, resolved_ip=None, + error=f"dns_error: {e}", timestamp_ns=timestamp_ns, + ) + pin = dns_resolver.pin_resolution(ep.host, resolved_ip) - url = f"{ep.scheme}://{ep.host}:{ep.port}{ep.path}" + url = f"{ep.scheme}://{dns_resolver.format_host_for_url(ep.host)}:{ep.port}{ep.path}" try: - with dns_resolver.pin_resolution(ep.host, resolved_ip): + with pin: resp = requests.get( url, timeout=timeout, verify=ep.verify_tls, allow_redirects=True ) diff --git a/app/config.py b/app/config.py index 03653f6..899a932 100644 --- a/app/config.py +++ b/app/config.py @@ -5,6 +5,8 @@ from typing import List, Optional import yaml +from . import dns_resolver + class ConfigError(Exception): """Raised for any problem with the config file's contents.""" @@ -16,9 +18,12 @@ class Endpoint: host: str scheme: str # "http" or "https" port: int - dns_server: str # DNS server used to resolve `host` — the system/router - # resolver is never consulted, see app/dns_resolver.py expected_status: int + # DNS server used to resolve `host` — the system/router resolver is + # never consulted, see app/dns_resolver.py. Optional (and unused) when + # `host` is already a literal IP address, since there's nothing to + # resolve in that case. + dns_server: Optional[str] = None path: str = "/" timeout_seconds: Optional[float] = None verify_tls: bool = True # set False for endpoints with self-signed certs @@ -103,14 +108,22 @@ def load_config(path: str) -> AppConfig: f"{ctx} ('{name}'): scheme must be 'http' or 'https', got '{scheme}'" ) + host = _require(ep, "host", ctx) + dns_server = ep.get("dns_server") or None + if not dns_resolver.is_ip_literal(host) and not dns_server: + raise ConfigError( + f"{ctx} ('{name}'): 'dns_server' is required when 'host' is not " + "a literal IP address" + ) + timeout = ep.get("timeout_seconds") endpoints.append( Endpoint( name=name, - host=_require(ep, "host", ctx), + host=host, scheme=scheme, port=int(_require(ep, "port", ctx)), - dns_server=_require(ep, "dns_server", ctx), + dns_server=dns_server, expected_status=int(_require(ep, "expected_status", ctx)), path=ep.get("path", "/"), timeout_seconds=float(timeout) if timeout else None, diff --git a/app/dns_resolver.py b/app/dns_resolver.py index de6b338..f912788 100644 --- a/app/dns_resolver.py +++ b/app/dns_resolver.py @@ -9,6 +9,7 @@ server given for that specific endpoint. from __future__ import annotations import contextlib +import ipaddress import socket import threading @@ -20,6 +21,35 @@ import dns.resolver _patch_lock = threading.Lock() +def is_ip_literal(host: str) -> bool: + """Return True if `host` is already a literal IPv4 or IPv6 address. + + Such hosts have nothing to resolve — dnspython does not special-case + them, it will happily send a DNS query for e.g. "192.168.0.2" as if it + were a hostname and get NXDOMAIN back. Callers should skip resolution + entirely for these. + """ + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + + +def format_host_for_url(host: str) -> str: + """Format `host` for embedding directly in a URL. + + IPv6 literals need brackets (e.g. "::1" -> "[::1]"); everything else + (hostnames, IPv4 literals) is returned unchanged. + """ + try: + if ipaddress.ip_address(host).version == 6: + return f"[{host}]" + except ValueError: + pass + return host + + def resolve(host: str, dns_server: str, timeout: float) -> str: """Resolve `host` to an IPv4 address using *only* `dns_server`. diff --git a/config.example.yaml b/config.example.yaml index 72f8b9d..19a5f5d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,6 +45,8 @@ endpoints: # DNS server used to resolve `host` for THIS endpoint. The # system/router-provided DNS resolver is never used — every lookup # goes explicitly to this server only. + # Required unless `host` is already a literal IP address (see the + # entry below), in which case it's optional and ignored. dns_server: "1.1.1.1" expected_status: 200 # path: "/" # optional, defaults to "/" @@ -58,4 +60,11 @@ endpoints: dns_server: "9.9.9.9" expected_status: 200 + - name: "example-raw-ip" + host: "192.168.0.2" # literal IP -> DNS resolution is skipped entirely + scheme: "http" + port: 8080 + # dns_server not needed here + expected_status: 200 + # Add as many endpoints as you like below.