hivemind-websocket-protocol 0.2.8a1__tar.gz → 0.2.8a3__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.
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/__init__.py +114 -11
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/version.py +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/SOURCES.txt +2 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_close_diagnostics.py +33 -8
- hivemind_websocket_protocol-0.2.8a3/tests/test_password_strength_cache.py +105 -0
- hivemind_websocket_protocol-0.2.8a3/tests/test_receive_logging.py +149 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/LICENSE.md +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/README.md +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/pyproject.toml +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/setup.cfg +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_decode_auth.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_protocol_unit.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_trusted_proxy_config.py +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import dataclasses
|
|
3
|
+
import hashlib
|
|
3
4
|
import math
|
|
4
5
|
import os
|
|
5
6
|
import os.path
|
|
6
7
|
import random
|
|
7
8
|
import threading
|
|
8
9
|
import time
|
|
10
|
+
from collections import OrderedDict
|
|
9
11
|
from os import makedirs
|
|
10
12
|
from os.path import exists, join
|
|
11
13
|
from socket import gethostname
|
|
@@ -18,7 +20,7 @@ from OpenSSL import crypto
|
|
|
18
20
|
from ovos_bus_client.session import Session
|
|
19
21
|
from ovos_utils.log import LOG
|
|
20
22
|
from ovos_utils.xdg_utils import xdg_data_home
|
|
21
|
-
from poorman_handshake import PasswordHandShake
|
|
23
|
+
from poorman_handshake import PasswordHandShake, check_password_strength
|
|
22
24
|
from tornado import ioloop
|
|
23
25
|
from tornado import web
|
|
24
26
|
from tornado.platform.asyncio import AnyThreadEventLoopPolicy
|
|
@@ -56,6 +58,101 @@ DEFAULT_WEBSOCKET_PING_INTERVAL = 30.0
|
|
|
56
58
|
DEFAULT_WEBSOCKET_PING_TIMEOUT = 20.0
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
#: Passwords already checked against a given policy, most recent last.
|
|
62
|
+
#:
|
|
63
|
+
#: ``PasswordHandShake(password, min_bits=N)`` runs the credential through
|
|
64
|
+
#: zxcvbn on construction. That is the right thing to do, but it is ~2.2 ms
|
|
65
|
+
#: and Core builds one per admission on the single Tornado IOLoop, so a fleet
|
|
66
|
+
#: reconnecting at once serialises behind it: 400 satellites is ~0.87 s of
|
|
67
|
+
#: event loop spent re-deciding that the same handful of passwords are still
|
|
68
|
+
#: strong.
|
|
69
|
+
#:
|
|
70
|
+
#: Entries are keyed on a *keyed* blake2s digest and the policy that accepted
|
|
71
|
+
#: it. The key is per-process and never persisted, so this is an LRU lookup
|
|
72
|
+
#: key, not a stored password hash; rotating a password or tightening
|
|
73
|
+
#: ``min_bits`` misses the cache and re-validates.
|
|
74
|
+
_PASSWORD_STRENGTH_LOCK = threading.Lock()
|
|
75
|
+
_PASSWORD_STRENGTH_CACHE: "OrderedDict[Tuple[bytes, float], None]" = OrderedDict()
|
|
76
|
+
_PASSWORD_STRENGTH_CACHE_KEY = os.urandom(32)
|
|
77
|
+
_PASSWORD_STRENGTH_CACHE_SIZE = 4096
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _password_handshake(password: str,
|
|
81
|
+
min_bits: Optional[float] = None) -> PasswordHandShake:
|
|
82
|
+
"""Build a PasswordHandShake, validating each password once per policy.
|
|
83
|
+
|
|
84
|
+
Raises ``WeakPasswordError`` exactly as the plain constructor does -- a
|
|
85
|
+
weak password is never cached, so it is rejected on every attempt.
|
|
86
|
+
"""
|
|
87
|
+
if min_bits is None:
|
|
88
|
+
min_bits = runtime_password_min_bits()
|
|
89
|
+
|
|
90
|
+
if min_bits > 0:
|
|
91
|
+
digest = hashlib.blake2s(
|
|
92
|
+
password.encode("utf-8"),
|
|
93
|
+
key=_PASSWORD_STRENGTH_CACHE_KEY,
|
|
94
|
+
).digest()
|
|
95
|
+
cache_key = (digest, min_bits)
|
|
96
|
+
with _PASSWORD_STRENGTH_LOCK:
|
|
97
|
+
if cache_key in _PASSWORD_STRENGTH_CACHE:
|
|
98
|
+
_PASSWORD_STRENGTH_CACHE.move_to_end(cache_key)
|
|
99
|
+
else:
|
|
100
|
+
# Outside the cache-hit branch on purpose: a rejection must
|
|
101
|
+
# propagate and must not be remembered as a pass.
|
|
102
|
+
check_password_strength(password, min_bits=min_bits)
|
|
103
|
+
_PASSWORD_STRENGTH_CACHE[cache_key] = None
|
|
104
|
+
while len(_PASSWORD_STRENGTH_CACHE) > _PASSWORD_STRENGTH_CACHE_SIZE:
|
|
105
|
+
_PASSWORD_STRENGTH_CACHE.popitem(last=False)
|
|
106
|
+
|
|
107
|
+
# Already validated above; min_bits=0 skips the duplicate zxcvbn run.
|
|
108
|
+
return PasswordHandShake(password, min_bits=0)
|
|
109
|
+
#: Connection hot-path logger, resolved once.
|
|
110
|
+
#:
|
|
111
|
+
#: ``LOG.debug``/``LOG.info`` resolve the calling module, function and line
|
|
112
|
+
#: with ``inspect.stack()`` on *every* call, before the level is checked, so a
|
|
113
|
+
#: discarded DEBUG record costs the same as an emitted one. Admission, receive
|
|
114
|
+
#: and disconnect all run on Tornado's single IOLoop that serves every
|
|
115
|
+
#: connected satellite, so that cost is paid per connection and per inbound
|
|
116
|
+
#: frame, and delays every other peer on the node.
|
|
117
|
+
#:
|
|
118
|
+
#: ``LOG.create_logger`` returns the same OVOS-configured logger those calls
|
|
119
|
+
#: would have used -- same formatter, stdout and rotating-file handlers -- and
|
|
120
|
+
#: registers it in ``LOG._loggers``, so a later ``LOG.init``/``LOG.set_level``
|
|
121
|
+
#: still retargets its level. Only the per-call stack walk is dropped. It is
|
|
122
|
+
#: resolved lazily because ``LOG.init`` usually runs after this import.
|
|
123
|
+
_RECEIVE_LOGGER = None
|
|
124
|
+
_RECEIVE_LOGGER_KEY = None
|
|
125
|
+
_RECEIVE_LOGGER_LOCK = threading.Lock()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _receive_logger():
|
|
129
|
+
"""Return the cached hot-path logger, rebuilding when LOG rewires.
|
|
130
|
+
|
|
131
|
+
Cached against ``(LOG.name, LOG.base_path)``: ``LOG.init()`` normally runs
|
|
132
|
+
after import, and a logger created before it would carry only the stdout
|
|
133
|
+
handler -- configured file logging would silently vanish from this path,
|
|
134
|
+
because init does not rebuild handlers on existing loggers. When the
|
|
135
|
+
fingerprint changes, the stale entry and its handlers are dropped so
|
|
136
|
+
``create_logger`` rebuilds against the live config. The lock keeps two
|
|
137
|
+
racing first frames from attaching duplicate handlers to the same
|
|
138
|
+
process-wide ``logging.getLogger`` name.
|
|
139
|
+
"""
|
|
140
|
+
global _RECEIVE_LOGGER, _RECEIVE_LOGGER_KEY
|
|
141
|
+
key = (LOG.name, LOG.base_path)
|
|
142
|
+
if _RECEIVE_LOGGER is None or _RECEIVE_LOGGER_KEY != key:
|
|
143
|
+
with _RECEIVE_LOGGER_LOCK:
|
|
144
|
+
if _RECEIVE_LOGGER is None or _RECEIVE_LOGGER_KEY != key:
|
|
145
|
+
name = f"{LOG.name} - {__name__}"
|
|
146
|
+
stale = LOG._loggers.pop(name, None)
|
|
147
|
+
if stale is not None:
|
|
148
|
+
for handler in list(stale.handlers):
|
|
149
|
+
stale.removeHandler(handler)
|
|
150
|
+
handler.close()
|
|
151
|
+
_RECEIVE_LOGGER = LOG.create_logger(name)
|
|
152
|
+
_RECEIVE_LOGGER_KEY = key
|
|
153
|
+
return _RECEIVE_LOGGER
|
|
154
|
+
|
|
155
|
+
|
|
59
156
|
def _split_csv(value: Any) -> Tuple[str, ...]:
|
|
60
157
|
if not value:
|
|
61
158
|
return ()
|
|
@@ -322,14 +419,18 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
322
419
|
def _handle_inbound_message(self, message: str) -> None:
|
|
323
420
|
message = self.client.decode(message)
|
|
324
421
|
peer = self._peer_label(self.client.peer)
|
|
422
|
+
log = _receive_logger()
|
|
325
423
|
if (
|
|
326
424
|
message.msg_type == HiveMessageType.BUS
|
|
327
425
|
and message.payload.msg_type == "recognizer_loop:b64_audio"
|
|
328
426
|
):
|
|
329
|
-
|
|
427
|
+
log.debug("Received %s sent base64 audio for STT", peer)
|
|
330
428
|
else:
|
|
331
|
-
|
|
332
|
-
|
|
429
|
+
log.info("Received %s message: %s", peer, message.msg_type)
|
|
430
|
+
# Lazy args, never an f-string: ``HiveMessage.__str__`` serializes
|
|
431
|
+
# the whole envelope to JSON, and that must not run when DEBUG is
|
|
432
|
+
# off. It also keeps a user's transcribed speech out of the cost.
|
|
433
|
+
log.debug("Received %s message: %s", peer, message)
|
|
333
434
|
self.hm_protocol.handle_message(message, self.client)
|
|
334
435
|
|
|
335
436
|
def _peer_label(self, peer: str) -> str:
|
|
@@ -362,7 +463,8 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
362
463
|
)
|
|
363
464
|
self.close(code=1008, reason="invalid authorization")
|
|
364
465
|
return
|
|
365
|
-
|
|
466
|
+
_receive_logger().debug("Authorizing client from %s - %s",
|
|
467
|
+
self.source_ip or "unknown", useragent)
|
|
366
468
|
|
|
367
469
|
def do_send(payload: str, is_bin: bool):
|
|
368
470
|
def _write():
|
|
@@ -430,7 +532,7 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
430
532
|
self.client.is_admin = user.is_admin
|
|
431
533
|
if user.password:
|
|
432
534
|
# pre-shared password to derive aes_key
|
|
433
|
-
self.client.pswd_handshake =
|
|
535
|
+
self.client.pswd_handshake = _password_handshake(user.password)
|
|
434
536
|
|
|
435
537
|
self.client.node_type = HiveMindNodeType.NODE # TODO . placeholder
|
|
436
538
|
|
|
@@ -456,9 +558,9 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
456
558
|
def on_close(self):
|
|
457
559
|
client = getattr(self, "client", None)
|
|
458
560
|
if client is None:
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
561
|
+
_receive_logger().debug(
|
|
562
|
+
"closing unauthenticated websocket from %s "
|
|
563
|
+
"(no client was ever attached)", self.request.remote_ip
|
|
462
564
|
)
|
|
463
565
|
return
|
|
464
566
|
# The age of the last pong cannot tell a ping timeout apart from a
|
|
@@ -472,13 +574,14 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
472
574
|
since_pong = (
|
|
473
575
|
time.monotonic() - self.last_pong if self.last_pong is not None else None
|
|
474
576
|
)
|
|
475
|
-
|
|
577
|
+
log = _receive_logger()
|
|
578
|
+
log.info(
|
|
476
579
|
"disconnecting client: %s (close_code=%s, close_reason=%s, "
|
|
477
580
|
"seconds_since_last_pong=%s)",
|
|
478
581
|
self._peer_label(client.peer), self.close_code, self.close_reason,
|
|
479
582
|
f"{since_pong:.1f}" if since_pong is not None else "unknown",
|
|
480
583
|
)
|
|
481
|
-
|
|
584
|
+
log.debug("disconnecting client: %s", self._peer_label(client.peer))
|
|
482
585
|
self.hm_protocol.handle_client_disconnected(client)
|
|
483
586
|
|
|
484
587
|
def check_origin(self, origin) -> bool:
|
|
@@ -12,12 +12,16 @@ a peer that timed out echoes nothing.
|
|
|
12
12
|
|
|
13
13
|
`ovos_utils.log.LOG` sets `propagate = False` on its underlying logger, so
|
|
14
14
|
`caplog`'s root-logger capture can silently miss records depending on test
|
|
15
|
-
order. These tests instead patch
|
|
16
|
-
and assert on the calls
|
|
15
|
+
order. These tests instead patch the module's cached hot-path logger
|
|
16
|
+
(`_RECEIVE_LOGGER`, returned by `_receive_logger()`) and assert on the calls
|
|
17
|
+
it receives.
|
|
17
18
|
"""
|
|
19
|
+
import re
|
|
18
20
|
import time
|
|
19
21
|
from unittest.mock import MagicMock
|
|
20
22
|
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
21
25
|
from hivemind_core.protocol import HiveMindClientConnection
|
|
22
26
|
from hivemind_websocket_protocol import (
|
|
23
27
|
DEFAULT_WEBSOCKET_PING_INTERVAL,
|
|
@@ -64,6 +68,13 @@ def _logged(log):
|
|
|
64
68
|
return log.info.call_args[0][0] % log.info.call_args[0][1:]
|
|
65
69
|
|
|
66
70
|
|
|
71
|
+
def _pong_age(message):
|
|
72
|
+
"""The `seconds_since_last_pong=` value logged, as a float."""
|
|
73
|
+
match = re.search(r"seconds_since_last_pong=([\d.]+)", message)
|
|
74
|
+
assert match, message
|
|
75
|
+
return float(match.group(1))
|
|
76
|
+
|
|
77
|
+
|
|
67
78
|
def test_on_pong_stamps_last_pong():
|
|
68
79
|
handler = object.__new__(HiveMindTornadoWebSocket)
|
|
69
80
|
handler.last_pong = None
|
|
@@ -75,7 +86,9 @@ def test_on_pong_stamps_last_pong():
|
|
|
75
86
|
|
|
76
87
|
def test_close_reports_code_reason_and_pong_age(monkeypatch):
|
|
77
88
|
log = MagicMock()
|
|
78
|
-
monkeypatch.setattr(hwp, "
|
|
89
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
|
|
90
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
|
|
91
|
+
(hwp.LOG.name, hwp.LOG.base_path))
|
|
79
92
|
handler = _handler(
|
|
80
93
|
last_pong=time.monotonic() - 12,
|
|
81
94
|
close_code=1006,
|
|
@@ -98,7 +111,9 @@ def test_a_real_ping_timeout_is_reported_with_its_pong_age(monkeypatch):
|
|
|
98
111
|
is what the log must report - no threshold separates it from an ordinary
|
|
99
112
|
disconnect."""
|
|
100
113
|
log = MagicMock()
|
|
101
|
-
monkeypatch.setattr(hwp, "
|
|
114
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
|
|
115
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
|
|
116
|
+
(hwp.LOG.name, hwp.LOG.base_path))
|
|
102
117
|
round_trip = 0.3
|
|
103
118
|
age = DEFAULT_WEBSOCKET_PING_INTERVAL + DEFAULT_WEBSOCKET_PING_TIMEOUT - round_trip
|
|
104
119
|
handler = _handler(last_pong=time.monotonic() - age)
|
|
@@ -113,17 +128,25 @@ def test_no_pong_age_ever_triggers_a_verdict(monkeypatch):
|
|
|
113
128
|
one factual line, never a guess about the cause."""
|
|
114
129
|
for age in (0.5, 25, 49.7, 50, 120, 3600):
|
|
115
130
|
log = MagicMock()
|
|
116
|
-
monkeypatch.setattr(hwp, "
|
|
131
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
|
|
132
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
|
|
133
|
+
(hwp.LOG.name, hwp.LOG.base_path))
|
|
117
134
|
handler = _handler(last_pong=time.monotonic() - age)
|
|
118
135
|
|
|
119
136
|
handler.on_close()
|
|
120
137
|
|
|
121
|
-
|
|
138
|
+
# The exact decimal is timing-dependent (wall-clock elapses between
|
|
139
|
+
# `_handler()` stamping `last_pong` and `on_close()` reading it), so
|
|
140
|
+
# only the reported age's ballpark - not its literal formatting - is
|
|
141
|
+
# asserted here.
|
|
142
|
+
assert _pong_age(_logged(log)) == pytest.approx(age, abs=1.0)
|
|
122
143
|
|
|
123
144
|
|
|
124
145
|
def test_client_that_never_ponged_reports_unknown_age(monkeypatch):
|
|
125
146
|
log = MagicMock()
|
|
126
|
-
monkeypatch.setattr(hwp, "
|
|
147
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
|
|
148
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
|
|
149
|
+
(hwp.LOG.name, hwp.LOG.base_path))
|
|
127
150
|
handler = _handler(last_pong=None)
|
|
128
151
|
|
|
129
152
|
handler.on_close()
|
|
@@ -133,7 +156,9 @@ def test_client_that_never_ponged_reports_unknown_age(monkeypatch):
|
|
|
133
156
|
|
|
134
157
|
def test_on_close_never_logs_payload_or_access_key(monkeypatch):
|
|
135
158
|
log = MagicMock()
|
|
136
|
-
monkeypatch.setattr(hwp, "
|
|
159
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
|
|
160
|
+
monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
|
|
161
|
+
(hwp.LOG.name, hwp.LOG.base_path))
|
|
137
162
|
for last_pong in (time.monotonic() - 1, time.monotonic() - 3600):
|
|
138
163
|
handler = _handler(last_pong=last_pong)
|
|
139
164
|
handler.on_close()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Admission must not re-run zxcvbn for every connecting satellite.
|
|
2
|
+
|
|
3
|
+
``PasswordHandShake`` validates the credential on construction, and Core builds
|
|
4
|
+
one per admission on the single Tornado IOLoop. Validating the same password
|
|
5
|
+
400 times costs ~0.87 s of event loop that no satellite can use.
|
|
6
|
+
"""
|
|
7
|
+
import threading
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from poorman_handshake import WeakPasswordError
|
|
12
|
+
|
|
13
|
+
import hivemind_websocket_protocol as hwp
|
|
14
|
+
|
|
15
|
+
STRONG = "correct-horse-battery-staple-9271"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _clear_cache():
|
|
20
|
+
hwp._PASSWORD_STRENGTH_CACHE.clear()
|
|
21
|
+
yield
|
|
22
|
+
hwp._PASSWORD_STRENGTH_CACHE.clear()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_repeated_admissions_validate_once():
|
|
26
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
27
|
+
for _ in range(400):
|
|
28
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
29
|
+
|
|
30
|
+
assert check.call_count == 1, (
|
|
31
|
+
"each admission re-ran the strength check; a reconnecting fleet pays "
|
|
32
|
+
"this on the IOLoop"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_handshake_is_not_shared_between_connections():
|
|
37
|
+
"""Only the verdict is cached — handshake state is per connection."""
|
|
38
|
+
first = hwp._password_handshake(STRONG, min_bits=64)
|
|
39
|
+
second = hwp._password_handshake(STRONG, min_bits=64)
|
|
40
|
+
|
|
41
|
+
assert first is not second
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_weak_password_is_rejected_every_time():
|
|
45
|
+
"""A rejection must never be remembered as a pass."""
|
|
46
|
+
for _ in range(3):
|
|
47
|
+
with pytest.raises(WeakPasswordError):
|
|
48
|
+
hwp._password_handshake("123456", min_bits=64)
|
|
49
|
+
|
|
50
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_tightened_policy_revalidates():
|
|
54
|
+
hwp._password_handshake(STRONG, min_bits=40)
|
|
55
|
+
|
|
56
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
57
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
58
|
+
|
|
59
|
+
assert check.call_count == 1, "min_bits is part of the cache key"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_rotated_password_revalidates():
|
|
63
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
64
|
+
|
|
65
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
66
|
+
hwp._password_handshake(STRONG + "-rotated", min_bits=64)
|
|
67
|
+
|
|
68
|
+
assert check.call_count == 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_disabled_policy_does_not_validate_or_cache():
|
|
72
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
73
|
+
hwp._password_handshake(STRONG, min_bits=0)
|
|
74
|
+
|
|
75
|
+
assert check.call_count == 0
|
|
76
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_cache_is_bounded():
|
|
80
|
+
limit = hwp._PASSWORD_STRENGTH_CACHE_SIZE
|
|
81
|
+
with patch.object(hwp, "check_password_strength"):
|
|
82
|
+
for i in range(limit + 50):
|
|
83
|
+
hwp._password_handshake(f"{STRONG}-{i}", min_bits=64)
|
|
84
|
+
|
|
85
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) <= limit
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_concurrent_admissions_are_safe():
|
|
89
|
+
"""Admission runs from Tornado's loop and executor threads alike."""
|
|
90
|
+
errors = []
|
|
91
|
+
|
|
92
|
+
def admit():
|
|
93
|
+
try:
|
|
94
|
+
for _ in range(50):
|
|
95
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
96
|
+
except Exception as e: # noqa: BLE001
|
|
97
|
+
errors.append(e)
|
|
98
|
+
|
|
99
|
+
threads = [threading.Thread(target=admit) for _ in range(8)]
|
|
100
|
+
for t in threads:
|
|
101
|
+
t.start()
|
|
102
|
+
for t in threads:
|
|
103
|
+
t.join()
|
|
104
|
+
|
|
105
|
+
assert errors == []
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The websocket receive path must not pay for logging it discards.
|
|
2
|
+
|
|
3
|
+
``LOG.debug``/``LOG.info`` walk ``inspect.stack()`` on every call to label the
|
|
4
|
+
record with the caller, *before* the level is consulted, so a dropped DEBUG
|
|
5
|
+
record costs as much as an emitted one. ``_handle_inbound_message`` runs on the
|
|
6
|
+
single Tornado IOLoop that serves every connected satellite, so anything it
|
|
7
|
+
pays there is paid again by every other peer on the node.
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from ovos_utils.log import LOG
|
|
14
|
+
|
|
15
|
+
import hivemind_websocket_protocol as hwp
|
|
16
|
+
from hivemind_websocket_protocol import HiveMindTornadoWebSocket
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _CountingMessage:
|
|
20
|
+
"""Stands in for a HiveMessage, whose ``__str__`` serializes to JSON."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.msg_type = "bus"
|
|
24
|
+
self.payload = SimpleNamespace(msg_type="recognizer_loop:utterance")
|
|
25
|
+
self.renders = 0
|
|
26
|
+
|
|
27
|
+
def __str__(self):
|
|
28
|
+
self.renders += 1
|
|
29
|
+
return '{"msg_type": "bus", "payload": "secret utterance"}'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def handler():
|
|
34
|
+
"""A handler wired up just enough to run ``_handle_inbound_message``."""
|
|
35
|
+
h = HiveMindTornadoWebSocket.__new__(HiveMindTornadoWebSocket)
|
|
36
|
+
h.source_ip = "127.0.0.1"
|
|
37
|
+
h.hm_protocol = SimpleNamespace(handle_message=lambda msg, client: None)
|
|
38
|
+
return h
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(autouse=True)
|
|
42
|
+
def _reset_receive_logger():
|
|
43
|
+
"""Drop the cache and the logging singleton's handlers between tests."""
|
|
44
|
+
def reset():
|
|
45
|
+
name = f"{LOG.name} - {hwp.__name__}"
|
|
46
|
+
stale = logging.getLogger(name)
|
|
47
|
+
for handler in list(stale.handlers):
|
|
48
|
+
stale.removeHandler(handler)
|
|
49
|
+
handler.close()
|
|
50
|
+
LOG._loggers.pop(name, None)
|
|
51
|
+
hwp._RECEIVE_LOGGER = None
|
|
52
|
+
hwp._RECEIVE_LOGGER_KEY = None
|
|
53
|
+
reset()
|
|
54
|
+
yield
|
|
55
|
+
reset()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _deliver(handler, message):
|
|
59
|
+
handler.client = SimpleNamespace(decode=lambda raw: message, peer="tcp4:peer")
|
|
60
|
+
handler._handle_inbound_message("raw-frame")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_payload_not_rendered_when_debug_is_disabled(handler):
|
|
64
|
+
"""At INFO the envelope is never serialized — that is the whole point."""
|
|
65
|
+
hwp._receive_logger().setLevel(logging.INFO)
|
|
66
|
+
message = _CountingMessage()
|
|
67
|
+
|
|
68
|
+
_deliver(handler, message)
|
|
69
|
+
|
|
70
|
+
assert message.renders == 0, (
|
|
71
|
+
"the inbound envelope was serialized for a DEBUG record that was "
|
|
72
|
+
"discarded; pass it as a lazy argument instead of formatting it"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_payload_is_still_rendered_when_debug_is_enabled(handler):
|
|
77
|
+
"""Laziness must not cost the operator the debug output itself."""
|
|
78
|
+
hwp._receive_logger().setLevel(logging.DEBUG)
|
|
79
|
+
message = _CountingMessage()
|
|
80
|
+
|
|
81
|
+
_deliver(handler, message)
|
|
82
|
+
|
|
83
|
+
# ">= 1" not "== 1": every attached handler formats the record, and pytest
|
|
84
|
+
# adds its own capture handlers on top of the stdout one.
|
|
85
|
+
assert message.renders >= 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_b64_audio_frames_are_not_rendered(handler):
|
|
89
|
+
"""Audio frames are already special-cased; keep them that way."""
|
|
90
|
+
hwp._receive_logger().setLevel(logging.DEBUG)
|
|
91
|
+
message = _CountingMessage()
|
|
92
|
+
message.payload = SimpleNamespace(msg_type="recognizer_loop:b64_audio")
|
|
93
|
+
|
|
94
|
+
_deliver(handler, message)
|
|
95
|
+
|
|
96
|
+
assert message.renders == 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_receive_logger_is_resolved_once(handler):
|
|
100
|
+
"""One stack walk per process, not one per inbound frame."""
|
|
101
|
+
assert hwp._receive_logger() is hwp._receive_logger()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_receive_logger_follows_log_set_level():
|
|
105
|
+
"""Caching must not pin the level: LOG.init/set_level still retargets it."""
|
|
106
|
+
log = hwp._receive_logger()
|
|
107
|
+
assert log.name in LOG._loggers, (
|
|
108
|
+
"the receive logger must be registered with LOG so set_level reaches it"
|
|
109
|
+
)
|
|
110
|
+
previous = LOG.level
|
|
111
|
+
try:
|
|
112
|
+
LOG.set_level("DEBUG")
|
|
113
|
+
assert log.level == logging.DEBUG
|
|
114
|
+
LOG.set_level("WARNING")
|
|
115
|
+
assert log.level == logging.WARNING
|
|
116
|
+
finally:
|
|
117
|
+
LOG.set_level(previous)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_logger_rewires_after_log_init_changes_base_path(tmp_path):
|
|
121
|
+
"""init after first use must not strand this path on stdout-only."""
|
|
122
|
+
hwp._receive_logger()
|
|
123
|
+
previous = LOG.base_path
|
|
124
|
+
try:
|
|
125
|
+
LOG.base_path = str(tmp_path)
|
|
126
|
+
kinds = {type(h).__name__ for h in hwp._receive_logger().handlers}
|
|
127
|
+
assert "RotatingFileHandler" in kinds
|
|
128
|
+
finally:
|
|
129
|
+
LOG.base_path = previous
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_concurrent_first_use_attaches_handlers_once():
|
|
133
|
+
import threading
|
|
134
|
+
gate = threading.Event()
|
|
135
|
+
|
|
136
|
+
def resolve():
|
|
137
|
+
gate.wait()
|
|
138
|
+
hwp._receive_logger()
|
|
139
|
+
|
|
140
|
+
threads = [threading.Thread(target=resolve) for _ in range(8)]
|
|
141
|
+
for t in threads:
|
|
142
|
+
t.start()
|
|
143
|
+
gate.set()
|
|
144
|
+
for t in threads:
|
|
145
|
+
t.join(timeout=10)
|
|
146
|
+
|
|
147
|
+
streams = [h for h in hwp._receive_logger().handlers
|
|
148
|
+
if type(h).__name__ == "StreamHandler"]
|
|
149
|
+
assert len(streams) <= 1
|
|
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
|
{hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_client_ip.py
RENAMED
|
File without changes
|
|
File without changes
|
{hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|