arcsecond 3.18.2__tar.gz → 3.19.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arcsecond-3.18.2 → arcsecond-3.19.0}/PKG-INFO +1 -1
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/commands.py +8 -6
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/detection.py +34 -4
- arcsecond-3.19.0/arcsecond/imagesources/sources/mdns.py +221 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/sources/network.py +72 -1
- {arcsecond-3.18.2 → arcsecond-3.19.0}/pyproject.toml +1 -1
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_detection.py +58 -4
- arcsecond-3.19.0/tests/test_imagesources_mdns.py +273 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/uv.lock +1 -1
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.docker/Dockerfile_postgres +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.docker/Dockerfile_redis +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.flake8 +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.github/dependabot.yml +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.github/workflows/pythonpublish.yml +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.github/workflows/tests.yml +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/.gitignore +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/LICENSE +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/Makefile +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/README.md +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/__version__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/alpaca/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/alpaca/commands.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/alpaca/dome_probe.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/config.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/constants.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/endpoint.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/main.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/api/resources.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cli.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/auth.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/resources.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/constants.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/context.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/datafiles/context.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/datafiles/errors.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/datafiles/uploader.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/datafiles/utils.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/errors.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/logger.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/uploader.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/utils.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploader/walker.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/cloud/uploads.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/errors.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/backups.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/checks.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/constants.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/database.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/constants.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/containers.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/docker-compose.yml +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/images.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/docker/utils.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/keygen/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/keygen/client.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/keygen/utils.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/local.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/main.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/postgres/init-db.sh +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/setup.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/utils.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/hosting/validation.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/proxy.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/registry.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/runtime.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/sources/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/sources/base.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/sources/filewatch.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/sources/opencv.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/imagesources/store.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/options.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/arcsecond/targets.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/examples/example_upload_files.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/requirements.txt +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/setup.cfg +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/setup.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/sonar-project.properties +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/api/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/api/test_api.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/api/test_api_endpoint.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/api/test_config.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/api/test_targets.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/__init__.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_init.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/conftest.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/fixtures/file1.fits +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_alpaca_probe.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_cli.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_hosting_backups_restore.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_hosting_database.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_hosting_local.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_hosting_utils_secret_key.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_commands.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_network.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_proxy.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_store.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_imagesources_webcam.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/test_targets_planning.py +0 -0
- {arcsecond-3.18.2 → arcsecond-3.19.0}/tests/utils.py +0 -0
|
@@ -613,13 +613,15 @@ def _unreachable_note(url: str) -> Optional[str]:
|
|
|
613
613
|
)
|
|
614
614
|
host = (urlsplit(url).hostname or "").lower()
|
|
615
615
|
if host.endswith(".local"):
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
#
|
|
616
|
+
# Worth explaining rather than merely reporting. A .local name is
|
|
617
|
+
# answered by its own machine over multicast, and the proxy asks that
|
|
618
|
+
# way itself when this computer's resolver cannot — so reaching here
|
|
619
|
+
# means neither worked, and something is eating the multicast.
|
|
619
620
|
note += (
|
|
620
|
-
" A .local name is answered by that machine itself over mDNS
|
|
621
|
-
"
|
|
622
|
-
"
|
|
621
|
+
" A .local name is answered by that machine itself over mDNS. The "
|
|
622
|
+
"proxy asks that way too when this computer cannot, so something "
|
|
623
|
+
"on the network is stopping both — a firewall, or a subnet the "
|
|
624
|
+
"multicast does not cross. Its IP address is the reliable form."
|
|
623
625
|
)
|
|
624
626
|
return note
|
|
625
627
|
|
|
@@ -35,6 +35,8 @@ from urllib.parse import urlsplit
|
|
|
35
35
|
|
|
36
36
|
from .sources.base import DetectedDevice
|
|
37
37
|
from .sources.filewatch import detect_allsky
|
|
38
|
+
from .sources.mdns import resolve as resolve_over_mdns
|
|
39
|
+
from .sources.network import HTTP_SCHEMES
|
|
38
40
|
from .store import ALLSKY, USB, Camera
|
|
39
41
|
|
|
40
42
|
logger = logging.getLogger(__name__)
|
|
@@ -109,17 +111,45 @@ def is_reachable(url: str, timeout: float = NETWORK_TIMEOUT) -> tuple:
|
|
|
109
111
|
if endpoint is None:
|
|
110
112
|
return False, "the address cannot be read"
|
|
111
113
|
host, port = endpoint
|
|
114
|
+
|
|
112
115
|
try:
|
|
113
116
|
with socket.create_connection((host, port), timeout=timeout):
|
|
114
117
|
return True, f"answering on {host}:{port}"
|
|
115
118
|
except socket.gaierror:
|
|
116
|
-
|
|
117
|
-
# camera, the name simply does not resolve on this machine, and that
|
|
118
|
-
# is a different thing to go and fix.
|
|
119
|
-
return False, f"{host} cannot be resolved"
|
|
119
|
+
pass
|
|
120
120
|
except OSError as e:
|
|
121
121
|
return False, f"no answer on {host}:{port} ({e.strerror or e})"
|
|
122
122
|
|
|
123
|
+
# The machine's resolver has no answer. A `.local` name can still be
|
|
124
|
+
# answered by the machine that owns it, which is exactly what the proxy
|
|
125
|
+
# does when it connects — so the same fallback belongs here, or `detect`
|
|
126
|
+
# would call a camera missing that the proxy serves perfectly well.
|
|
127
|
+
#
|
|
128
|
+
# Only for the addresses the proxy fetches itself: an RTSP stream is
|
|
129
|
+
# opened by FFmpeg, with its own resolver and no way in, so promising that
|
|
130
|
+
# one works would be a promise this cannot keep.
|
|
131
|
+
if urlsplit(url).scheme.lower() not in HTTP_SCHEMES:
|
|
132
|
+
return False, f"{host} cannot be resolved"
|
|
133
|
+
|
|
134
|
+
addresses = resolve_over_mdns(host)
|
|
135
|
+
if not addresses:
|
|
136
|
+
# Told apart from a connection that fails: nothing is wrong with the
|
|
137
|
+
# camera, the name simply does not resolve here, and that is a
|
|
138
|
+
# different thing to go and fix.
|
|
139
|
+
return False, f"{host} cannot be resolved"
|
|
140
|
+
|
|
141
|
+
# A machine with several interfaces answers with an address for each, and
|
|
142
|
+
# only some of them are reachable from here — the same reason the proxy
|
|
143
|
+
# hands the whole list to aiohttp rather than picking one.
|
|
144
|
+
why = ""
|
|
145
|
+
for address in addresses:
|
|
146
|
+
try:
|
|
147
|
+
with socket.create_connection((address, port), timeout=timeout):
|
|
148
|
+
return True, f"answering on {address}:{port}, found over mDNS"
|
|
149
|
+
except OSError as e:
|
|
150
|
+
why = f"no answer on {address}:{port} ({e.strerror or e})"
|
|
151
|
+
return False, why
|
|
152
|
+
|
|
123
153
|
|
|
124
154
|
# ---------------------------------------------------------------------------
|
|
125
155
|
# The report
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolving a ``.local`` name when the machine's own resolver will not.
|
|
3
|
+
|
|
4
|
+
A ``.local`` name is not in any DNS server: the machine that owns it answers
|
|
5
|
+
for itself, over multicast, on 224.0.0.251:5353 (RFC 6762). Most operating
|
|
6
|
+
systems do that for every application — macOS through mDNSResponder, Linux
|
|
7
|
+
through Avahi, Windows through its DNS client — but not all of them do, and
|
|
8
|
+
not on every network profile. When they do not, ``getaddrinfo`` sends the
|
|
9
|
+
query to the configured DNS server instead, which knows nothing about
|
|
10
|
+
``.local`` and answers SERVFAIL. That is what a proxy sees as::
|
|
11
|
+
|
|
12
|
+
Cannot connect to host skykot.local:80 [DNS server returned general failure]
|
|
13
|
+
|
|
14
|
+
while the browser two windows away, which speaks mDNS itself, shows the very
|
|
15
|
+
same camera. So the proxy speaks it too, as a fallback and only for ``.local``
|
|
16
|
+
names — anything else belongs to the machine's resolver and stays there.
|
|
17
|
+
|
|
18
|
+
This is deliberately a *one-shot* query, not a discovery service: one question
|
|
19
|
+
about one name, the first answer wins, and nothing is kept running. mDNS on
|
|
20
|
+
the wire is ordinary DNS, so what follows is a small DNS encoder and decoder
|
|
21
|
+
rather than anything exotic.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import socket
|
|
26
|
+
import struct
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
MDNS_ADDRESS = "224.0.0.251"
|
|
32
|
+
MDNS_PORT = 5353
|
|
33
|
+
SUFFIX = ".local"
|
|
34
|
+
|
|
35
|
+
# Long enough for a machine on the same network to answer, short enough that a
|
|
36
|
+
# camera which is simply switched off does not hold anything up. A responder
|
|
37
|
+
# on the same LAN answers in milliseconds.
|
|
38
|
+
DEFAULT_TIMEOUT = 1.5
|
|
39
|
+
|
|
40
|
+
_TYPE_A = 1
|
|
41
|
+
_CLASS_IN = 1
|
|
42
|
+
# RFC 6762 §5.4: the top bit of the class field asks the responder to answer
|
|
43
|
+
# this one directly to our port rather than multicasting it to the network.
|
|
44
|
+
# Without it the answer goes to port 5353, where the system responder — if
|
|
45
|
+
# there is one — is listening instead of us.
|
|
46
|
+
_UNICAST_RESPONSE = 0x8000
|
|
47
|
+
_HEADER = struct.Struct("!6H")
|
|
48
|
+
_POINTER_MASK = 0xC0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_mdns_name(host: str) -> bool:
|
|
52
|
+
"""Whether ``host`` is a name only multicast DNS can answer for."""
|
|
53
|
+
host = (host or "").strip().rstrip(".").lower()
|
|
54
|
+
return host.endswith(SUFFIX) and host != SUFFIX
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _encode_name(name: str) -> bytes:
|
|
58
|
+
parts = [p for p in name.strip().rstrip(".").split(".") if p]
|
|
59
|
+
encoded = b""
|
|
60
|
+
for part in parts:
|
|
61
|
+
raw = part.encode("utf-8")
|
|
62
|
+
if len(raw) > 63:
|
|
63
|
+
raise ValueError(f"label too long in {name!r}")
|
|
64
|
+
encoded += bytes([len(raw)]) + raw
|
|
65
|
+
return encoded + b"\x00"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_query(name: str) -> bytes:
|
|
69
|
+
# Transaction id 0: a responder echoes it back, and having only one query
|
|
70
|
+
# in flight per socket means there is nothing to match it against.
|
|
71
|
+
header = _HEADER.pack(0, 0, 1, 0, 0, 0)
|
|
72
|
+
question = _encode_name(name)
|
|
73
|
+
question += struct.pack("!HH", _TYPE_A, _CLASS_IN | _UNICAST_RESPONSE)
|
|
74
|
+
return header + question
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_name(data: bytes, offset: int) -> tuple:
|
|
78
|
+
"""``(name, offset_after)``, following compression pointers.
|
|
79
|
+
|
|
80
|
+
Answers routinely point back into the question rather than repeating the
|
|
81
|
+
name, so a decoder that cannot follow a pointer reads garbage.
|
|
82
|
+
"""
|
|
83
|
+
labels = []
|
|
84
|
+
jumped = False
|
|
85
|
+
end = offset
|
|
86
|
+
seen = 0
|
|
87
|
+
|
|
88
|
+
while True:
|
|
89
|
+
if offset >= len(data):
|
|
90
|
+
raise ValueError("truncated name")
|
|
91
|
+
length = data[offset]
|
|
92
|
+
if length == 0:
|
|
93
|
+
offset += 1
|
|
94
|
+
if not jumped:
|
|
95
|
+
end = offset
|
|
96
|
+
break
|
|
97
|
+
if length & _POINTER_MASK == _POINTER_MASK:
|
|
98
|
+
if offset + 1 >= len(data):
|
|
99
|
+
raise ValueError("truncated pointer")
|
|
100
|
+
target = ((length & 0x3F) << 8) | data[offset + 1]
|
|
101
|
+
if not jumped:
|
|
102
|
+
end = offset + 2
|
|
103
|
+
jumped = True
|
|
104
|
+
# A pointer chain must make progress towards the start of the
|
|
105
|
+
# packet; anything else is a loop, and a packet from the network
|
|
106
|
+
# is not to be trusted to avoid one.
|
|
107
|
+
if target >= offset:
|
|
108
|
+
raise ValueError("pointer does not move backwards")
|
|
109
|
+
offset = target
|
|
110
|
+
seen += 1
|
|
111
|
+
if seen > 32:
|
|
112
|
+
raise ValueError("too many pointers")
|
|
113
|
+
continue
|
|
114
|
+
offset += 1
|
|
115
|
+
labels.append(data[offset : offset + length].decode("utf-8", "replace"))
|
|
116
|
+
offset += length
|
|
117
|
+
|
|
118
|
+
return ".".join(labels), end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_response(data: bytes, wanted: str) -> list:
|
|
122
|
+
"""Every IPv4 address ``wanted`` resolves to in ``data``.
|
|
123
|
+
|
|
124
|
+
Only A records for the name that was asked about count. A response may
|
|
125
|
+
carry records about other names entirely — a responder is free to include
|
|
126
|
+
whatever it thinks useful — and answering with one of those would send the
|
|
127
|
+
proxy to a different machine.
|
|
128
|
+
|
|
129
|
+
All of them, not the first: a machine with more than one interface answers
|
|
130
|
+
with an address per interface, and which of those the caller can actually
|
|
131
|
+
reach is not something this can tell from here. A Mac answering for itself
|
|
132
|
+
while a virtual network is up is the ordinary case, not an exotic one.
|
|
133
|
+
"""
|
|
134
|
+
found: list = []
|
|
135
|
+
try:
|
|
136
|
+
_, _, questions, answers, authority, additional = _HEADER.unpack_from(data)
|
|
137
|
+
except struct.error:
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
offset = _HEADER.size
|
|
141
|
+
try:
|
|
142
|
+
for _ in range(questions):
|
|
143
|
+
_, offset = _read_name(data, offset)
|
|
144
|
+
offset += 4 # QTYPE, QCLASS
|
|
145
|
+
|
|
146
|
+
target = wanted.rstrip(".").lower()
|
|
147
|
+
for _ in range(answers + authority + additional):
|
|
148
|
+
name, offset = _read_name(data, offset)
|
|
149
|
+
rtype, rclass, _, length = struct.unpack_from("!HHIH", data, offset)
|
|
150
|
+
offset += 10
|
|
151
|
+
rdata = data[offset : offset + length]
|
|
152
|
+
offset += length
|
|
153
|
+
if (
|
|
154
|
+
rtype == _TYPE_A
|
|
155
|
+
and (rclass & 0x7FFF) == _CLASS_IN
|
|
156
|
+
and len(rdata) == 4
|
|
157
|
+
and name.rstrip(".").lower() == target
|
|
158
|
+
):
|
|
159
|
+
address = socket.inet_ntoa(rdata)
|
|
160
|
+
if address not in found:
|
|
161
|
+
found.append(address)
|
|
162
|
+
except (ValueError, struct.error, IndexError):
|
|
163
|
+
# A malformed packet is one more reason to fall back to failing, not a
|
|
164
|
+
# reason to take the proxy down. Anyone on the network can send one.
|
|
165
|
+
# Whatever was read before the damage is still good.
|
|
166
|
+
logger.debug("Ignoring the rest of a malformed mDNS response for %s", wanted)
|
|
167
|
+
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve(host: str, timeout: float = DEFAULT_TIMEOUT) -> list:
|
|
172
|
+
"""Ask the network for ``host``'s IPv4 addresses. Empty if none answer.
|
|
173
|
+
|
|
174
|
+
In the order the responder gave them, which is the order to try them in:
|
|
175
|
+
the caller connects to each until one works, since a machine with several
|
|
176
|
+
interfaces answers for all of them and only some may be reachable.
|
|
177
|
+
|
|
178
|
+
Blocking, and short. Call it from a thread when the caller is async.
|
|
179
|
+
"""
|
|
180
|
+
if not is_mdns_name(host):
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
name = host.strip().rstrip(".")
|
|
184
|
+
try:
|
|
185
|
+
query = _build_query(name)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
sock = None
|
|
190
|
+
try:
|
|
191
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
192
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
193
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
|
|
194
|
+
sock.settimeout(timeout)
|
|
195
|
+
sock.bind(("", 0))
|
|
196
|
+
sock.sendto(query, (MDNS_ADDRESS, MDNS_PORT))
|
|
197
|
+
|
|
198
|
+
deadline = time.monotonic() + timeout
|
|
199
|
+
while time.monotonic() < deadline:
|
|
200
|
+
sock.settimeout(max(0.05, deadline - time.monotonic()))
|
|
201
|
+
try:
|
|
202
|
+
data, _ = sock.recvfrom(4096)
|
|
203
|
+
except socket.timeout:
|
|
204
|
+
return []
|
|
205
|
+
addresses = _parse_response(data, name)
|
|
206
|
+
if addresses:
|
|
207
|
+
logger.info("Resolved %s to %s over mDNS.", name, addresses)
|
|
208
|
+
return addresses
|
|
209
|
+
# Something else on the network answered, or answered about
|
|
210
|
+
# another name. Keep listening until the deadline rather than
|
|
211
|
+
# taking the first packet as the last word.
|
|
212
|
+
except OSError as e:
|
|
213
|
+
# No multicast route, a firewall in the way, a network that is down:
|
|
214
|
+
# all of them mean "not resolved here", which is what the caller does
|
|
215
|
+
# something about.
|
|
216
|
+
logger.debug("mDNS query for %s failed: %s", name, e)
|
|
217
|
+
finally:
|
|
218
|
+
if sock is not None:
|
|
219
|
+
sock.close()
|
|
220
|
+
|
|
221
|
+
return []
|
|
@@ -22,9 +22,11 @@ import asyncio
|
|
|
22
22
|
import hashlib
|
|
23
23
|
import logging
|
|
24
24
|
import os
|
|
25
|
+
import socket
|
|
25
26
|
from typing import Optional
|
|
26
27
|
from urllib.parse import urlsplit, urlunsplit
|
|
27
28
|
|
|
29
|
+
from . import mdns
|
|
28
30
|
from .base import FrameSource, SourceInfo
|
|
29
31
|
|
|
30
32
|
logger = logging.getLogger(__name__)
|
|
@@ -163,6 +165,7 @@ class HTTPImageSource(FrameSource):
|
|
|
163
165
|
self._etag: Optional[str] = None
|
|
164
166
|
self._last_modified: Optional[str] = None
|
|
165
167
|
self._digest: Optional[bytes] = None
|
|
168
|
+
self._resolver = None
|
|
166
169
|
|
|
167
170
|
async def open(self) -> None:
|
|
168
171
|
import aiohttp
|
|
@@ -170,7 +173,13 @@ class HTTPImageSource(FrameSource):
|
|
|
170
173
|
timeout = aiohttp.ClientTimeout(
|
|
171
174
|
total=None, connect=_CONNECT_TIMEOUT, sock_read=_READ_TIMEOUT
|
|
172
175
|
)
|
|
173
|
-
|
|
176
|
+
# aiohttp never closes a resolver it was handed, so this one is kept
|
|
177
|
+
# and closed alongside the session.
|
|
178
|
+
self._resolver = _mdns_aware_resolver()
|
|
179
|
+
self._session = aiohttp.ClientSession(
|
|
180
|
+
timeout=timeout,
|
|
181
|
+
connector=aiohttp.TCPConnector(resolver=self._resolver),
|
|
182
|
+
)
|
|
174
183
|
|
|
175
184
|
try:
|
|
176
185
|
response = await self._session.get(self.url)
|
|
@@ -266,6 +275,9 @@ class HTTPImageSource(FrameSource):
|
|
|
266
275
|
if self._session is not None:
|
|
267
276
|
await self._session.close()
|
|
268
277
|
self._session = None
|
|
278
|
+
if self._resolver is not None:
|
|
279
|
+
await self._resolver.close()
|
|
280
|
+
self._resolver = None
|
|
269
281
|
|
|
270
282
|
def info(self) -> SourceInfo:
|
|
271
283
|
return SourceInfo(
|
|
@@ -279,6 +291,65 @@ class HTTPImageSource(FrameSource):
|
|
|
279
291
|
)
|
|
280
292
|
|
|
281
293
|
|
|
294
|
+
async def _resolve_over_mdns(host: str) -> list:
|
|
295
|
+
"""``host``'s addresses according to the machine that owns the name."""
|
|
296
|
+
if not mdns.is_mdns_name(host):
|
|
297
|
+
return []
|
|
298
|
+
loop = asyncio.get_running_loop()
|
|
299
|
+
return await loop.run_in_executor(None, mdns.resolve, host)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _mdns_aware_resolver():
|
|
303
|
+
"""An aiohttp resolver that falls back to mDNS for a ``.local`` name.
|
|
304
|
+
|
|
305
|
+
The machine's own resolver is asked first and is almost always the end of
|
|
306
|
+
it. Only when it has no answer, and only for a ``.local`` name, is the
|
|
307
|
+
network asked directly — see ``mdns``. A camera registered by such a name
|
|
308
|
+
therefore keeps working when its address changes, which is the whole
|
|
309
|
+
reason for resolving at connect time rather than storing an address.
|
|
310
|
+
|
|
311
|
+
Built inside a function because aiohttp is an optional dependency: the CLI
|
|
312
|
+
has to stay importable on a machine that never installed the proxy extra.
|
|
313
|
+
"""
|
|
314
|
+
import aiohttp
|
|
315
|
+
from aiohttp.abc import AbstractResolver
|
|
316
|
+
|
|
317
|
+
class _MDNSFallbackResolver(AbstractResolver):
|
|
318
|
+
def __init__(self):
|
|
319
|
+
self._default = aiohttp.DefaultResolver()
|
|
320
|
+
|
|
321
|
+
async def resolve(self, host, port=0, family=socket.AF_INET):
|
|
322
|
+
try:
|
|
323
|
+
return await self._default.resolve(host, port, family)
|
|
324
|
+
except OSError:
|
|
325
|
+
# Only A records are asked for, so an IPv6-only lookup is not
|
|
326
|
+
# something this can answer.
|
|
327
|
+
if family not in (socket.AF_INET, socket.AF_UNSPEC):
|
|
328
|
+
raise
|
|
329
|
+
addresses = await _resolve_over_mdns(host)
|
|
330
|
+
if not addresses:
|
|
331
|
+
raise
|
|
332
|
+
# Every address the machine answered with, in the order it
|
|
333
|
+
# gave them: aiohttp tries them in turn, which is what makes
|
|
334
|
+
# a responder with several interfaces work.
|
|
335
|
+
return [
|
|
336
|
+
{
|
|
337
|
+
"hostname": host,
|
|
338
|
+
"host": address,
|
|
339
|
+
"port": port,
|
|
340
|
+
"family": socket.AF_INET,
|
|
341
|
+
"proto": 0,
|
|
342
|
+
"flags": socket.AI_NUMERICHOST,
|
|
343
|
+
}
|
|
344
|
+
for address in addresses
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
async def close(self) -> None:
|
|
348
|
+
await self._default.close()
|
|
349
|
+
|
|
350
|
+
return _MDNSFallbackResolver()
|
|
351
|
+
|
|
352
|
+
|
|
282
353
|
class AllSkyHTTPSource(HTTPImageSource):
|
|
283
354
|
"""An all-sky camera that publishes its JPEG over HTTP.
|
|
284
355
|
|
|
@@ -135,19 +135,73 @@ def test_reachability_never_leaks_the_password():
|
|
|
135
135
|
assert "hunter2" not in why
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def
|
|
139
|
-
"""
|
|
138
|
+
def _unresolvable(monkeypatch, answering=()):
|
|
139
|
+
"""Make the machine's resolver fail, except for addresses in ``answering``."""
|
|
140
140
|
import socket as socket_module
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
from contextlib import nullcontext
|
|
143
|
+
|
|
144
|
+
def connect(endpoint, timeout=None):
|
|
145
|
+
host, _ = endpoint
|
|
146
|
+
if host in answering:
|
|
147
|
+
return nullcontext()
|
|
143
148
|
raise socket_module.gaierror(-2, "Name or service not known")
|
|
144
149
|
|
|
145
|
-
monkeypatch.setattr(detection.socket, "create_connection",
|
|
150
|
+
monkeypatch.setattr(detection.socket, "create_connection", connect)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_a_name_that_does_not_resolve_says_so(monkeypatch):
|
|
154
|
+
"""Distinct from a refused connection: the camera may be perfectly fine."""
|
|
155
|
+
_unresolvable(monkeypatch)
|
|
156
|
+
monkeypatch.setattr(detection, "resolve_over_mdns", lambda host, **k: [])
|
|
157
|
+
|
|
146
158
|
reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
|
|
147
159
|
assert reachable is False
|
|
148
160
|
assert why == "skykot.local cannot be resolved"
|
|
149
161
|
|
|
150
162
|
|
|
163
|
+
def test_a_local_name_the_machine_cannot_resolve_is_asked_of_the_network(monkeypatch):
|
|
164
|
+
"""What the proxy does when it connects, so `detect` must agree with it."""
|
|
165
|
+
_unresolvable(monkeypatch, answering={"192.168.1.42"})
|
|
166
|
+
monkeypatch.setattr(
|
|
167
|
+
detection, "resolve_over_mdns", lambda host, **k: ["192.168.1.42"]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
|
|
171
|
+
assert reachable is True
|
|
172
|
+
assert why == "answering on 192.168.1.42:80, found over mDNS"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_an_address_that_does_not_answer_is_not_the_last_word(monkeypatch):
|
|
176
|
+
"""The first address a multi-homed machine gives may be one this network
|
|
177
|
+
cannot reach; the others are still worth trying."""
|
|
178
|
+
_unresolvable(monkeypatch, answering={"192.168.1.97"})
|
|
179
|
+
monkeypatch.setattr(
|
|
180
|
+
detection,
|
|
181
|
+
"resolve_over_mdns",
|
|
182
|
+
lambda host, **k: ["192.168.64.1", "192.168.1.97"],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
reachable, why = detection.is_reachable("http://skykot.local/image.jpg")
|
|
186
|
+
assert reachable is True
|
|
187
|
+
assert "192.168.1.97" in why
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_a_video_stream_is_not_asked_of_the_network(monkeypatch):
|
|
191
|
+
"""FFmpeg opens an RTSP stream with its own resolver, out of our reach —
|
|
192
|
+
so promising it works would be a promise we cannot keep."""
|
|
193
|
+
_unresolvable(monkeypatch, answering={"192.168.1.42"})
|
|
194
|
+
|
|
195
|
+
def never(*a, **k):
|
|
196
|
+
raise AssertionError("asked over mDNS")
|
|
197
|
+
|
|
198
|
+
monkeypatch.setattr(detection, "resolve_over_mdns", never)
|
|
199
|
+
|
|
200
|
+
reachable, why = detection.is_reachable("rtsp://cam.local/stream1")
|
|
201
|
+
assert reachable is False
|
|
202
|
+
assert why == "cam.local cannot be resolved"
|
|
203
|
+
|
|
204
|
+
|
|
151
205
|
def test_an_address_that_cannot_be_read_is_not_contacted():
|
|
152
206
|
reachable, why = detection.is_reachable("http://")
|
|
153
207
|
assert reachable is False
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Tests for resolving a `.local` name when the machine's resolver will not.
|
|
2
|
+
|
|
3
|
+
Nothing here touches the network: the wire format is what matters, so the
|
|
4
|
+
packets are built by hand and fed to the parser. A responder on the other end
|
|
5
|
+
is somebody else's software, and a test that waited for one would pass or fail
|
|
6
|
+
according to what happens to be plugged in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
import struct
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from arcsecond.imagesources.sources import mdns
|
|
15
|
+
|
|
16
|
+
NAME = "skykot.local"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _response(records, questions=(NAME,), flags=0x8400):
|
|
20
|
+
"""A response packet carrying ``records`` as ``(name, type, rdata)``."""
|
|
21
|
+
header = mdns._HEADER.pack(0, flags, len(questions), len(records), 0, 0)
|
|
22
|
+
body = b""
|
|
23
|
+
for question in questions:
|
|
24
|
+
body += mdns._encode_name(question) + struct.pack("!HH", 1, 1)
|
|
25
|
+
for name, rtype, rdata in records:
|
|
26
|
+
body += mdns._encode_name(name)
|
|
27
|
+
body += struct.pack("!HHIH", rtype, 1, 120, len(rdata)) + rdata
|
|
28
|
+
return header + body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Which names this is for
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"host,expected",
|
|
38
|
+
[
|
|
39
|
+
("skykot.local", True),
|
|
40
|
+
("skykot.local.", True),
|
|
41
|
+
("SKYKOT.LOCAL", True),
|
|
42
|
+
("allsky.example.com", False),
|
|
43
|
+
("192.168.1.42", False),
|
|
44
|
+
(".local", False),
|
|
45
|
+
("", False),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
def test_only_local_names_are_asked_of_the_network(host, expected):
|
|
49
|
+
"""Everything else belongs to the machine's resolver and stays there."""
|
|
50
|
+
assert mdns.is_mdns_name(host) is expected
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_name_that_is_not_local_is_not_even_asked(monkeypatch):
|
|
54
|
+
def no_sockets(*a, **k):
|
|
55
|
+
raise AssertionError("opened a socket")
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(mdns.socket, "socket", no_sockets)
|
|
58
|
+
assert mdns.resolve("allsky.example.com") == []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# The query
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_the_query_asks_for_one_a_record_by_name():
|
|
67
|
+
query = mdns._build_query(NAME)
|
|
68
|
+
_, _, questions, answers, _, _ = mdns._HEADER.unpack_from(query)
|
|
69
|
+
assert (questions, answers) == (1, 0)
|
|
70
|
+
assert mdns._encode_name(NAME) in query
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_the_query_asks_to_be_answered_directly():
|
|
74
|
+
"""Without the unicast bit the answer goes to port 5353, where the
|
|
75
|
+
system responder is listening instead of us."""
|
|
76
|
+
query = mdns._build_query(NAME)
|
|
77
|
+
_, qclass = struct.unpack("!HH", query[-4:])
|
|
78
|
+
assert qclass & mdns._UNICAST_RESPONSE
|
|
79
|
+
assert qclass & 0x7FFF == mdns._CLASS_IN
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# The answer
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_an_address_is_read_from_the_answer():
|
|
88
|
+
packet = _response([(NAME, 1, socket.inet_aton("192.168.1.42"))])
|
|
89
|
+
assert mdns._parse_response(packet, NAME) == ["192.168.1.42"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_name_written_as_a_pointer_is_followed():
|
|
93
|
+
"""Responders routinely point back at the question instead of repeating
|
|
94
|
+
the name, so a decoder that cannot follow one reads garbage."""
|
|
95
|
+
header = mdns._HEADER.pack(0, 0x8400, 1, 1, 0, 0)
|
|
96
|
+
question = mdns._encode_name(NAME) + struct.pack("!HH", 1, 1)
|
|
97
|
+
pointer = struct.pack("!H", 0xC000 | mdns._HEADER.size)
|
|
98
|
+
answer = pointer + struct.pack("!HHIH", 1, 1, 120, 4)
|
|
99
|
+
answer += socket.inet_aton("192.168.1.42")
|
|
100
|
+
|
|
101
|
+
assert mdns._parse_response(header + question + answer, NAME) == ["192.168.1.42"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_an_answer_about_another_machine_is_not_taken():
|
|
105
|
+
"""A responder may say whatever it finds useful. Taking one of those
|
|
106
|
+
would send the proxy to a different machine entirely."""
|
|
107
|
+
packet = _response([("other.local", 1, socket.inet_aton("10.0.0.1"))])
|
|
108
|
+
assert mdns._parse_response(packet, NAME) == []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_the_right_answer_is_found_among_others():
|
|
112
|
+
packet = _response(
|
|
113
|
+
[
|
|
114
|
+
("other.local", 1, socket.inet_aton("10.0.0.1")),
|
|
115
|
+
(NAME, 1, socket.inet_aton("192.168.1.42")),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
assert mdns._parse_response(packet, NAME) == ["192.168.1.42"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_every_address_a_machine_answers_with_is_kept():
|
|
122
|
+
"""A machine with more than one interface answers with one address each,
|
|
123
|
+
and which of them the caller can reach is not knowable from here."""
|
|
124
|
+
packet = _response(
|
|
125
|
+
[
|
|
126
|
+
(NAME, 1, socket.inet_aton("192.168.64.1")),
|
|
127
|
+
(NAME, 1, socket.inet_aton("192.168.1.97")),
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
assert mdns._parse_response(packet, NAME) == ["192.168.64.1", "192.168.1.97"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_the_same_address_twice_is_kept_once():
|
|
134
|
+
packet = _response(
|
|
135
|
+
[
|
|
136
|
+
(NAME, 1, socket.inet_aton("192.168.1.97")),
|
|
137
|
+
(NAME, 1, socket.inet_aton("192.168.1.97")),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
assert mdns._parse_response(packet, NAME) == ["192.168.1.97"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_a_record_that_is_not_an_address_is_ignored():
|
|
144
|
+
packet = _response([(NAME, 12, mdns._encode_name("something.local"))])
|
|
145
|
+
assert mdns._parse_response(packet, NAME) == []
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize(
|
|
149
|
+
"packet",
|
|
150
|
+
[
|
|
151
|
+
b"",
|
|
152
|
+
b"\x00\x01\x02",
|
|
153
|
+
mdns._HEADER.pack(0, 0x8400, 1, 1, 0, 0) + b"\x05sky", # truncated name
|
|
154
|
+
# A pointer to itself: a packet from the network must not be trusted
|
|
155
|
+
# to avoid a loop.
|
|
156
|
+
mdns._HEADER.pack(0, 0x8400, 0, 1, 0, 0) + struct.pack("!H", 0xC00C),
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
def test_a_malformed_packet_is_ignored_rather_than_fatal(packet):
|
|
160
|
+
"""Anyone on the network can send one, and the proxy must survive it."""
|
|
161
|
+
assert mdns._parse_response(packet, NAME) == []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# The query as a whole
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_nothing_answering_is_an_answer(monkeypatch):
|
|
170
|
+
class SilentSocket:
|
|
171
|
+
def setsockopt(self, *a):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
def settimeout(self, *a):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def bind(self, *a):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def sendto(self, *a):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def recvfrom(self, *a):
|
|
184
|
+
raise socket.timeout()
|
|
185
|
+
|
|
186
|
+
def close(self):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(mdns.socket, "socket", lambda *a, **k: SilentSocket())
|
|
190
|
+
assert mdns.resolve(NAME, timeout=0.05) == []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_a_network_that_refuses_multicast_is_not_fatal(monkeypatch):
|
|
194
|
+
"""No route, or a firewall in the way: that means "not resolved here",
|
|
195
|
+
which is a thing the caller reports rather than a crash."""
|
|
196
|
+
|
|
197
|
+
def no_multicast(*a, **k):
|
|
198
|
+
raise OSError("Network is unreachable")
|
|
199
|
+
|
|
200
|
+
monkeypatch.setattr(mdns.socket, "socket", no_multicast)
|
|
201
|
+
assert mdns.resolve(NAME, timeout=0.05) == []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# The resolver the proxy hands to aiohttp
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve(host, family=socket.AF_INET, default=None):
|
|
210
|
+
"""Run the proxy's resolver for ``host``, with ``default`` standing in for
|
|
211
|
+
the machine's own resolver."""
|
|
212
|
+
import asyncio
|
|
213
|
+
from unittest.mock import AsyncMock, patch
|
|
214
|
+
|
|
215
|
+
from arcsecond.imagesources.sources.network import _mdns_aware_resolver
|
|
216
|
+
|
|
217
|
+
if default is None:
|
|
218
|
+
machine = AsyncMock(side_effect=socket.gaierror(-2, "not known"))
|
|
219
|
+
else:
|
|
220
|
+
machine = AsyncMock(return_value=default)
|
|
221
|
+
|
|
222
|
+
async def main():
|
|
223
|
+
resolver = _mdns_aware_resolver()
|
|
224
|
+
with patch.object(
|
|
225
|
+
resolver, "_default", AsyncMock(resolve=machine, close=AsyncMock())
|
|
226
|
+
):
|
|
227
|
+
try:
|
|
228
|
+
return await resolver.resolve(host, 80, family)
|
|
229
|
+
finally:
|
|
230
|
+
await resolver.close()
|
|
231
|
+
|
|
232
|
+
return asyncio.run(main())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_the_machine_resolver_is_asked_first():
|
|
236
|
+
"""It is the answer almost every time, and mDNS is only for what is left."""
|
|
237
|
+
answer = [{"hostname": "cam.example.com", "host": "10.0.0.5", "port": 80}]
|
|
238
|
+
assert _resolve("cam.example.com", default=answer) == answer
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_a_local_name_falls_back_to_the_network(monkeypatch):
|
|
242
|
+
monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: ["192.168.1.42"])
|
|
243
|
+
|
|
244
|
+
(result,) = _resolve(NAME)
|
|
245
|
+
assert result["host"] == "192.168.1.42"
|
|
246
|
+
assert result["hostname"] == NAME
|
|
247
|
+
assert result["port"] == 80
|
|
248
|
+
assert result["family"] == socket.AF_INET
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_every_address_is_offered_to_aiohttp(monkeypatch):
|
|
252
|
+
"""aiohttp tries them in turn, which is what makes a multi-homed
|
|
253
|
+
responder work rather than work half the time."""
|
|
254
|
+
monkeypatch.setattr(
|
|
255
|
+
mdns, "resolve", lambda host, *a, **k: ["192.168.64.1", "192.168.1.97"]
|
|
256
|
+
)
|
|
257
|
+
assert [r["host"] for r in _resolve(NAME)] == ["192.168.64.1", "192.168.1.97"]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_a_name_nothing_answers_for_still_fails(monkeypatch):
|
|
261
|
+
"""The connection error the operator gets must stay the real one."""
|
|
262
|
+
monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: [])
|
|
263
|
+
|
|
264
|
+
with pytest.raises(socket.gaierror):
|
|
265
|
+
_resolve(NAME)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_an_ipv6_lookup_is_not_answered_with_an_ipv4_address(monkeypatch):
|
|
269
|
+
"""Only A records are asked for, so this is not ours to answer."""
|
|
270
|
+
monkeypatch.setattr(mdns, "resolve", lambda host, *a, **k: ["192.168.1.42"])
|
|
271
|
+
|
|
272
|
+
with pytest.raises(socket.gaierror):
|
|
273
|
+
_resolve(NAME, family=socket.AF_INET6)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_errors.py
RENAMED
|
File without changes
|
{arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_full_process.py
RENAMED
|
File without changes
|
|
File without changes
|
{arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_prepare.py
RENAMED
|
File without changes
|
{arcsecond-3.18.2 → arcsecond-3.19.0}/tests/cloud/uploader/datafiles/test_uploader_upload.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|