Files
LorenEteval_Furious/Furious/Service/DnsResolver.py
T
2026-09-09 15:58:31 +08:00

392 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Provide Qt support for DNS resolver."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Models import *
from Furious.Qt.HttpGetManager import *
from Furious.Qt.Signals import connectWeakly
from PySide6 import QtCore
from PySide6.QtNetwork import *
from shiboken6 import isValid
from typing import Tuple
import logging
__all__ = ['DnsResolutionOperation', 'DnsResolver']
logger = logging.getLogger(__name__)
class DnsResolutionOperation(QtCore.QObject):
"""Observe one recursive DNS request without nesting the Qt event loop."""
finished = QtCore.Signal(bool, object)
def __init__(self, resolver, domain, timeout=30000, parent=None):
"""Initialize an idle resolution operation."""
super().__init__(parent)
self._resolver = resolver
self._domain = domain
self._timeout = max(int(timeout), 1)
self._resultMap = resolver._newResultMap(domain)
self._terminal = False
self._elapsed = QtCore.QElapsedTimer()
self._timer = QtCore.QTimer(self)
self._timer.setInterval(20)
connectWeakly(self._timer.timeout, self, '_poll')
def start(self):
"""Start the DNS request and its event-driven completion observer."""
if self._terminal or self._timer.isActive():
return
try:
self._resolver._beginResolve(self._resultMap)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'failed to start DNS resolution for {self._domain!r}: {ex}')
self._resultMap['error'] = True
self._finish()
return
self._elapsed.start()
self._timer.start()
self._poll()
def _poll(self):
"""Finish when recursion drains, or abort this request at its deadline."""
if self._terminal:
return
if self._resultMap['depth'] == 0:
self._finish()
return
if self._elapsed.isValid() and self._elapsed.elapsed() >= self._timeout:
logger.error(
f'DNS resolution for {self._domain!r} reached timeout '
f'{self._timeout // 1000}s'
)
self._resultMap['error'] = True
self._abortReplies()
self._finish()
def _abortReplies(self):
"""Abort only network replies acquired by this resolution."""
for networkReply in self._resultMap['reference']:
if (
isinstance(networkReply, QNetworkReply)
and isValid(networkReply)
and not networkReply.isFinished()
):
networkReply.abort()
def _finish(self):
"""Publish exactly one terminal result."""
if self._terminal:
return
self._terminal = True
self._timer.stop()
self.finished.emit(
bool(self._resultMap['error']),
list(self._resultMap['result'].keys()),
)
def cancel(self):
"""Cancel without publishing a stale result."""
if self._terminal:
return
self._terminal = True
self._timer.stop()
self._abortReplies()
class DnsResolver(HttpGetManager):
"""Represent DNS resolver."""
MAX_REFERENCE_DEPTH = 32
def __init__(self, parent=None, **kwargs):
"""Initialize the DNS resolver."""
actionMessage = kwargs.pop('actionMessage', 'DNS resolution')
super().__init__(parent, actionMessage=actionMessage)
@staticmethod
def request(address) -> QNetworkRequest:
"""Return the request value used by the DNS resolver."""
request = QNetworkRequest(
QtCore.QUrl(f'https://cloudflare-dns.com/dns-query?name={address}')
)
request.setRawHeader('accept'.encode(), 'application/dns-json'.encode())
return request
def successCallback(self, networkReply, **kwargs):
"""Handle a successful network operation."""
domain, resultMap, referenceDepth, ancestry = (
kwargs.pop('domain', ''),
kwargs.pop('resultMap', {}),
kwargs.pop('referenceDepth', 0),
kwargs.pop('ancestry', tuple()),
)
data = networkReply.readAll().data()
try:
replyObject = UJSONEncoder.decode(data)
except Exception as ex:
# Any non-exit exceptions
logger.error(
f'bad network reply while resolving DNS for \'{domain}\'. {ex}'
)
resultMap['error'] = True
else:
answers = (
replyObject.get('Answer') if isinstance(replyObject, dict) else None
)
if not isinstance(answers, list) or not answers:
status = (
replyObject.get('Status') if isinstance(replyObject, dict) else None
)
logger.error(
f'DNS resolution for \'{domain}\' returned no answer. '
f'Status: {status!r}'
)
resultMap['error'] = True
answers = []
else:
logger.info(f'DNS resolution for \'{domain}\' success')
for record in answers:
address = record.get('data') if isinstance(record, dict) else None
if not isinstance(address, str) or not address:
logger.error(
f'DNS resolution for \'{domain}\' returned an invalid '
f'answer record'
)
resultMap['error'] = True
continue
logger.info(f'\'{domain}\' resolved to \'{address}\'')
if isValidIPAddress(address):
resultMap['result'][address] = True
continue
try:
recordType = int(record.get('type', 0))
except (TypeError, ValueError):
recordType = 0
if recordType != 5:
logger.error(
f'DNS resolution for \'{domain}\' returned an unsupported '
f'non-address answer record'
)
resultMap['error'] = True
continue
reference = address.rstrip('.').strip()
normalizedReference = reference.casefold()
if not reference:
resultMap['error'] = True
continue
if normalizedReference in ancestry:
logger.error(
f'DNS resolution for \'{domain}\' returned a cyclic '
f'reference to \'{reference}\''
)
resultMap['error'] = True
continue
if referenceDepth >= self.MAX_REFERENCE_DEPTH:
logger.error(
f'DNS resolution for \'{domain}\' exceeded the maximum '
f'reference depth {self.MAX_REFERENCE_DEPTH}'
)
resultMap['error'] = True
continue
if normalizedReference in resultMap['visited']:
continue
resultMap['visited'].add(normalizedReference)
resultMap['depth'] += 1
try:
newNetworkReply = self.webGET(
self.request(reference),
logActionMessage=False,
domain=reference,
resultMap=resultMap,
referenceDepth=referenceDepth + 1,
ancestry=ancestry + (normalizedReference,),
)
except Exception as ex:
# Any non-exit exceptions
resultMap['depth'] -= 1
logger.error(
f'failed to follow DNS reference \'{reference}\'. {ex}'
)
resultMap['error'] = True
continue
resultMap['reference'].append(newNetworkReply)
resultMap['depth'] -= 1
def failureCallback(self, networkReply: QNetworkReply, **kwargs):
"""Handle a failed network operation."""
domain = kwargs.pop('domain', '')
resultMap = kwargs.pop('resultMap', {})
logger.error(
f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}'
)
resultMap['error'] = True
resultMap['depth'] -= 1
@staticmethod
def _newResultMap(domain):
"""Return mutable state for one recursive DNS resolution."""
normalizedDomain = str(domain).rstrip('.').strip().casefold()
return {
'domain': domain,
'depth': 0,
'error': False,
'reference': [],
'result': {},
'visited': {normalizedDomain},
}
def _beginResolve(self, resultMap):
"""Start the root request for one prepared resolution state."""
domain = resultMap['domain']
normalizedDomain = str(domain).rstrip('.').strip().casefold()
resultMap['depth'] += 1
networkReply = self.webGET(
self.request(domain),
logActionMessage=False,
domain=domain,
resultMap=resultMap,
referenceDepth=0,
ancestry=(normalizedDomain,),
)
resultMap['reference'].append(networkReply)
def resolve(self, domain, timeout=30000) -> Tuple[bool, list[str]]:
"""Resolve the DNS resolver."""
resultMap = self._newResultMap(domain)
self._beginResolve(resultMap)
self.wait(resultMap, timeout=timeout)
return resultMap['error'], list(resultMap['result'].keys())
def resolveAsync(self, domain, timeout=30000, parent=None):
"""Return an event-driven DNS operation; the caller starts and owns it."""
return DnsResolutionOperation(
self,
domain,
timeout=timeout,
parent=parent,
)
@staticmethod
def wait(resultMap, startCounter=0, timeout=30000, step=100):
"""Wait for the DNS resolver operation to complete."""
domain = resultMap.get('domain', '')
if not domain:
return
if resultMap['depth'] != 0:
logger.info(f'DNS resolution for \'{domain}\' in progress. Wait')
else:
return
while resultMap['depth'] != 0 and startCounter < timeout:
PySide6Legacy.eventLoopWait(step)
startCounter += step
if resultMap['depth'] != 0:
logger.error(
f'DNS resolution for \'{domain}\' reached timeout {timeout // 1000}s'
)
resultMap['error'] = True
for networkReply in resultMap['reference']:
if (
isinstance(networkReply, QNetworkReply)
and isValid(networkReply)
and not networkReply.isFinished()
):
networkReply.abort()
def dispose(self):
"""Abort pending replies and schedule this resolver for destruction."""
for networkReply in tuple(self._replyContexts):
if isValid(networkReply) and not networkReply.isFinished():
networkReply.abort()
self._replyContexts.clear()
self.deleteLater()