fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099

Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 13:53:55 +09:00
parent dc08d29509
commit c2257d3a86
2709 changed files with 619549 additions and 10 deletions

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
try:
from ._qh3 import AdGuardResolver, NextDNSResolver, QUICResolver
except ImportError:
QUICResolver = None # type: ignore
AdGuardResolver = None # type: ignore
NextDNSResolver = None # type: ignore
__all__ = (
"QUICResolver",
"AdGuardResolver",
"NextDNSResolver",
)

View File

@@ -0,0 +1,541 @@
from __future__ import annotations
import socket
import ssl
import typing
from collections import deque
from ssl import SSLError
from time import time as monotonic
from qh3.quic.configuration import QuicConfiguration
from qh3.quic.connection import QuicConnection
from qh3.quic.events import (
ConnectionTerminated,
HandshakeCompleted,
QuicEvent,
StopSendingReceived,
StreamDataReceived,
StreamReset,
)
from ....util.ssl_ import IS_FIPS, resolve_cert_reqs
from ...ssa._gro import _sock_has_gro, _sock_has_gso, sync_recv_gro, sync_sendmsg_gso
from ..dou import PlainResolver
from ..protocols import (
COMMON_RCODE_LABEL,
DomainNameServerQuery,
DomainNameServerReturn,
ProtocolResolver,
SupportedQueryType,
)
from ..utils import (
is_ipv4,
is_ipv6,
rfc1035_pack,
rfc1035_should_read,
rfc1035_unpack,
validate_length_of,
)
if IS_FIPS:
raise ImportError(
"DNS-over-QUIC disabled when Python is built with FIPS-compliant ssl module"
)
class QUICResolver(PlainResolver):
protocol = ProtocolResolver.DOQ
implementation = "qh3"
def __init__(
self,
server: str,
port: int | None = None,
*patterns: str,
**kwargs: typing.Any,
):
super().__init__(server, port or 853, *patterns, **kwargs)
# qh3 load_default_certs seems off. need to investigate.
if "ca_cert_data" not in kwargs and "ca_certs" not in kwargs:
kwargs["ca_cert_data"] = []
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
try:
ctx.load_default_certs()
for der in ctx.get_ca_certs(binary_form=True):
kwargs["ca_cert_data"].append(ssl.DER_cert_to_PEM_cert(der))
if kwargs["ca_cert_data"]:
kwargs["ca_cert_data"] = "".join(kwargs["ca_cert_data"])
else:
del kwargs["ca_cert_data"]
except (AttributeError, ValueError, OSError):
del kwargs["ca_cert_data"]
if "ca_cert_data" not in kwargs and "ca_certs" not in kwargs:
if (
"cert_reqs" not in kwargs
or resolve_cert_reqs(kwargs["cert_reqs"]) is ssl.CERT_REQUIRED
):
raise ssl.SSLError(
"DoQ requires at least one CA loaded in order to verify the remote peer certificate. "
"Add ?cert_reqs=0 to disable certificate checks."
)
configuration = QuicConfiguration(
is_client=True,
alpn_protocols=["doq"],
server_name=self._server
if "server_hostname" not in kwargs
else kwargs["server_hostname"],
verify_mode=resolve_cert_reqs(kwargs["cert_reqs"])
if "cert_reqs" in kwargs
else ssl.CERT_REQUIRED,
cadata=kwargs["ca_cert_data"].encode()
if "ca_cert_data" in kwargs
else None,
cafile=kwargs["ca_certs"] if "ca_certs" in kwargs else None,
idle_timeout=300.0,
)
if "cert_file" in kwargs:
configuration.load_cert_chain(
kwargs["cert_file"],
kwargs["key_file"] if "key_file" in kwargs else None,
kwargs["key_password"] if "key_password" in kwargs else None,
)
elif "cert_data" in kwargs:
configuration.load_cert_chain(
kwargs["cert_data"],
kwargs["key_data"] if "key_data" in kwargs else None,
kwargs["key_password"] if "key_password" in kwargs else None,
)
self._quic = QuicConnection(configuration=configuration)
self._dgram_gro_enabled: bool = _sock_has_gro(self._socket)
self._dgram_gso_enabled: bool = _sock_has_gso(self._socket)
self._quic.connect((self._server, self._port), monotonic())
self.__exchange_until(HandshakeCompleted, receive_first=False)
self._terminated: bool = False
self._should_disconnect: bool = False
# DNS over QUIC mandate the size-prefix (unsigned int, 2b)
self._rfc1035_prefix_mandated = True
self._unconsumed: deque[DomainNameServerReturn] = deque()
self._pending: deque[DomainNameServerQuery] = deque()
def close(self) -> None:
if not self._terminated:
with self._lock:
self._quic.close()
while True:
datagrams = self._quic.datagrams_to_send(monotonic())
if not datagrams:
break
if self._dgram_gso_enabled and len(datagrams) > 1:
sync_sendmsg_gso(self._socket, [d[0] for d in datagrams])
else:
for datagram in datagrams:
self._socket.sendall(datagram[0])
self._socket.close()
self._terminated = True
def is_available(self) -> bool:
self._quic.handle_timer(monotonic())
if hasattr(self._quic, "_close_event") and self._quic._close_event is not None:
self._terminated = True
return not self._terminated
def getaddrinfo(
self,
host: bytes | str | None,
port: str | int | None,
family: socket.AddressFamily,
type: socket.SocketKind,
proto: int = 0,
flags: int = 0,
*,
quic_upgrade_via_dns_rr: bool = False,
) -> list[
tuple[
socket.AddressFamily,
socket.SocketKind,
int,
str,
tuple[str, int] | tuple[str, int, int, int],
]
]:
if host is None:
raise socket.gaierror( # Defensive: stdlib cpy behavior
"Tried to resolve 'localhost' using the QUICResolver"
)
if port is None:
port = 0 # Defensive: stdlib cpy behavior
if isinstance(port, str):
port = int(port) # Defensive: stdlib cpy behavior
if port < 0:
raise socket.gaierror( # Defensive: stdlib cpy behavior
"Servname not supported for ai_socktype"
)
if isinstance(host, bytes):
host = host.decode("ascii") # Defensive: stdlib cpy behavior
if is_ipv4(host):
if family == socket.AF_INET6:
raise socket.gaierror( # Defensive: stdlib cpy behavior
"Address family for hostname not supported"
)
return [
(
socket.AF_INET,
type,
6,
"",
(
host,
port,
),
)
]
elif is_ipv6(host):
if family == socket.AF_INET:
raise socket.gaierror( # Defensive: stdlib cpy behavior
"Address family for hostname not supported"
)
return [
(
socket.AF_INET6,
type,
17,
"",
(
host,
port,
0,
0,
),
)
]
validate_length_of(host)
remote_preemptive_quic_rr = False
if quic_upgrade_via_dns_rr and type == socket.SOCK_DGRAM:
quic_upgrade_via_dns_rr = False
tbq = []
if family in [socket.AF_UNSPEC, socket.AF_INET]:
tbq.append(SupportedQueryType.A)
if family in [socket.AF_UNSPEC, socket.AF_INET6]:
tbq.append(SupportedQueryType.AAAA)
if quic_upgrade_via_dns_rr:
tbq.append(SupportedQueryType.HTTPS)
queries = DomainNameServerQuery.bulk(host, *tbq)
open_streams = []
with self._lock:
for q in queries:
payload = bytes(q)
self._pending.append(q)
if self._rfc1035_prefix_mandated is True:
payload = rfc1035_pack(payload)
stream_id = self._quic.get_next_available_stream_id()
self._quic.send_stream_data(stream_id, payload, True)
open_streams.append(stream_id)
datagrams = self._quic.datagrams_to_send(monotonic())
if self._dgram_gso_enabled and len(datagrams) > 1:
sync_sendmsg_gso(self._socket, [d[0] for d in datagrams])
else:
for dg in datagrams:
self._socket.sendall(dg[0])
responses: list[DomainNameServerReturn] = []
while len(responses) < len(tbq):
with self._lock:
if self._unconsumed:
dns_resp = None
for query in queries:
for unconsumed in self._unconsumed:
if unconsumed.id == query.id:
dns_resp = unconsumed
responses.append(dns_resp)
break
if dns_resp:
break
if dns_resp:
self._unconsumed.remove(dns_resp)
self._pending.remove(query)
continue
try:
events: list[StreamDataReceived] = self.__exchange_until( # type: ignore[assignment]
StreamDataReceived,
receive_first=True,
event_type_collectable=(StreamDataReceived,),
respect_end_stream_signal=False,
)
payload = b"".join([e.data for e in events])
while rfc1035_should_read(payload):
events.extend(
self.__exchange_until( # type: ignore[arg-type]
StreamDataReceived,
receive_first=True,
event_type_collectable=(StreamDataReceived,),
respect_end_stream_signal=False,
)
)
payload = b"".join([e.data for e in events])
except (TimeoutError, OSError, socket.timeout, ConnectionError) as e:
raise socket.gaierror(
"Got unexpectedly disconnected while waiting for name resolution"
) from e
if not payload:
continue
#: We can receive two responses at once (or more, concatenated). Let's unwrap them.
fragments = rfc1035_unpack(payload)
for fragment in fragments:
dns_resp = DomainNameServerReturn(fragment)
if any(dns_resp.id == _.id for _ in queries):
responses.append(dns_resp)
query_tbr: DomainNameServerQuery | None = None
for query_tbr in self._pending:
if query_tbr.id == dns_resp.id:
break
if query_tbr:
self._pending.remove(query_tbr)
else:
self._unconsumed.append(dns_resp)
if self._should_disconnect:
with self._lock:
self.close()
self._should_disconnect = False
self._terminated = True
results = []
for response in responses:
if not response.is_ok:
if response.rcode == 2:
raise socket.gaierror(
f"DNSSEC validation failure. Check http://dnsviz.net/d/{host}/dnssec/ and http://dnssec-debugger.verisignlabs.com/{host} for errors"
)
raise socket.gaierror(
f"DNS returned an error: {COMMON_RCODE_LABEL[response.rcode] if response.rcode in COMMON_RCODE_LABEL else f'code {response.rcode}'}"
)
for record in response.records:
if record[0] == SupportedQueryType.HTTPS:
assert isinstance(record[-1], dict)
if "h3" in record[-1]["alpn"]:
remote_preemptive_quic_rr = True
continue
assert not isinstance(record[-1], dict)
inet_type = (
socket.AF_INET
if record[0] == SupportedQueryType.A
else socket.AF_INET6
)
dst_addr: tuple[str, int] | tuple[str, int, int, int] = (
(
record[-1],
port,
)
if inet_type == socket.AF_INET
else (
record[-1],
port,
0,
0,
)
)
results.append(
(
inet_type,
type,
6 if type == socket.SOCK_STREAM else 17,
"",
dst_addr,
)
)
quic_results = []
if remote_preemptive_quic_rr:
any_specified = False
for result in results:
if result[1] == socket.SOCK_STREAM:
quic_results.append(
(result[0], socket.SOCK_DGRAM, 17, "", result[4])
)
else:
any_specified = True
break
if any_specified:
quic_results = []
return sorted(quic_results + results, key=lambda _: _[0] + _[1], reverse=True)
def __exchange_until(
self,
event_type: type[QuicEvent] | tuple[type[QuicEvent], ...],
*,
receive_first: bool = False,
event_type_collectable: type[QuicEvent]
| tuple[type[QuicEvent], ...]
| None = None,
respect_end_stream_signal: bool = True,
) -> list[QuicEvent]:
while True:
if receive_first is False:
now = monotonic()
while True:
datagrams = self._quic.datagrams_to_send(now)
if not datagrams:
break
if self._dgram_gso_enabled and len(datagrams) > 1:
sync_sendmsg_gso(self._socket, [d[0] for d in datagrams])
else:
for datagram in datagrams:
self._socket.sendall(datagram[0])
events = []
while True:
if not self._quic._events:
if self._dgram_gro_enabled:
data_in = sync_recv_gro(self._socket, 65535)
else:
data_in = self._socket.recv(1500)
if not data_in:
break
now = monotonic()
if isinstance(data_in, list):
for gro_segment in data_in:
self._quic.receive_datagram(
gro_segment, (self._server, self._port), now
)
else:
self._quic.receive_datagram(
data_in, (self._server, self._port), now
)
while True:
now = monotonic()
datagrams = self._quic.datagrams_to_send(now)
if not datagrams:
break
if self._dgram_gso_enabled and len(datagrams) > 1:
sync_sendmsg_gso(self._socket, [d[0] for d in datagrams])
else:
for datagram in datagrams:
self._socket.sendall(datagram[0])
for ev in iter(self._quic.next_event, None):
if isinstance(ev, ConnectionTerminated):
if ev.error_code == 298:
raise SSLError(
"DNS over QUIC did not succeed (Error 298). Chain certificate verification failed."
)
raise socket.gaierror(
f"DNS over QUIC encountered a unrecoverable failure (error {ev.error_code} {ev.reason_phrase})"
)
elif isinstance(ev, StreamReset):
self._terminated = True
raise socket.gaierror(
"DNS over QUIC server submitted a StreamReset. A request was rejected."
)
elif isinstance(ev, StopSendingReceived):
self._should_disconnect = True
continue
if event_type_collectable:
if isinstance(ev, event_type_collectable):
events.append(ev)
else:
events.append(ev)
if isinstance(ev, event_type):
if not respect_end_stream_signal:
return events
if hasattr(ev, "stream_ended") and ev.stream_ended:
return events
elif hasattr(ev, "stream_ended") is False:
return events
return events
class AdGuardResolver(
QUICResolver
): # Defensive: we do not cover specific vendors/DNS shortcut
specifier = "adguard"
def __init__(self, *patterns: str, **kwargs: typing.Any):
if "server" in kwargs:
kwargs.pop("server")
if "port" in kwargs:
port = kwargs["port"]
kwargs.pop("port")
else:
port = None
super().__init__("unfiltered.adguard-dns.com", port, *patterns, **kwargs)
class NextDNSResolver(
QUICResolver
): # Defensive: we do not cover specific vendors/DNS shortcut
specifier = "nextdns"
def __init__(self, *patterns: str, **kwargs: typing.Any):
if "server" in kwargs:
kwargs.pop("server")
if "port" in kwargs:
port = kwargs["port"]
kwargs.pop("port")
else:
port = None
super().__init__("dns.nextdns.io", port, *patterns, **kwargs)