import asyncio import json import logging import shutil import time from urllib.parse import urlparse import aiohttp from mcstatus import JavaServer import config log = logging.getLogger('healthbot') GITEA_VERSION_URL = 'https://gitea.rainbow-herring.ts.net/api/v1/version' DOH_RESOLVERS = { 'Cloudflare': 'https://cloudflare-dns.com/dns-query', 'Google': 'https://dns.google/resolve', } class TargetsConfigError(Exception): """Raised when the targets configuration file is missing or invalid.""" def validate_targets(data): """Validate parsed targets data, returning a normalized list of targets.""" if not isinstance(data, list): raise TargetsConfigError('targets must be a JSON list') targets = [] for i, entry in enumerate(data): if not isinstance(entry, dict): raise TargetsConfigError(f'entry {i} is not an object') name = entry.get('name') if not isinstance(name, str) or not name.strip(): raise TargetsConfigError(f'entry {i} missing a valid "name"') kind = entry.get('kind') if not isinstance(kind, str) or not kind.strip(): raise TargetsConfigError(f'entry {i} missing a valid "kind"') if kind == 'minecraft': host = entry.get('host') port = entry.get('port') if not isinstance(host, str) or not host.strip(): raise TargetsConfigError(f'entry {i} missing a valid "host"') if not isinstance(port, int) or not (0 < port < 65536): raise TargetsConfigError(f'entry {i} missing a valid "port"') else: url = entry.get('url') if not isinstance(url, str) or not url.strip(): raise TargetsConfigError(f'entry {i} missing a valid "url"') critical = entry.get('critical', True) if not isinstance(critical, bool): raise TargetsConfigError(f'entry {i} "critical" must be true/false') target = {'name': name.strip(), 'kind': kind.strip(), 'critical': critical} target.update({k: entry[k] for k in ('url', 'host', 'port') if k in entry}) targets.append(target) return targets def load_targets(): """Load the live targets file; on success refresh the backup copy. Raises TargetsConfigError if the live file is missing or invalid. """ try: with open(config.TARGETS_FILE, 'r') as f: raw = f.read() targets = validate_targets(json.loads(raw)) except (OSError, ValueError, TargetsConfigError) as exc: raise TargetsConfigError( f'invalid targets file {config.TARGETS_FILE}: {exc}' ) from exc try: shutil.copyfile(config.TARGETS_FILE, config.TARGETS_BACKUP_FILE) except OSError as exc: log.warning('Could not back up targets file: %s', exc) return targets def load_backup_targets(): """Load the last known-good copy of the targets file.""" try: with open(config.TARGETS_BACKUP_FILE, 'r') as f: return validate_targets(json.load(f)) except (OSError, ValueError, TargetsConfigError) as exc: raise TargetsConfigError( f'backup targets file {config.TARGETS_BACKUP_FILE} also invalid: {exc}' ) from exc async def _get(session, url, timeout): started = time.monotonic() async with session.get( url, timeout=aiohttp.ClientTimeout(total=timeout), allow_redirects=False, ) as resp: latency_ms = round((time.monotonic() - started) * 1000) body = await resp.text() return resp.status, latency_ms, body async def _resolve_public(session, resolver_url, host, timeout): try: async with session.get( resolver_url, params={'name': host, 'type': 'A'}, headers={'Accept': 'application/dns-json'}, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status != 200: return {'ok': False, 'ips': [], 'error': f'HTTP {resp.status}'} body = await resp.text() data = json.loads(body) except asyncio.TimeoutError: return {'ok': False, 'ips': [], 'error': 'timeout'} except aiohttp.ClientError: return {'ok': False, 'ips': [], 'error': 'error'} except Exception: return {'ok': False, 'ips': [], 'error': 'error'} status = data.get('Status') if status == 3: return {'ok': False, 'ips': [], 'error': 'NXDOMAIN'} if status == 2: return {'ok': False, 'ips': [], 'error': 'SERVFAIL'} if status != 0: return {'ok': False, 'ips': [], 'error': f'status {status}'} answers = [a for a in data.get('Answer', []) if a.get('type') == 1] if not answers: return {'ok': False, 'ips': [], 'error': 'no A record'} return {'ok': True, 'ips': [a['data'] for a in answers], 'error': ''} async def check_minecraft(session, target, timeout): host, port = target['host'], target['port'] result = { 'name': target['name'], 'kind': 'minecraft', 'url': f'{host}:{port}', 'critical': target.get('critical', True), 'up': False, 'status_code': None, 'latency_ms': None, 'db': None, 'version': None, 'detail': '', 'dns': {}, 'players': None, 'motd': None, } try: server = JavaServer(host, port, timeout=timeout) status = await server.async_status() except asyncio.TimeoutError: result['detail'] = 'timeout' return result except OSError: result['detail'] = 'connection error' return result except Exception as exc: result['detail'] = type(exc).__name__ return result result['up'] = True result['latency_ms'] = round(status.latency) result['players'] = f"{status.players.online}/{status.players.max}" if status.version and status.version.name: result['version'] = status.version.name try: result['motd'] = status.motd.to_plain() except Exception: pass return result async def check_target(session, target, timeout): if target.get('kind') == 'minecraft': result = await check_minecraft(session, target, timeout) else: result = { 'name': target['name'], 'kind': target.get('kind', ''), 'url': target['url'], 'critical': target.get('critical', True), 'up': False, 'status_code': None, 'latency_ms': None, 'db': None, 'version': None, 'detail': '', 'dns': {}, } try: status, latency_ms, body = await _get(session, target['url'], timeout) result['status_code'] = status result['latency_ms'] = latency_ms if status == 200: data = {} try: data = json.loads(body) except (json.JSONDecodeError, ValueError): pass result['up'] = data.get('status') == 'pass' if data.get('db') is not None: result['db'] = data['db'] else: result['detail'] = f'HTTP {status}' except asyncio.TimeoutError: result['detail'] = 'timeout' except aiohttp.ClientError: result['detail'] = 'connection error' except Exception as exc: result['detail'] = type(exc).__name__ if result['up'] and result['kind'] == 'gitea': try: status, _, body = await _get(session, GITEA_VERSION_URL, timeout) if status == 200: result['version'] = json.loads(body).get('version') except Exception: pass url = target.get('url') or f'//{target["host"]}' host = urlparse(url).hostname for label, resolver_url in DOH_RESOLVERS.items(): result['dns'][label] = await _resolve_public(session, resolver_url, host, timeout) return result async def run_checks(session, timeout): using_backup = False try: targets = load_targets() except TargetsConfigError as exc: log.exception('Targets config problem: %s', exc) targets = load_backup_targets() using_backup = True results = await asyncio.gather( *(check_target(session, target, timeout) for target in targets) ) return list(results), using_backup