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/protocol.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Message shapes established by the IPGUARD 3.4.8 Android package."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from binascii import Error as Base64Error
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from unicodedata import category
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .errors import InvalidInputError, ProtocolError
|
|
14
|
+
from .models import (
|
|
15
|
+
AndroidProfile,
|
|
16
|
+
AuthIdentifier,
|
|
17
|
+
Board,
|
|
18
|
+
IncomingCall,
|
|
19
|
+
Relay,
|
|
20
|
+
Session,
|
|
21
|
+
SessionStatus,
|
|
22
|
+
)
|
|
23
|
+
from .validation import (
|
|
24
|
+
validate_identifier,
|
|
25
|
+
validate_installation_label,
|
|
26
|
+
validate_routing_identity,
|
|
27
|
+
validate_utf8,
|
|
28
|
+
validate_verification_code,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
type Packet = dict[str, object]
|
|
32
|
+
MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
33
|
+
MAX_MESSAGE_FRAMES = 16
|
|
34
|
+
MAX_INVENTORY_SITES = 256
|
|
35
|
+
MAX_INVENTORY_BOARDS = 1024
|
|
36
|
+
MAX_CALL_TOKEN_BYTES = 1024
|
|
37
|
+
MAX_CALL_SDP_BYTES = 256 * 1024
|
|
38
|
+
MAX_CALL_METADATA_BYTES = 256
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def envelope(
|
|
42
|
+
identity: str, event: str, body: Mapping[str, object], token: str | None = None
|
|
43
|
+
) -> Packet:
|
|
44
|
+
return {
|
|
45
|
+
"header": {
|
|
46
|
+
"event": event,
|
|
47
|
+
"sender": identity,
|
|
48
|
+
"timestamp": time.time_ns() // 1_000_000,
|
|
49
|
+
},
|
|
50
|
+
"body": {**body, "token": str(uuid4()) if token is None else token},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hangup_call(identity: str, token: str) -> Packet:
|
|
55
|
+
"""Build the single supported hangup shape with the original call token."""
|
|
56
|
+
if not isinstance(token, str):
|
|
57
|
+
raise InvalidInputError("Invalid call token.")
|
|
58
|
+
packet = envelope(identity, "sp_brd_hangup_call", {"cause": 0}, token)
|
|
59
|
+
assert_android_metadata(packet)
|
|
60
|
+
return packet
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hello(identity: str, identifier: AuthIdentifier, session: Session | None) -> Packet:
|
|
64
|
+
# Fixed handset metadata also replaces obsolete saved implementation labels.
|
|
65
|
+
profile = AndroidProfile()
|
|
66
|
+
offset = datetime.now().astimezone().utcoffset()
|
|
67
|
+
data: dict[str, object] = {
|
|
68
|
+
"fcm_token": "",
|
|
69
|
+
"without_sip": True,
|
|
70
|
+
"os_version": profile.os_version,
|
|
71
|
+
"language": profile.language,
|
|
72
|
+
"timezone": int(offset.total_seconds()) if offset else 0,
|
|
73
|
+
"weektypes": [[], [], [], [], [], [], []],
|
|
74
|
+
"connectionInfo": profile.connection_info,
|
|
75
|
+
}
|
|
76
|
+
if session is not None:
|
|
77
|
+
label = validate_installation_label(session.label)
|
|
78
|
+
data.update(
|
|
79
|
+
{
|
|
80
|
+
"username": label if session.status is SessionStatus.ACTIVATED else "",
|
|
81
|
+
"phone": identifier.value if identifier.kind.value == "phone" else "",
|
|
82
|
+
"email": identifier.value if identifier.kind.value == "email" else "",
|
|
83
|
+
"akey": "",
|
|
84
|
+
"code": validate_verification_code(session.verification_code),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return envelope(
|
|
88
|
+
identity, "hello", {"type": 0, "version": profile.app_version, "data": data}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def identifier_fields(identifier: AuthIdentifier) -> dict[str, object]:
|
|
93
|
+
return {identifier.kind.value: identifier.value}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def packet_event(packet: Packet) -> str:
|
|
97
|
+
header = packet.get("header")
|
|
98
|
+
return str(header.get("event", "")) if isinstance(header, dict) else ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def packet_body(packet: Packet) -> dict[str, object]:
|
|
102
|
+
body = packet.get("body")
|
|
103
|
+
return body if isinstance(body, dict) else {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def packet_sender(packet: Packet) -> object:
|
|
107
|
+
header = packet.get("header")
|
|
108
|
+
return header.get("sender") if isinstance(header, dict) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def incoming_call(
|
|
112
|
+
packet: Packet, boards: Mapping[str, Board]
|
|
113
|
+
) -> tuple[IncomingCall, bytes] | None:
|
|
114
|
+
"""Validate an offer and return only its safe notification and token digest.
|
|
115
|
+
|
|
116
|
+
Authentication and current-transport checks belong to the caller. The SDP
|
|
117
|
+
is bounded, Base64-decoded and checked as UTF-8; no media session is created.
|
|
118
|
+
Malformed offers are ignored without raising credential-bearing errors.
|
|
119
|
+
"""
|
|
120
|
+
if packet_event(packet) != "ps_sp_sdp_offer":
|
|
121
|
+
return None
|
|
122
|
+
body = packet_body(packet)
|
|
123
|
+
sender, remote, token, sdp = (
|
|
124
|
+
packet_sender(packet),
|
|
125
|
+
body.get("remoteIdentity"),
|
|
126
|
+
body.get("token"),
|
|
127
|
+
body.get("sdp"),
|
|
128
|
+
)
|
|
129
|
+
if not all(isinstance(value, str) for value in (sender, remote, token, sdp)):
|
|
130
|
+
return None
|
|
131
|
+
assert isinstance(sender, str) and isinstance(remote, str)
|
|
132
|
+
assert isinstance(token, str) and isinstance(sdp, str)
|
|
133
|
+
try:
|
|
134
|
+
validate_routing_identity(sender)
|
|
135
|
+
validate_routing_identity(remote)
|
|
136
|
+
encoded_token = validate_utf8(
|
|
137
|
+
token, MAX_CALL_TOKEN_BYTES, "Invalid call token."
|
|
138
|
+
)
|
|
139
|
+
if (
|
|
140
|
+
not sender.strip()
|
|
141
|
+
or not remote.strip()
|
|
142
|
+
or not token.strip()
|
|
143
|
+
or any(
|
|
144
|
+
ord(char) < 32 for value in (sender, remote, token) for char in value
|
|
145
|
+
)
|
|
146
|
+
or not sdp
|
|
147
|
+
or len(sdp) > MAX_CALL_SDP_BYTES
|
|
148
|
+
):
|
|
149
|
+
return None
|
|
150
|
+
description = base64.b64decode(sdp.encode("ascii"), validate=True).decode(
|
|
151
|
+
"utf-8"
|
|
152
|
+
)
|
|
153
|
+
if (
|
|
154
|
+
not description
|
|
155
|
+
or "\0" in description
|
|
156
|
+
or description.splitlines()[0] != "v=0"
|
|
157
|
+
):
|
|
158
|
+
return None
|
|
159
|
+
except InvalidInputError, UnicodeError, Base64Error:
|
|
160
|
+
return None
|
|
161
|
+
board = boards.get(remote)
|
|
162
|
+
if board is not None:
|
|
163
|
+
# Only the call projection is sanitized; release inventory is unchanged.
|
|
164
|
+
board = Board(
|
|
165
|
+
identity=board.identity,
|
|
166
|
+
name=_call_metadata(board.name),
|
|
167
|
+
site_id=_call_metadata(board.site_id) or None,
|
|
168
|
+
site_name=_call_metadata(board.site_name) or None,
|
|
169
|
+
)
|
|
170
|
+
return IncomingCall(board), hashlib.sha256(encoded_token).digest()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _call_metadata(value: str | None) -> str:
|
|
174
|
+
if not isinstance(value, str) or not value.strip():
|
|
175
|
+
return ""
|
|
176
|
+
try:
|
|
177
|
+
validate_utf8(value, MAX_CALL_METADATA_BYTES, "Invalid call board metadata.")
|
|
178
|
+
except InvalidInputError:
|
|
179
|
+
return ""
|
|
180
|
+
if any(category(char) in {"Cc", "Cf"} for char in value):
|
|
181
|
+
return ""
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def assert_android_metadata(packet: Packet) -> None:
|
|
186
|
+
"""Allow only supported envelopes and Android request shapes."""
|
|
187
|
+
header, body, profile = packet.get("header"), packet.get("body"), AndroidProfile()
|
|
188
|
+
if (
|
|
189
|
+
set(packet) != {"header", "body"}
|
|
190
|
+
or not isinstance(header, dict)
|
|
191
|
+
or set(header) != {"event", "sender", "timestamp"}
|
|
192
|
+
or not isinstance(body, dict)
|
|
193
|
+
or not isinstance(header.get("event"), str)
|
|
194
|
+
or not isinstance(header.get("sender"), str)
|
|
195
|
+
or type(header.get("timestamp")) is not int
|
|
196
|
+
or header["timestamp"] < 0
|
|
197
|
+
or not isinstance(body.get("token"), str)
|
|
198
|
+
or not body["token"]
|
|
199
|
+
):
|
|
200
|
+
raise InvalidInputError("Unsupported Android message envelope.")
|
|
201
|
+
validate_routing_identity(header["sender"])
|
|
202
|
+
event = header["event"]
|
|
203
|
+
if event == "hello":
|
|
204
|
+
data = body.get("data")
|
|
205
|
+
basic = {
|
|
206
|
+
"fcm_token",
|
|
207
|
+
"without_sip",
|
|
208
|
+
"os_version",
|
|
209
|
+
"language",
|
|
210
|
+
"timezone",
|
|
211
|
+
"weektypes",
|
|
212
|
+
"connectionInfo",
|
|
213
|
+
}
|
|
214
|
+
authenticated = {"username", "phone", "email", "akey", "code"}
|
|
215
|
+
if (
|
|
216
|
+
set(body) != {"type", "version", "data", "token"}
|
|
217
|
+
or type(body.get("type")) is not int
|
|
218
|
+
or body["type"] != 0
|
|
219
|
+
or not isinstance(data, dict)
|
|
220
|
+
or set(data) not in (basic, basic | authenticated)
|
|
221
|
+
or body.get("version") != profile.app_version
|
|
222
|
+
):
|
|
223
|
+
raise InvalidInputError("Incompatible Android greeting metadata.")
|
|
224
|
+
expected = {
|
|
225
|
+
"os_version": profile.os_version,
|
|
226
|
+
"language": profile.language,
|
|
227
|
+
"connectionInfo": profile.connection_info,
|
|
228
|
+
"fcm_token": "",
|
|
229
|
+
"without_sip": True,
|
|
230
|
+
"weektypes": [[], [], [], [], [], [], []],
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
any(data.get(key) != value for key, value in expected.items())
|
|
234
|
+
or data.get("without_sip") is not True
|
|
235
|
+
or type(data.get("timezone")) is not int
|
|
236
|
+
or not -86400 <= data["timezone"] <= 86400
|
|
237
|
+
):
|
|
238
|
+
raise InvalidInputError("Incompatible Android greeting metadata.")
|
|
239
|
+
if set(data) == basic | authenticated:
|
|
240
|
+
if (
|
|
241
|
+
not all(isinstance(data[key], str) for key in authenticated)
|
|
242
|
+
or data["akey"] != ""
|
|
243
|
+
or bool(data["phone"]) == bool(data["email"])
|
|
244
|
+
):
|
|
245
|
+
raise InvalidInputError("Incompatible Android greeting metadata.")
|
|
246
|
+
if data["username"]:
|
|
247
|
+
validate_installation_label(data["username"])
|
|
248
|
+
validate_verification_code(data["code"])
|
|
249
|
+
_assert_identifier_fields(
|
|
250
|
+
{key: data[key] for key in ("phone", "email") if data[key]}
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
allowed = {
|
|
254
|
+
"sp_srv_check_auth": {"token"},
|
|
255
|
+
"sp_srv_validate_auth": {"token", "code"},
|
|
256
|
+
"sp_srv_ask_key": {"token", "code"},
|
|
257
|
+
"sp_srv_check_username": {"token", "code", "username", "model"},
|
|
258
|
+
"sp_srv_override_username": {"token", "code", "username", "model"},
|
|
259
|
+
"sp_srv_update_ack": {"token"},
|
|
260
|
+
"sp_brd_open_door": {"token", "door"},
|
|
261
|
+
"sp_brd_hangup_call": {"token", "cause"},
|
|
262
|
+
}
|
|
263
|
+
expected_keys = allowed.get(event)
|
|
264
|
+
if expected_keys is None:
|
|
265
|
+
raise InvalidInputError("Unsupported Android message event.")
|
|
266
|
+
if event.startswith("sp_srv_") and event != "sp_srv_update_ack":
|
|
267
|
+
identifiers = {key: body[key] for key in ("phone", "email") if key in body}
|
|
268
|
+
_assert_identifier_fields(identifiers)
|
|
269
|
+
expected_keys = expected_keys | set(identifiers)
|
|
270
|
+
if set(body) != expected_keys:
|
|
271
|
+
raise InvalidInputError("Unsupported Android message fields.")
|
|
272
|
+
if "code" in body:
|
|
273
|
+
validate_verification_code(body["code"])
|
|
274
|
+
if event in ("sp_srv_check_username", "sp_srv_override_username"):
|
|
275
|
+
label = body.get("username")
|
|
276
|
+
if not isinstance(label, str) or body.get("model") != profile.model:
|
|
277
|
+
raise InvalidInputError("Incompatible Android registration metadata.")
|
|
278
|
+
validate_installation_label(label)
|
|
279
|
+
if event == "sp_brd_open_door" and (
|
|
280
|
+
type(body["door"]) is not int or not 1 <= body["door"] <= 3
|
|
281
|
+
):
|
|
282
|
+
raise InvalidInputError("Unsupported Android relay number.")
|
|
283
|
+
if event == "sp_brd_hangup_call":
|
|
284
|
+
token = body["token"]
|
|
285
|
+
assert isinstance(token, str)
|
|
286
|
+
validate_utf8(token, MAX_CALL_TOKEN_BYTES, "Invalid call token.")
|
|
287
|
+
if (
|
|
288
|
+
type(body["cause"]) is not int
|
|
289
|
+
or body["cause"] != 0
|
|
290
|
+
or not token.strip()
|
|
291
|
+
or any(ord(char) < 32 for char in token)
|
|
292
|
+
):
|
|
293
|
+
raise InvalidInputError("Unsupported Android hangup message.")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _assert_identifier_fields(fields: Mapping[str, object]) -> None:
|
|
297
|
+
if len(fields) != 1:
|
|
298
|
+
raise InvalidInputError("Unsupported Android account fields.")
|
|
299
|
+
kind, value = next(iter(fields.items()))
|
|
300
|
+
if not isinstance(value, str):
|
|
301
|
+
raise InvalidInputError("Unsupported Android account fields.")
|
|
302
|
+
identifier = validate_identifier(value)
|
|
303
|
+
if identifier.kind.value != kind or identifier.value != value:
|
|
304
|
+
raise InvalidInputError("Unsupported Android account fields.")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def decode_frames(frames: list[bytes]) -> Packet | None:
|
|
308
|
+
if len(frames) > MAX_MESSAGE_FRAMES or sum(map(len, frames)) > MAX_MESSAGE_BYTES:
|
|
309
|
+
raise ProtocolError("Service message exceeds supported bounds.")
|
|
310
|
+
for frame in reversed(frames):
|
|
311
|
+
try:
|
|
312
|
+
value = json.loads(frame)
|
|
313
|
+
if isinstance(value, str):
|
|
314
|
+
value = json.loads(value)
|
|
315
|
+
except UnicodeDecodeError, ValueError:
|
|
316
|
+
continue
|
|
317
|
+
except RecursionError:
|
|
318
|
+
raise ProtocolError(
|
|
319
|
+
"Service message nesting exceeds supported bounds."
|
|
320
|
+
) from None
|
|
321
|
+
if not isinstance(value, dict):
|
|
322
|
+
continue
|
|
323
|
+
if value.get("action") == "register":
|
|
324
|
+
continue
|
|
325
|
+
header, body = value.get("header"), value.get("body")
|
|
326
|
+
if (
|
|
327
|
+
isinstance(header, dict)
|
|
328
|
+
and isinstance(header.get("event"), str)
|
|
329
|
+
and isinstance(body, dict)
|
|
330
|
+
):
|
|
331
|
+
return value
|
|
332
|
+
# SIP action/register controls and unrelated/noisy frames are not auth replies.
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _decoded(value: object) -> object:
|
|
337
|
+
if isinstance(value, str):
|
|
338
|
+
try:
|
|
339
|
+
return json.loads(value)
|
|
340
|
+
except ValueError, RecursionError:
|
|
341
|
+
raise ProtocolError("Invalid service data encoding.") from None
|
|
342
|
+
return value
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def configuration_password(packet: Packet) -> str | None:
|
|
346
|
+
data = _decoded(packet_body(packet).get("data"))
|
|
347
|
+
item: object = None
|
|
348
|
+
if isinstance(data, list):
|
|
349
|
+
item = data[1] if len(data) > 1 else None
|
|
350
|
+
elif isinstance(data, dict):
|
|
351
|
+
item = data.get("1")
|
|
352
|
+
elif data is not None:
|
|
353
|
+
raise ProtocolError("Unsupported account configuration shape.")
|
|
354
|
+
password = item.get("sip_pwd") if isinstance(item, dict) else None
|
|
355
|
+
return password if isinstance(password, str) and password else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def relay_inventory(body: Mapping[str, object]) -> tuple[Relay, ...]:
|
|
359
|
+
"""Return relay permissions, preserving the existing discovery contract."""
|
|
360
|
+
return door_inventory(body)[1]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def door_inventory(
|
|
364
|
+
body: Mapping[str, object],
|
|
365
|
+
) -> tuple[tuple[Board, ...], tuple[Relay, ...]]:
|
|
366
|
+
"""Read the inventory once, including boards with no release permissions."""
|
|
367
|
+
source = _decoded(body.get("boards") if "boards" in body else body.get("sites"))
|
|
368
|
+
if isinstance(source, list):
|
|
369
|
+
sites: dict[str, object] = {"0": {"name": "", "boards": source}}
|
|
370
|
+
elif isinstance(source, dict):
|
|
371
|
+
sites = source
|
|
372
|
+
else:
|
|
373
|
+
raise ProtocolError(
|
|
374
|
+
"Door inventory has no supported boards or sites structure."
|
|
375
|
+
)
|
|
376
|
+
relays: list[Relay] = []
|
|
377
|
+
boards: list[Board] = []
|
|
378
|
+
seen: set[tuple[str, int]] = set()
|
|
379
|
+
if len(sites) > MAX_INVENTORY_SITES:
|
|
380
|
+
raise ProtocolError("Door inventory exceeds supported bounds.")
|
|
381
|
+
board_count = 0
|
|
382
|
+
for site_id, site in sites.items():
|
|
383
|
+
if not isinstance(site, dict) or not isinstance(site.get("boards"), list):
|
|
384
|
+
continue
|
|
385
|
+
board_count += len(site["boards"])
|
|
386
|
+
if board_count > MAX_INVENTORY_BOARDS:
|
|
387
|
+
raise ProtocolError("Door inventory exceeds supported bounds.")
|
|
388
|
+
for item in site["boards"]:
|
|
389
|
+
if (
|
|
390
|
+
not isinstance(item, dict)
|
|
391
|
+
or not isinstance(item.get("identity"), str)
|
|
392
|
+
or not item["identity"]
|
|
393
|
+
):
|
|
394
|
+
continue
|
|
395
|
+
try:
|
|
396
|
+
validate_routing_identity(item["identity"])
|
|
397
|
+
except InvalidInputError:
|
|
398
|
+
raise ProtocolError("Invalid service board identity.") from None
|
|
399
|
+
board = Board(
|
|
400
|
+
identity=item["identity"],
|
|
401
|
+
name=_name(item.get("name")),
|
|
402
|
+
site_id=str(site_id),
|
|
403
|
+
site_name=_name(site.get("name")),
|
|
404
|
+
device_id=_scalar(item.get("device_id")),
|
|
405
|
+
device_type=_scalar(item.get("device_type")),
|
|
406
|
+
)
|
|
407
|
+
boards.append(board)
|
|
408
|
+
access, names = item.get("relay_access_mobile_v2"), item.get("relay_name")
|
|
409
|
+
if not isinstance(access, list):
|
|
410
|
+
continue
|
|
411
|
+
names = names if isinstance(names, list) else []
|
|
412
|
+
pending = bool(item.get("waitConfirmBadgeDigital"))
|
|
413
|
+
for index, permission in enumerate(access[:3]):
|
|
414
|
+
if not isinstance(permission, dict):
|
|
415
|
+
continue
|
|
416
|
+
identity = (board.identity, index + 1)
|
|
417
|
+
if identity in seen:
|
|
418
|
+
raise ProtocolError("Ambiguous board relay inventory.")
|
|
419
|
+
seen.add(identity)
|
|
420
|
+
kind = _scalar(permission.get("type"))
|
|
421
|
+
relays.append(
|
|
422
|
+
Relay(
|
|
423
|
+
board=board,
|
|
424
|
+
number=index + 1,
|
|
425
|
+
name=_name(names[index])
|
|
426
|
+
if index < len(names)
|
|
427
|
+
else f"Relay {index + 1}",
|
|
428
|
+
remote_allowed=not pending and kind in (0, 2, "0", "2"),
|
|
429
|
+
pending_confirmation=pending,
|
|
430
|
+
access_type=kind,
|
|
431
|
+
access_mode=_scalar(permission.get("mode")),
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
return tuple(boards), tuple(relays)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _name(value: object) -> str:
|
|
438
|
+
return value if isinstance(value, str) else ""
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _scalar(value: object) -> int | str | None:
|
|
442
|
+
return (
|
|
443
|
+
value if isinstance(value, (int, str)) and not isinstance(value, bool) else None
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def validate_endpoint(endpoint: str) -> None:
|
|
448
|
+
from urllib.parse import urlsplit
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
value = urlsplit(endpoint)
|
|
452
|
+
valid = (
|
|
453
|
+
value.scheme == "tcp"
|
|
454
|
+
and value.hostname
|
|
455
|
+
and value.port
|
|
456
|
+
and not value.path
|
|
457
|
+
and not value.query
|
|
458
|
+
and not value.fragment
|
|
459
|
+
and not value.username
|
|
460
|
+
)
|
|
461
|
+
except ValueError:
|
|
462
|
+
valid = False
|
|
463
|
+
if not valid:
|
|
464
|
+
raise InvalidInputError("Expected a ZeroMQ TCP service endpoint.")
|
ipguard_api/py.typed
ADDED
|
File without changes
|