ipguard-api 0.1.0b6__py3-none-any.whl
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.
- ipguard_api/__init__.py +63 -0
- ipguard_api/client.py +833 -0
- ipguard_api/errors.py +53 -0
- ipguard_api/models.py +205 -0
- ipguard_api/protocol.py +464 -0
- ipguard_api/py.typed +0 -0
- ipguard_api/transport.py +265 -0
- ipguard_api/validation.py +93 -0
- ipguard_api-0.1.0b6.dist-info/METADATA +47 -0
- ipguard_api-0.1.0b6.dist-info/RECORD +12 -0
- ipguard_api-0.1.0b6.dist-info/WHEEL +4 -0
- ipguard_api-0.1.0b6.dist-info/licenses/LICENSE +201 -0
ipguard_api/client.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
"""Authorized enrollment, pushed relay discovery and single-attempt release."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from struct import error as StructError
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Concatenate
|
|
13
|
+
from uuid import UUID, uuid4
|
|
14
|
+
|
|
15
|
+
from zmq.utils import z85
|
|
16
|
+
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
AuthRevokedError,
|
|
20
|
+
ConnectionFailureError,
|
|
21
|
+
ExistingInstallationError,
|
|
22
|
+
InvalidCodeError,
|
|
23
|
+
InvalidInputError,
|
|
24
|
+
IpguardError,
|
|
25
|
+
OutcomeUnknownError,
|
|
26
|
+
RelayUnavailableError,
|
|
27
|
+
ReleaseRejectedError,
|
|
28
|
+
)
|
|
29
|
+
from .models import (
|
|
30
|
+
DEFAULT_ACTIVATION_ENDPOINT,
|
|
31
|
+
DEFAULT_SECURE_ENDPOINT,
|
|
32
|
+
AndroidProfile,
|
|
33
|
+
AuthIdentifier,
|
|
34
|
+
Board,
|
|
35
|
+
ClientSnapshot,
|
|
36
|
+
IncomingCall,
|
|
37
|
+
Relay,
|
|
38
|
+
Session,
|
|
39
|
+
SessionStatus,
|
|
40
|
+
)
|
|
41
|
+
from .protocol import (
|
|
42
|
+
Packet,
|
|
43
|
+
configuration_password,
|
|
44
|
+
door_inventory,
|
|
45
|
+
envelope,
|
|
46
|
+
hangup_call,
|
|
47
|
+
hello,
|
|
48
|
+
identifier_fields,
|
|
49
|
+
incoming_call,
|
|
50
|
+
packet_body,
|
|
51
|
+
packet_event,
|
|
52
|
+
packet_sender,
|
|
53
|
+
)
|
|
54
|
+
from .transport import ZmqTransport
|
|
55
|
+
from .validation import (
|
|
56
|
+
validate_duration,
|
|
57
|
+
validate_identifier,
|
|
58
|
+
validate_installation_label,
|
|
59
|
+
validate_routing_identity,
|
|
60
|
+
validate_verification_code,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
type Listener = Callable[[ClientSnapshot], None]
|
|
64
|
+
type CallListener = Callable[[IncomingCall], None]
|
|
65
|
+
MAX_RECENT_CALLS = 256
|
|
66
|
+
RECENT_CALL_TTL = 15 * 60
|
|
67
|
+
AUTO_HANGUP_TTL = 5.0
|
|
68
|
+
_LOGGER = logging.getLogger(__name__)
|
|
69
|
+
_LOGGED_EVENTS = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"srv_sp_delete",
|
|
72
|
+
"bc_sp_delete_app",
|
|
73
|
+
"bc_sp_relays",
|
|
74
|
+
"srv_sp_update",
|
|
75
|
+
"srv_sp_check_auth_result",
|
|
76
|
+
"srv_sp_validate_auth_result",
|
|
77
|
+
"srv_sp_ask_key_result",
|
|
78
|
+
"srv_sp_check_username_result",
|
|
79
|
+
"brd_sp_open_door_ack",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(eq=False, repr=False, slots=True)
|
|
85
|
+
class _AutomaticHangup:
|
|
86
|
+
transport: ZmqTransport
|
|
87
|
+
token: str
|
|
88
|
+
remote: str
|
|
89
|
+
sender: str
|
|
90
|
+
deadline: float
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cancel_cleanup[**P, R](
|
|
94
|
+
method: Callable[Concatenate[IpguardClient, P], Awaitable[R]],
|
|
95
|
+
) -> Callable[Concatenate[IpguardClient, P], Awaitable[R]]:
|
|
96
|
+
@wraps(method)
|
|
97
|
+
async def wrapped(client: IpguardClient, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
98
|
+
try:
|
|
99
|
+
return await method(client, *args, **kwargs)
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
await client.async_close()
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
return wrapped
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IpguardClient:
|
|
108
|
+
"""Maintain one installation's cloud connection; getters perform no IO."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
timeout: float = 10.0,
|
|
113
|
+
*,
|
|
114
|
+
session: Session | None = None,
|
|
115
|
+
activation_endpoint: str = DEFAULT_ACTIVATION_ENDPOINT,
|
|
116
|
+
secure_endpoint: str = DEFAULT_SECURE_ENDPOINT,
|
|
117
|
+
reconnect_delay: float = 1.0,
|
|
118
|
+
max_reconnect_delay: float = 30.0,
|
|
119
|
+
auto_hangup: bool = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
if type(auto_hangup) is not bool:
|
|
122
|
+
raise InvalidInputError("Automatic hangup must be a boolean.")
|
|
123
|
+
self.timeout = validate_duration(timeout, 300)
|
|
124
|
+
reconnect_delay = validate_duration(reconnect_delay, 3600)
|
|
125
|
+
max_reconnect_delay = validate_duration(max_reconnect_delay, 3600)
|
|
126
|
+
if max_reconnect_delay < reconnect_delay:
|
|
127
|
+
raise InvalidInputError(
|
|
128
|
+
"The maximum reconnect delay must not be shorter than the initial delay."
|
|
129
|
+
)
|
|
130
|
+
self._session = (
|
|
131
|
+
replace(session, android_profile=AndroidProfile()) if session else None
|
|
132
|
+
)
|
|
133
|
+
self._activation_endpoint = (
|
|
134
|
+
session.activation_endpoint if session else activation_endpoint
|
|
135
|
+
)
|
|
136
|
+
self._secure_endpoint = session.secure_endpoint if session else secure_endpoint
|
|
137
|
+
self._reconnect_delay, self._max_reconnect_delay = (
|
|
138
|
+
reconnect_delay,
|
|
139
|
+
max_reconnect_delay,
|
|
140
|
+
)
|
|
141
|
+
self._transport: ZmqTransport | None = None
|
|
142
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
143
|
+
self._listeners: set[Listener] = set()
|
|
144
|
+
self._call_listeners: set[CallListener] = set()
|
|
145
|
+
self._call_tokens: OrderedDict[bytes, float] = OrderedDict()
|
|
146
|
+
self._call_boards: dict[str, Board] = {}
|
|
147
|
+
self._auto_hangup = auto_hangup
|
|
148
|
+
self._hangup_call: _AutomaticHangup | None = None
|
|
149
|
+
self._hangup_task: asyncio.Task[None] | None = None
|
|
150
|
+
self._relays: tuple[Relay, ...] = ()
|
|
151
|
+
self._connected = False
|
|
152
|
+
self._revoked = False
|
|
153
|
+
self._error: IpguardError | None = None
|
|
154
|
+
self._closed = False
|
|
155
|
+
self._inventory_ready = asyncio.Event()
|
|
156
|
+
self._configuration_ready = asyncio.Event()
|
|
157
|
+
self._failure_ready = asyncio.Event()
|
|
158
|
+
self._identifier: AuthIdentifier | None = None
|
|
159
|
+
self._identity = ""
|
|
160
|
+
self._label = ""
|
|
161
|
+
self._verification_requested = False
|
|
162
|
+
self._release_locks: dict[tuple[str, int], asyncio.Lock] = {}
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def connected(self) -> bool:
|
|
166
|
+
return self._connected
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def session(self) -> Session | None:
|
|
170
|
+
return self._session
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def snapshot(self) -> ClientSnapshot:
|
|
174
|
+
return ClientSnapshot(
|
|
175
|
+
self._relays, self._connected, self._revoked, self._error, self._session
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def subscribe(self, callback: Listener) -> Callable[[], None]:
|
|
179
|
+
"""Register a synchronous event-loop callback; return its removal function."""
|
|
180
|
+
self._listeners.add(callback)
|
|
181
|
+
return lambda: self._listeners.discard(callback)
|
|
182
|
+
|
|
183
|
+
def subscribe_calls(self, callback: CallListener) -> Callable[[], None]:
|
|
184
|
+
"""Subscribe to sanitized incoming offers.
|
|
185
|
+
|
|
186
|
+
Callbacks run synchronously on the event loop. Notifications are not
|
|
187
|
+
replayed to later subscribers. The removal function is idempotent.
|
|
188
|
+
Duplicate tokens are remembered across reconnects for 15 minutes after
|
|
189
|
+
their last receipt, subject to a limit of 256 recent tokens.
|
|
190
|
+
The default is passive; enabling auto_hangup sends one
|
|
191
|
+
hangup attempt after notification, without answering or negotiating media.
|
|
192
|
+
"""
|
|
193
|
+
self._call_listeners.add(callback)
|
|
194
|
+
return lambda: self._call_listeners.discard(callback)
|
|
195
|
+
|
|
196
|
+
def _notify(self) -> None:
|
|
197
|
+
snapshot = self.snapshot
|
|
198
|
+
for callback in tuple(self._listeners):
|
|
199
|
+
try:
|
|
200
|
+
callback(snapshot)
|
|
201
|
+
except Exception:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
@_cancel_cleanup
|
|
205
|
+
async def async_request_verification(self, identifier: str, label: str) -> None:
|
|
206
|
+
auth = validate_identifier(identifier)
|
|
207
|
+
label = validate_installation_label(label)
|
|
208
|
+
await self._stop_connection()
|
|
209
|
+
old = self._session
|
|
210
|
+
previous_identifier, previous_identity = self._identifier, self._identity
|
|
211
|
+
same_account = (
|
|
212
|
+
old is not None
|
|
213
|
+
and old.identifier.kind == auth.kind
|
|
214
|
+
and old.identifier.value.casefold() == auth.value.casefold()
|
|
215
|
+
)
|
|
216
|
+
same_pending = (
|
|
217
|
+
previous_identifier is not None
|
|
218
|
+
and previous_identifier.kind == auth.kind
|
|
219
|
+
and previous_identifier.value.casefold() == auth.value.casefold()
|
|
220
|
+
)
|
|
221
|
+
self._identity = (
|
|
222
|
+
old.identity
|
|
223
|
+
if same_account and old
|
|
224
|
+
else (
|
|
225
|
+
previous_identity
|
|
226
|
+
if same_pending and previous_identity
|
|
227
|
+
else str(uuid4())
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
if self._identity != (old.identity if old else previous_identity):
|
|
231
|
+
self._call_tokens.clear()
|
|
232
|
+
self._call_boards.clear()
|
|
233
|
+
self._identifier, self._label = auth, label
|
|
234
|
+
self._session = None
|
|
235
|
+
self._revoked, self._closed, self._error = False, False, None
|
|
236
|
+
self._verification_requested = False
|
|
237
|
+
self._relays = ()
|
|
238
|
+
_LOGGER.debug("IPGUARD stage verification_request started")
|
|
239
|
+
transport = await self._new_transport(secure=False)
|
|
240
|
+
await transport.async_send(hello(self._identity, auth, None))
|
|
241
|
+
await self._auth_request(
|
|
242
|
+
"sp_srv_check_auth", identifier_fields(auth), "srv_sp_check_auth_result"
|
|
243
|
+
)
|
|
244
|
+
self._verification_requested = True
|
|
245
|
+
|
|
246
|
+
@_cancel_cleanup
|
|
247
|
+
async def async_verify_code(self, code: str) -> None:
|
|
248
|
+
code = validate_verification_code(code)
|
|
249
|
+
if not self._verification_requested or self._identifier is None:
|
|
250
|
+
raise AuthenticationError(
|
|
251
|
+
"Request a verification code before verifying it."
|
|
252
|
+
)
|
|
253
|
+
if (
|
|
254
|
+
self._transport is None
|
|
255
|
+
or self._transport.closed
|
|
256
|
+
or self._transport.server_key is not None
|
|
257
|
+
):
|
|
258
|
+
transport = await self._new_transport(secure=False)
|
|
259
|
+
await transport.async_send(hello(self._identity, self._identifier, None))
|
|
260
|
+
fields = {**identifier_fields(self._identifier), "code": code}
|
|
261
|
+
_LOGGER.debug("IPGUARD stage verify_code started")
|
|
262
|
+
await self._auth_request(
|
|
263
|
+
"sp_srv_validate_auth", fields, "srv_sp_validate_auth_result"
|
|
264
|
+
)
|
|
265
|
+
body = await self._auth_request(
|
|
266
|
+
"sp_srv_ask_key", fields, "srv_sp_ask_key_result"
|
|
267
|
+
)
|
|
268
|
+
key = body.get("key")
|
|
269
|
+
if not isinstance(key, str) or not _valid_curve_key(key):
|
|
270
|
+
raise AuthenticationError("IPGUARD returned an invalid server public key.")
|
|
271
|
+
self._session = Session(
|
|
272
|
+
identity=self._identity,
|
|
273
|
+
identifier=self._identifier,
|
|
274
|
+
label=self._label,
|
|
275
|
+
verification_code=code,
|
|
276
|
+
server_public_key=key,
|
|
277
|
+
activation_endpoint=self._activation_endpoint,
|
|
278
|
+
secure_endpoint=self._secure_endpoint,
|
|
279
|
+
status=SessionStatus.PENDING,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@_cancel_cleanup
|
|
283
|
+
async def async_complete_registration(
|
|
284
|
+
self, *, replace_existing: bool = False
|
|
285
|
+
) -> Session:
|
|
286
|
+
session = self._session
|
|
287
|
+
if session is None or session.status is not SessionStatus.PENDING:
|
|
288
|
+
raise AuthenticationError("Verify a code before completing registration.")
|
|
289
|
+
if not isinstance(replace_existing, bool):
|
|
290
|
+
raise InvalidInputError("Replacement consent must be an explicit boolean.")
|
|
291
|
+
if (
|
|
292
|
+
self._transport is None
|
|
293
|
+
or self._transport.closed
|
|
294
|
+
or self._transport.server_key is None
|
|
295
|
+
):
|
|
296
|
+
transport = await self._new_transport(secure=True)
|
|
297
|
+
await transport.async_send(
|
|
298
|
+
hello(session.identity, session.identifier, session)
|
|
299
|
+
)
|
|
300
|
+
fields = {
|
|
301
|
+
**identifier_fields(session.identifier),
|
|
302
|
+
"code": session.verification_code,
|
|
303
|
+
"username": validate_installation_label(session.label),
|
|
304
|
+
"model": AndroidProfile().model,
|
|
305
|
+
}
|
|
306
|
+
_LOGGER.debug("IPGUARD stage registration started")
|
|
307
|
+
result = await self._register("sp_srv_check_username", fields)
|
|
308
|
+
if isinstance(result, int) and not isinstance(result, bool) and result == 0:
|
|
309
|
+
if not replace_existing:
|
|
310
|
+
raise ExistingInstallationError(
|
|
311
|
+
"Registration requires consent to replace an existing mobile installation."
|
|
312
|
+
)
|
|
313
|
+
result = await self._register("sp_srv_override_username", fields)
|
|
314
|
+
if (
|
|
315
|
+
not isinstance(result, int)
|
|
316
|
+
or isinstance(result, bool)
|
|
317
|
+
or result not in (1, 2)
|
|
318
|
+
):
|
|
319
|
+
raise AuthenticationError("IPGUARD rejected installation registration.")
|
|
320
|
+
await self._wait_ready(self._configuration_ready, "activation configuration")
|
|
321
|
+
await self._wait_ready(self._inventory_ready, "relay inventory")
|
|
322
|
+
assert self._session is not None
|
|
323
|
+
self._session = replace(self._session, status=SessionStatus.ACTIVATED)
|
|
324
|
+
self._connected, self._error = True, None
|
|
325
|
+
self._notify()
|
|
326
|
+
return self._session
|
|
327
|
+
|
|
328
|
+
@_cancel_cleanup
|
|
329
|
+
async def async_connect(self, session: Session) -> None:
|
|
330
|
+
validate_installation_label(session.label)
|
|
331
|
+
validate_verification_code(session.verification_code)
|
|
332
|
+
auth = validate_identifier(session.identifier.value)
|
|
333
|
+
try:
|
|
334
|
+
UUID(session.identity)
|
|
335
|
+
except ValueError, AttributeError:
|
|
336
|
+
raise InvalidInputError("Invalid saved installation identity.") from None
|
|
337
|
+
if (
|
|
338
|
+
auth != session.identifier
|
|
339
|
+
or not _valid_curve_key(session.server_public_key)
|
|
340
|
+
or not session.sip_password
|
|
341
|
+
):
|
|
342
|
+
raise AuthenticationError("Saved installation authorization is incomplete.")
|
|
343
|
+
if session.status is SessionStatus.DISABLED:
|
|
344
|
+
raise AuthRevokedError(
|
|
345
|
+
"This installation requires a new user-driven enrollment."
|
|
346
|
+
)
|
|
347
|
+
if session.status is not SessionStatus.ACTIVATED:
|
|
348
|
+
raise AuthenticationError("Saved installation enrollment is incomplete.")
|
|
349
|
+
await self._stop_connection()
|
|
350
|
+
if self._session is None or self._session.identity != session.identity:
|
|
351
|
+
self._call_tokens.clear()
|
|
352
|
+
self._call_boards.clear()
|
|
353
|
+
self._closed, self._revoked, self._error = False, False, None
|
|
354
|
+
self._session = replace(session, android_profile=AndroidProfile())
|
|
355
|
+
self._identity, self._identifier, self._label = (
|
|
356
|
+
session.identity,
|
|
357
|
+
auth,
|
|
358
|
+
session.label,
|
|
359
|
+
)
|
|
360
|
+
self._activation_endpoint, self._secure_endpoint = (
|
|
361
|
+
session.activation_endpoint,
|
|
362
|
+
session.secure_endpoint,
|
|
363
|
+
)
|
|
364
|
+
await self._connect_saved()
|
|
365
|
+
|
|
366
|
+
async def _connect_saved(self) -> None:
|
|
367
|
+
assert self._session is not None
|
|
368
|
+
transport = await self._new_transport(secure=True)
|
|
369
|
+
await transport.async_send(
|
|
370
|
+
hello(self._session.identity, self._session.identifier, self._session)
|
|
371
|
+
)
|
|
372
|
+
await self._wait_ready(self._inventory_ready, "relay inventory")
|
|
373
|
+
if self._revoked:
|
|
374
|
+
raise AuthRevokedError("IPGUARD revoked this installation.")
|
|
375
|
+
self._connected, self._error = True, None
|
|
376
|
+
self._notify()
|
|
377
|
+
|
|
378
|
+
async def async_get_relays(self) -> tuple[Relay, ...]:
|
|
379
|
+
self._require_connected()
|
|
380
|
+
return self._relays
|
|
381
|
+
|
|
382
|
+
async def async_release(self, board_identity: str, relay: int) -> None:
|
|
383
|
+
validate_routing_identity(board_identity)
|
|
384
|
+
if (
|
|
385
|
+
not isinstance(board_identity, str)
|
|
386
|
+
or not board_identity
|
|
387
|
+
or isinstance(relay, bool)
|
|
388
|
+
or not isinstance(relay, int)
|
|
389
|
+
or not 1 <= relay <= 3
|
|
390
|
+
):
|
|
391
|
+
raise InvalidInputError(
|
|
392
|
+
"Select a board identity and a one-based relay number from 1 to 3."
|
|
393
|
+
)
|
|
394
|
+
self._require_release_permission(board_identity, relay)
|
|
395
|
+
lock = self._release_locks.setdefault((board_identity, relay), asyncio.Lock())
|
|
396
|
+
if lock.locked():
|
|
397
|
+
raise RelayUnavailableError(
|
|
398
|
+
"A release request is already in progress for this relay."
|
|
399
|
+
)
|
|
400
|
+
try:
|
|
401
|
+
async with lock:
|
|
402
|
+
self._require_release_permission(board_identity, relay)
|
|
403
|
+
transport, session = self._transport, self._session
|
|
404
|
+
assert transport is not None and session is not None
|
|
405
|
+
packet = envelope(session.identity, "sp_brd_open_door", {"door": relay})
|
|
406
|
+
token = packet_body(packet)["token"]
|
|
407
|
+
attempted = False
|
|
408
|
+
|
|
409
|
+
def before_send() -> None:
|
|
410
|
+
nonlocal attempted
|
|
411
|
+
self._require_release_permission(board_identity, relay)
|
|
412
|
+
if self._transport is not transport:
|
|
413
|
+
raise ConnectionFailureError(
|
|
414
|
+
"The connection changed; no release was sent."
|
|
415
|
+
)
|
|
416
|
+
attempted = True
|
|
417
|
+
|
|
418
|
+
_LOGGER.debug("IPGUARD stage release started")
|
|
419
|
+
try:
|
|
420
|
+
reply = await transport.async_request(
|
|
421
|
+
packet,
|
|
422
|
+
lambda p: (
|
|
423
|
+
packet_event(p) == "brd_sp_open_door_ack"
|
|
424
|
+
and packet_body(p).get("token") == token
|
|
425
|
+
and packet_sender(p) == board_identity
|
|
426
|
+
),
|
|
427
|
+
board_identity,
|
|
428
|
+
before_send=before_send,
|
|
429
|
+
)
|
|
430
|
+
except ConnectionFailureError:
|
|
431
|
+
if not attempted:
|
|
432
|
+
raise
|
|
433
|
+
raise OutcomeUnknownError(
|
|
434
|
+
"Release outcome is unknown; the command was not retried."
|
|
435
|
+
) from None
|
|
436
|
+
body = packet_body(reply)
|
|
437
|
+
if "result" in body and (
|
|
438
|
+
isinstance(body["result"], bool)
|
|
439
|
+
or not isinstance(body["result"], int)
|
|
440
|
+
or body["result"] != 1
|
|
441
|
+
):
|
|
442
|
+
raise ReleaseRejectedError(
|
|
443
|
+
"The board rejected the release request."
|
|
444
|
+
)
|
|
445
|
+
status = body.get("ble_frame_status")
|
|
446
|
+
if status and (
|
|
447
|
+
not isinstance(status, str) or not status.startswith("_teOK_")
|
|
448
|
+
):
|
|
449
|
+
raise ReleaseRejectedError(
|
|
450
|
+
"The board reported an unsuccessful release acknowledgement."
|
|
451
|
+
)
|
|
452
|
+
finally:
|
|
453
|
+
if self._release_locks.get((board_identity, relay)) is lock:
|
|
454
|
+
self._release_locks.pop((board_identity, relay))
|
|
455
|
+
|
|
456
|
+
def _require_release_permission(self, board_identity: str, relay: int) -> None:
|
|
457
|
+
self._require_connected()
|
|
458
|
+
selected = next(
|
|
459
|
+
(
|
|
460
|
+
item
|
|
461
|
+
for item in self._relays
|
|
462
|
+
if item.board_identity == board_identity and item.number == relay
|
|
463
|
+
),
|
|
464
|
+
None,
|
|
465
|
+
)
|
|
466
|
+
if (
|
|
467
|
+
selected is None
|
|
468
|
+
or not selected.remote_allowed
|
|
469
|
+
or selected.pending_confirmation
|
|
470
|
+
):
|
|
471
|
+
raise RelayUnavailableError(
|
|
472
|
+
"This relay is not currently permitted for remote release."
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def _require_connected(self) -> None:
|
|
476
|
+
if self._revoked:
|
|
477
|
+
raise AuthRevokedError("IPGUARD revoked this activated installation.")
|
|
478
|
+
if not self._connected or self._transport is None:
|
|
479
|
+
raise ConnectionFailureError(
|
|
480
|
+
"IPGUARD is not connected; no release was attempted."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
async def _auth_request(
|
|
484
|
+
self, event: str, fields: dict[str, object], response: str
|
|
485
|
+
) -> dict[str, object]:
|
|
486
|
+
if self._transport is None:
|
|
487
|
+
raise ConnectionFailureError("IPGUARD is not connected.")
|
|
488
|
+
packet = envelope(self._identity, event, fields)
|
|
489
|
+
token = packet_body(packet)["token"]
|
|
490
|
+
reply = await self._transport.async_request(
|
|
491
|
+
packet,
|
|
492
|
+
lambda p: (
|
|
493
|
+
packet_event(p) == response and packet_body(p).get("token") == token
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
body = packet_body(reply)
|
|
497
|
+
result = body.get("result")
|
|
498
|
+
if isinstance(result, bool) or not isinstance(result, int) or result != 1:
|
|
499
|
+
if (
|
|
500
|
+
event == "sp_srv_validate_auth"
|
|
501
|
+
and isinstance(result, int)
|
|
502
|
+
and not isinstance(result, bool)
|
|
503
|
+
and result == 0
|
|
504
|
+
):
|
|
505
|
+
raise InvalidCodeError("IPGUARD rejected the verification code.")
|
|
506
|
+
raise AuthenticationError("IPGUARD rejected the authorization request.")
|
|
507
|
+
return body
|
|
508
|
+
|
|
509
|
+
async def _register(self, event: str, fields: dict[str, object]) -> object:
|
|
510
|
+
assert self._transport is not None
|
|
511
|
+
packet = envelope(self._identity, event, fields)
|
|
512
|
+
token = packet_body(packet)["token"]
|
|
513
|
+
reply = await self._transport.async_request(
|
|
514
|
+
packet,
|
|
515
|
+
lambda p: (
|
|
516
|
+
(
|
|
517
|
+
packet_event(p) == "srv_sp_check_username_result"
|
|
518
|
+
and packet_body(p).get("token") == token
|
|
519
|
+
)
|
|
520
|
+
or (
|
|
521
|
+
packet_event(p) == "srv_sp_update"
|
|
522
|
+
and self._configuration_ready.is_set()
|
|
523
|
+
)
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
return (
|
|
527
|
+
2
|
|
528
|
+
if packet_event(reply) == "srv_sp_update"
|
|
529
|
+
else packet_body(reply).get("result")
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
async def _new_transport(self, *, secure: bool) -> ZmqTransport:
|
|
533
|
+
await self._stop_auto_hangup()
|
|
534
|
+
if self._transport is not None:
|
|
535
|
+
await self._transport.async_close()
|
|
536
|
+
self._connected = False
|
|
537
|
+
self._inventory_ready.clear()
|
|
538
|
+
self._error = None
|
|
539
|
+
self._failure_ready.clear()
|
|
540
|
+
self._configuration_ready.clear()
|
|
541
|
+
key = self._session.server_public_key if secure and self._session else None
|
|
542
|
+
|
|
543
|
+
async def receive(packet: Packet) -> None:
|
|
544
|
+
if packet_event(packet) == "ps_sp_sdp_offer":
|
|
545
|
+
self._receive_call(packet, transport)
|
|
546
|
+
else:
|
|
547
|
+
self._receive_call_termination(packet, transport)
|
|
548
|
+
await self._on_packet(packet)
|
|
549
|
+
|
|
550
|
+
transport = ZmqTransport(
|
|
551
|
+
self._identity,
|
|
552
|
+
self._secure_endpoint if secure else self._activation_endpoint,
|
|
553
|
+
self.timeout,
|
|
554
|
+
receive,
|
|
555
|
+
self._on_failure,
|
|
556
|
+
key,
|
|
557
|
+
)
|
|
558
|
+
self._transport = transport
|
|
559
|
+
await transport.async_start()
|
|
560
|
+
return transport
|
|
561
|
+
|
|
562
|
+
def _call_transport_current(self, transport: ZmqTransport) -> bool:
|
|
563
|
+
session = self._session
|
|
564
|
+
if (
|
|
565
|
+
self._closed
|
|
566
|
+
or self._revoked
|
|
567
|
+
or self._error is not None
|
|
568
|
+
or self._transport is not transport
|
|
569
|
+
or transport.closed
|
|
570
|
+
or session is None
|
|
571
|
+
or session.status is not SessionStatus.ACTIVATED
|
|
572
|
+
or transport.server_key is None
|
|
573
|
+
or transport.server_key != session.server_public_key
|
|
574
|
+
or transport.identity != session.identity
|
|
575
|
+
):
|
|
576
|
+
return False
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
def _receive_call(self, packet: Packet, transport: ZmqTransport) -> None:
|
|
580
|
+
if not self._call_transport_current(transport):
|
|
581
|
+
return
|
|
582
|
+
parsed = incoming_call(packet, self._call_boards)
|
|
583
|
+
if parsed is None:
|
|
584
|
+
return
|
|
585
|
+
notification, token_hash = parsed
|
|
586
|
+
now = monotonic()
|
|
587
|
+
while (
|
|
588
|
+
self._call_tokens
|
|
589
|
+
and next(iter(self._call_tokens.values())) <= now - RECENT_CALL_TTL
|
|
590
|
+
):
|
|
591
|
+
self._call_tokens.popitem(last=False)
|
|
592
|
+
if token_hash in self._call_tokens:
|
|
593
|
+
self._call_tokens[token_hash] = now
|
|
594
|
+
self._call_tokens.move_to_end(token_hash)
|
|
595
|
+
return
|
|
596
|
+
self._call_tokens[token_hash] = now
|
|
597
|
+
if len(self._call_tokens) > MAX_RECENT_CALLS:
|
|
598
|
+
self._call_tokens.popitem(last=False)
|
|
599
|
+
self._invalidate_auto_hangup()
|
|
600
|
+
for callback in tuple(self._call_listeners):
|
|
601
|
+
try:
|
|
602
|
+
callback(notification)
|
|
603
|
+
except Exception:
|
|
604
|
+
# Callback errors may contain private downstream data.
|
|
605
|
+
continue
|
|
606
|
+
if self._auto_hangup:
|
|
607
|
+
self._schedule_auto_hangup(packet, transport, now)
|
|
608
|
+
|
|
609
|
+
def _schedule_auto_hangup(
|
|
610
|
+
self, packet: Packet, transport: ZmqTransport, received: float
|
|
611
|
+
) -> None:
|
|
612
|
+
if self._hangup_task is not None and not self._hangup_task.done():
|
|
613
|
+
_LOGGER.debug("IPGUARD auto hangup outcome=skipped")
|
|
614
|
+
return
|
|
615
|
+
body = packet_body(packet)
|
|
616
|
+
token, remote, sender = (
|
|
617
|
+
body.get("token"),
|
|
618
|
+
body.get("remoteIdentity"),
|
|
619
|
+
packet_sender(packet),
|
|
620
|
+
)
|
|
621
|
+
# These fields were validated together with the discarded description.
|
|
622
|
+
assert isinstance(token, str)
|
|
623
|
+
assert isinstance(remote, str)
|
|
624
|
+
assert isinstance(sender, str)
|
|
625
|
+
call = self._hangup_call = _AutomaticHangup(
|
|
626
|
+
transport, token, remote, sender, received + AUTO_HANGUP_TTL
|
|
627
|
+
)
|
|
628
|
+
self._hangup_task = asyncio.create_task(
|
|
629
|
+
self._send_auto_hangup(call), name="ipguard-auto-hangup"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
async def _send_auto_hangup(self, call: _AutomaticHangup) -> None:
|
|
633
|
+
attempted = False
|
|
634
|
+
|
|
635
|
+
def before_send() -> None:
|
|
636
|
+
nonlocal attempted
|
|
637
|
+
if (
|
|
638
|
+
self._hangup_call is not call
|
|
639
|
+
or not self._connected
|
|
640
|
+
or not self._call_transport_current(call.transport)
|
|
641
|
+
or monotonic() >= call.deadline
|
|
642
|
+
):
|
|
643
|
+
raise ConnectionFailureError("The incoming call is no longer current.")
|
|
644
|
+
attempted = True
|
|
645
|
+
_LOGGER.debug("IPGUARD auto hangup outcome=attempted")
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
async with asyncio.timeout(min(self.timeout, AUTO_HANGUP_TTL)):
|
|
649
|
+
await call.transport.async_send(
|
|
650
|
+
hangup_call(call.transport.identity, call.token),
|
|
651
|
+
call.remote,
|
|
652
|
+
before_send=before_send,
|
|
653
|
+
)
|
|
654
|
+
_LOGGER.debug("IPGUARD auto hangup outcome=sent")
|
|
655
|
+
except asyncio.CancelledError:
|
|
656
|
+
_LOGGER.debug(
|
|
657
|
+
"IPGUARD auto hangup outcome=%s",
|
|
658
|
+
"uncertain" if attempted else "skipped",
|
|
659
|
+
)
|
|
660
|
+
raise
|
|
661
|
+
except Exception:
|
|
662
|
+
_LOGGER.debug(
|
|
663
|
+
"IPGUARD auto hangup outcome=%s",
|
|
664
|
+
"uncertain" if attempted else "skipped",
|
|
665
|
+
)
|
|
666
|
+
finally:
|
|
667
|
+
if self._hangup_call is call:
|
|
668
|
+
self._hangup_call = None
|
|
669
|
+
|
|
670
|
+
def _receive_call_termination(
|
|
671
|
+
self, packet: Packet, transport: ZmqTransport
|
|
672
|
+
) -> None:
|
|
673
|
+
call = self._hangup_call
|
|
674
|
+
if call is None or transport is not call.transport:
|
|
675
|
+
return
|
|
676
|
+
event, body, sender = (
|
|
677
|
+
packet_event(packet),
|
|
678
|
+
packet_body(packet),
|
|
679
|
+
packet_sender(packet),
|
|
680
|
+
)
|
|
681
|
+
expected = {
|
|
682
|
+
"ps_sp_hangup_call": call.sender,
|
|
683
|
+
"brd_sp_hangup_call": call.remote,
|
|
684
|
+
}.get(event)
|
|
685
|
+
if (
|
|
686
|
+
expected is not None
|
|
687
|
+
and sender == expected
|
|
688
|
+
and body.get("token") == call.token
|
|
689
|
+
and ("remoteIdentity" not in body or body["remoteIdentity"] == call.remote)
|
|
690
|
+
):
|
|
691
|
+
self._invalidate_auto_hangup()
|
|
692
|
+
|
|
693
|
+
def _invalidate_auto_hangup(self) -> None:
|
|
694
|
+
self._hangup_call = None
|
|
695
|
+
if self._hangup_task is not None and not self._hangup_task.done():
|
|
696
|
+
self._hangup_task.cancel()
|
|
697
|
+
|
|
698
|
+
async def _stop_auto_hangup(self) -> None:
|
|
699
|
+
self._invalidate_auto_hangup()
|
|
700
|
+
task, self._hangup_task = self._hangup_task, None
|
|
701
|
+
if task is not None and task is not asyncio.current_task():
|
|
702
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
703
|
+
|
|
704
|
+
async def _on_packet(self, packet: Packet) -> None:
|
|
705
|
+
event, body = packet_event(packet), packet_body(packet)
|
|
706
|
+
_LOGGER.debug(
|
|
707
|
+
"IPGUARD received event %s", event if event in _LOGGED_EVENTS else "other"
|
|
708
|
+
)
|
|
709
|
+
if event in ("srv_sp_delete", "bc_sp_delete_app"):
|
|
710
|
+
if self._session is None or self._session.status is SessionStatus.PENDING:
|
|
711
|
+
return
|
|
712
|
+
self._session = replace(self._session, status=SessionStatus.DISABLED)
|
|
713
|
+
raise AuthRevokedError("IPGUARD revoked this activated installation.")
|
|
714
|
+
if event == "bc_sp_relays":
|
|
715
|
+
boards, self._relays = door_inventory(body)
|
|
716
|
+
unique: dict[str, Board] = {}
|
|
717
|
+
ambiguous: set[str] = set()
|
|
718
|
+
for board in boards:
|
|
719
|
+
if board.identity in unique:
|
|
720
|
+
ambiguous.add(board.identity)
|
|
721
|
+
unique[board.identity] = board
|
|
722
|
+
self._call_boards = {
|
|
723
|
+
identity: board
|
|
724
|
+
for identity, board in unique.items()
|
|
725
|
+
if identity not in ambiguous
|
|
726
|
+
}
|
|
727
|
+
self._inventory_ready.set()
|
|
728
|
+
self._notify()
|
|
729
|
+
if event == "srv_sp_update" and isinstance(body.get("token"), str):
|
|
730
|
+
password = configuration_password(packet)
|
|
731
|
+
if password and self._session:
|
|
732
|
+
self._session = replace(self._session, sip_password=password)
|
|
733
|
+
self._configuration_ready.set()
|
|
734
|
+
assert self._transport is not None
|
|
735
|
+
await self._transport.async_send(
|
|
736
|
+
envelope(self._identity, "sp_srv_update_ack", {}, str(body["token"]))
|
|
737
|
+
)
|
|
738
|
+
self._notify()
|
|
739
|
+
|
|
740
|
+
async def _wait_ready(self, event: asyncio.Event, description: str) -> None:
|
|
741
|
+
ready = asyncio.create_task(event.wait())
|
|
742
|
+
failed = asyncio.create_task(self._failure_ready.wait())
|
|
743
|
+
try:
|
|
744
|
+
async with asyncio.timeout(self.timeout):
|
|
745
|
+
await asyncio.wait((ready, failed), return_when=asyncio.FIRST_COMPLETED)
|
|
746
|
+
except TimeoutError:
|
|
747
|
+
raise ConnectionFailureError(
|
|
748
|
+
f"Timed out waiting for {description}."
|
|
749
|
+
) from None
|
|
750
|
+
finally:
|
|
751
|
+
for task in (ready, failed):
|
|
752
|
+
task.cancel()
|
|
753
|
+
await asyncio.gather(ready, failed, return_exceptions=True)
|
|
754
|
+
if self._error is not None:
|
|
755
|
+
raise self._error
|
|
756
|
+
|
|
757
|
+
def _on_failure(self, error: IpguardError) -> None:
|
|
758
|
+
self._invalidate_auto_hangup()
|
|
759
|
+
self._connected, self._error = False, error
|
|
760
|
+
self._failure_ready.set()
|
|
761
|
+
self._revoked = isinstance(error, AuthRevokedError)
|
|
762
|
+
_LOGGER.debug("IPGUARD connection stopped: %s", type(error).__name__)
|
|
763
|
+
self._notify()
|
|
764
|
+
if (
|
|
765
|
+
not self._closed
|
|
766
|
+
and not self._revoked
|
|
767
|
+
and self._session
|
|
768
|
+
and self._session.status is SessionStatus.ACTIVATED
|
|
769
|
+
and self._reconnect_task is None
|
|
770
|
+
):
|
|
771
|
+
self._reconnect_task = asyncio.create_task(
|
|
772
|
+
self._reconnect(), name="ipguard-reconnect"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
async def _reconnect(self) -> None:
|
|
776
|
+
delay = self._reconnect_delay
|
|
777
|
+
try:
|
|
778
|
+
while not self._closed and not self._revoked:
|
|
779
|
+
_LOGGER.debug("IPGUARD reconnect scheduled")
|
|
780
|
+
await asyncio.sleep(delay)
|
|
781
|
+
try:
|
|
782
|
+
await self._connect_saved()
|
|
783
|
+
return
|
|
784
|
+
except AuthRevokedError:
|
|
785
|
+
return
|
|
786
|
+
except IpguardError as error:
|
|
787
|
+
self._connected, self._error = False, error
|
|
788
|
+
self._notify()
|
|
789
|
+
delay = min(delay * 2, self._max_reconnect_delay)
|
|
790
|
+
finally:
|
|
791
|
+
self._reconnect_task = None
|
|
792
|
+
|
|
793
|
+
async def _stop_connection(self) -> None:
|
|
794
|
+
self._closed = True
|
|
795
|
+
await self._stop_auto_hangup()
|
|
796
|
+
task, self._reconnect_task = self._reconnect_task, None
|
|
797
|
+
if task and task is not asyncio.current_task():
|
|
798
|
+
task.cancel()
|
|
799
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
800
|
+
transport, self._transport = self._transport, None
|
|
801
|
+
if transport:
|
|
802
|
+
await transport.async_close()
|
|
803
|
+
self._connected = False
|
|
804
|
+
|
|
805
|
+
async def async_close(self) -> None:
|
|
806
|
+
await self._stop_connection()
|
|
807
|
+
self._notify()
|
|
808
|
+
self._listeners.clear()
|
|
809
|
+
self._call_listeners.clear()
|
|
810
|
+
self._call_tokens.clear()
|
|
811
|
+
self._call_boards.clear()
|
|
812
|
+
self._verification_requested = False
|
|
813
|
+
self._identifier, self._identity, self._label = None, "", ""
|
|
814
|
+
if self._session and self._session.status is SessionStatus.PENDING:
|
|
815
|
+
self._session = None
|
|
816
|
+
|
|
817
|
+
async def __aenter__(self) -> IpguardClient:
|
|
818
|
+
return self
|
|
819
|
+
|
|
820
|
+
async def __aexit__(
|
|
821
|
+
self,
|
|
822
|
+
exc_type: type[BaseException] | None,
|
|
823
|
+
exc_value: BaseException | None,
|
|
824
|
+
traceback: TracebackType | None,
|
|
825
|
+
) -> None:
|
|
826
|
+
await self.async_close()
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _valid_curve_key(key: str) -> bool:
|
|
830
|
+
try:
|
|
831
|
+
return len(key) == 40 and len(z85.decode(key.encode("ascii"))) == 32
|
|
832
|
+
except KeyError, ValueError, UnicodeError, StructError, OverflowError:
|
|
833
|
+
return False
|