meshsocket 0.1.2__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshsocket
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
5
  Author: Carter Beaudoin
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@
4
4
  `from socketCore import MeshSocket` continues to work (the module is published alongside)
5
5
  so existing code keeps running while it migrates to the package name.
6
6
  """
7
- from socketCore import MeshSocket, LogColors
7
+ from socketCore import MeshSocket, LogColors, sanitize_identity, valid_ident, IDENT_RE, NAMESPACED_RE
8
8
 
9
- __all__ = ["MeshSocket", "LogColors"]
10
- __version__ = "0.1.0"
9
+ __all__ = ["MeshSocket", "LogColors", "sanitize_identity", "valid_ident", "IDENT_RE", "NAMESPACED_RE"]
10
+ __version__ = "0.2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshsocket
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
5
  Author: Carter Beaudoin
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshsocket"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,14 +3,56 @@ import certifi
3
3
  import inspect
4
4
  import json
5
5
  import ssl
6
+ import stat
6
7
  import uuid
7
8
  import time
8
9
  import logging
9
10
  import os
11
+ import re
10
12
  import websockets
13
+ from urllib.parse import urlsplit
11
14
  from collections import deque
12
15
  from typing import Callable, Dict, Any, List, Optional
13
16
 
17
+ # Identity grammar enforced by the relay (see socket_server): a plain identifier,
18
+ # or the carter-relay gateway's `<account digits>.<identifier>` form.
19
+ IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
20
+ NAMESPACED_RE = re.compile(r"^[0-9]{1,32}\.[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
21
+ _NS_PREFIX_RE = re.compile(r"^([0-9]{1,32})\.(.*)$", re.S)
22
+
23
+
24
+ def valid_ident(value: Any) -> bool:
25
+ return isinstance(value, str) and bool(IDENT_RE.match(value) or NAMESPACED_RE.match(value))
26
+
27
+
28
+ def sanitize_identity(value: Any, kind: str = "name") -> str:
29
+ """Turn a free-form string into a name/channel the relay accepts.
30
+
31
+ The relay itself does NOT sanitize: an identify whose name or channel fails
32
+ the grammar is closed with 1008. Clients call this before identifying (for
33
+ example on a device name such as "Carter's iPhone" -> "Carter-s-iPhone").
34
+
35
+ Rule (identical in relay, gateway and app): `value` must be a str of 1..256
36
+ chars, else ValueError. A leading `<digits>.` namespace prefix is kept and
37
+ only the remainder is sanitized. Every char outside [A-Za-z0-9_-] becomes
38
+ "-", runs of "-" collapse to one, leading/trailing "-" (and a leading "_",
39
+ which the grammar forbids) are stripped, the result is truncated to 64 chars,
40
+ and an empty result becomes "node" (kind="name") or "default"
41
+ (kind="channel"). The output always satisfies `valid_ident`.
42
+ """
43
+ if not isinstance(value, str) or not 1 <= len(value) <= 256:
44
+ raise ValueError(f"{kind} must be a string of 1..256 characters")
45
+ prefix = ""
46
+ m = _NS_PREFIX_RE.match(value)
47
+ if m:
48
+ prefix, value = m.group(1) + ".", m.group(2)
49
+ out = re.sub(r"[^A-Za-z0-9_-]", "-", value)
50
+ out = re.sub(r"-{2,}", "-", out).strip("-").lstrip("_")
51
+ out = out[:64].rstrip("-")
52
+ if not out:
53
+ out = "node" if kind == "name" else "default"
54
+ return prefix + out
55
+
14
56
  # ANSI Colors for nicer logs
15
57
  class LogColors:
16
58
  HEADER = '\033[95m'
@@ -75,9 +117,22 @@ class MeshSocket:
75
117
  can_route: bool | None = None,
76
118
  can_cross_channel_route: bool | None = None,
77
119
  can_monitor: bool | None = None,
78
- broadcast_scope: str | None = None):
120
+ broadcast_scope: str | None = None,
121
+ ping_interval: float | None = 30.0,
122
+ ping_timeout: float | None = 10.0,
123
+ close_timeout: float | None = 5.0):
79
124
 
80
125
  self.url = url
126
+ # Client keepalive: a WebSocket ping every `ping_interval` seconds; if
127
+ # the pong takes longer than `ping_timeout` the socket is closed (1011)
128
+ # and the supervisor reconnects. None disables (library default was an
129
+ # implicit 20/20). The relay pings independently.
130
+ self.ping_interval = ping_interval
131
+ self.ping_timeout = ping_timeout
132
+ # How long to wait for the peer's close frame once WE decide to close
133
+ # (e.g. after a pong timeout). A dead peer never answers, so this bounds
134
+ # how long a detected-dead socket lingers before the supervisor redials.
135
+ self.close_timeout = close_timeout
81
136
  self.connection = connection
82
137
  self.auth_token = auth_token or os.getenv('MESH_AUTH_TOKEN')
83
138
  self.channel = channel or os.getenv("MESH_CHANNEL")
@@ -93,7 +148,10 @@ class MeshSocket:
93
148
  else:
94
149
  self.name = name
95
150
 
96
- self.id = str(uuid.uuid4())
151
+ # `id` is a property: the server freezes it once the client is registered
152
+ # (see `freeze_id`) so no later frame can move a node in the routing table.
153
+ self._id = str(uuid.uuid4())
154
+ self._id_frozen = False
97
155
 
98
156
  # Callbacks
99
157
  self.on_reconnect_callback = on_reconnect
@@ -130,10 +188,34 @@ class MeshSocket:
130
188
  self.handlers: Dict[str, Callable] = {}
131
189
  self._pending_requests: Dict[str, asyncio.Future] = {}
132
190
 
133
- self.on("handshake", self._handle_handshake)
191
+ # Optional pre-dispatch gate: `authorize(msg_type) -> bool`. The server
192
+ # installs one per connection so unidentified sockets can reach nothing
193
+ # but `identify`; None means every registered handler is reachable.
194
+ self.authorize: Optional[Callable[[str], bool]] = None
195
+
196
+ # `ping` is answered in both modes. The rest are CLIENT-mode handlers:
197
+ # a server-side node must never let its peer rewrite its id via
198
+ # `welcome`, nor answer `handshake`/`status_request` before the server's
199
+ # own auth gate has admitted the socket.
134
200
  self.on("ping", self._handle_ping)
135
- self.on("status_request", self._handle_status_request)
136
- self.on("welcome", self._handle_welcome)
201
+ if connection is None:
202
+ self.on("handshake", self._handle_handshake)
203
+ self.on("status_request", self._handle_status_request)
204
+ self.on("welcome", self._handle_welcome)
205
+
206
+ @property
207
+ def id(self) -> str:
208
+ return self._id
209
+
210
+ @id.setter
211
+ def id(self, value: str):
212
+ if self._id_frozen:
213
+ raise AttributeError("MeshSocket.id is immutable once registered")
214
+ self._id = value
215
+
216
+ def freeze_id(self):
217
+ """Make `id` read-only. The server calls this right after registration."""
218
+ self._id_frozen = True
137
219
 
138
220
  @property
139
221
  def connected_event(self) -> asyncio.Event:
@@ -145,7 +227,25 @@ class MeshSocket:
145
227
  return self._connected_event
146
228
 
147
229
  def __str__(self):
148
- return f"{self.name}, {self.url}"
230
+ return f"{self.name}, {self.safe_url}"
231
+
232
+ @property
233
+ def safe_url(self) -> Optional[str]:
234
+ """scheme://host[:port] only — never the path or query, which may carry a
235
+ token, and which therefore must never reach a log line."""
236
+ if not self.url:
237
+ return self.url
238
+ try:
239
+ parts = urlsplit(self.url)
240
+ except ValueError:
241
+ return "<unparseable url>"
242
+ if not parts.scheme or not parts.hostname:
243
+ return "<unparseable url>"
244
+ host = parts.hostname
245
+ if ":" in host:
246
+ host = f"[{host}]"
247
+ port = f":{parts.port}" if parts.port else ""
248
+ return f"{parts.scheme}://{host}{port}"
149
249
 
150
250
  def on(self, type: str, func: Callable = None):
151
251
  if func is None:
@@ -271,15 +371,20 @@ class MeshSocket:
271
371
  listen_task = None
272
372
  admitted = False
273
373
  try:
274
- logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.url}...{LogColors.ENDC}")
374
+ logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.safe_url}...{LogColors.ENDC}")
275
375
  ssl_context = ssl.create_default_context(cafile=certifi.where()) if self.url.startswith("wss://") else None
276
- async with websockets.connect(self.url, ssl=ssl_context) as ws:
376
+ async with websockets.connect(self.url, ssl=ssl_context,
377
+ ping_interval=self.ping_interval,
378
+ ping_timeout=self.ping_timeout,
379
+ close_timeout=self.close_timeout) as ws:
277
380
  self.connection = ws
278
381
  retry_delay = 2
279
382
 
280
383
  # Arm the admission gate before identify so a welcome delivered
281
- # by the concurrently-running listen loop can't be missed.
384
+ # by the concurrently-running listen loop can't be missed. Each
385
+ # attempt gets a fresh server-assigned id, so unfreeze it here.
282
386
  self._admitted_event = asyncio.Event()
387
+ self._id_frozen = False
283
388
 
284
389
  # Send identify directly on the socket: connected_event is not
285
390
  # set yet, so the normal send() path would buffer or raise.
@@ -361,8 +466,9 @@ class MeshSocket:
361
466
  return False
362
467
 
363
468
  # --- SEND LOGIC ---
364
- async def send(self, type: str, payload: Any = None, reply_to: str = None) -> Optional[str]:
365
- msg_id = str(uuid.uuid4())
469
+ async def send(self, type: str, payload: Any = None, reply_to: str = None,
470
+ msg_id: str = None) -> Optional[str]:
471
+ msg_id = msg_id or str(uuid.uuid4())
366
472
  packet = {"id": msg_id, "type": type, "payload": payload, "reply_to": reply_to}
367
473
  packet_str = json.dumps(packet)
368
474
 
@@ -409,18 +515,46 @@ class MeshSocket:
409
515
  await loop.run_in_executor(None, self._write_lines_to_file, [packet_str])
410
516
 
411
517
  def _write_lines_to_file(self, lines: List[str]):
412
- with open(self.offline_file_path, "a") as f:
518
+ # Owner-only from the first byte: the buffer holds frames that are
519
+ # replayed verbatim onto an authenticated socket.
520
+ fd = os.open(self.offline_file_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
521
+ with os.fdopen(fd, "a") as f:
413
522
  for line in lines:
414
523
  f.write(line + "\n")
524
+ try:
525
+ os.chmod(self.offline_file_path, stat.S_IRUSR | stat.S_IWUSR)
526
+ except OSError:
527
+ pass
528
+
529
+ @staticmethod
530
+ def _valid_buffered_frame(line: str) -> bool:
531
+ """A replayable line is one JSON object with a string `type` (a frame this
532
+ client itself produced). Anything else was not written by us."""
533
+ try:
534
+ frame = json.loads(line)
535
+ except (ValueError, TypeError):
536
+ return False
537
+ return (isinstance(frame, dict) and isinstance(frame.get("type"), str)
538
+ and isinstance(frame.get("id"), str)
539
+ and frame.get("type") not in ("identify", "welcome"))
415
540
 
416
541
  async def _flush_offline_queue(self):
417
542
  if self.offline_file_path and os.path.exists(self.offline_file_path):
418
543
  logging.info(f"{LogColors.WARNING}Flushing disk buffer...{LogColors.ENDC}")
419
544
  try:
545
+ dropped = 0
420
546
  with open(self.offline_file_path, "r") as f:
421
547
  for line in f:
422
- if line.strip():
423
- await self.connection.send(line.strip())
548
+ line = line.strip()
549
+ if not line:
550
+ continue
551
+ if not self._valid_buffered_frame(line):
552
+ dropped += 1
553
+ continue
554
+ await self.connection.send(line)
555
+ if dropped:
556
+ logging.warning(f"{LogColors.WARNING}Dropped {dropped} invalid line(s) from the "
557
+ f"disk buffer (not frames this client wrote){LogColors.ENDC}")
424
558
  os.remove(self.offline_file_path)
425
559
  except Exception as e:
426
560
  logging.error(f"Failed to flush disk buffer: {e}")
@@ -436,16 +570,24 @@ class MeshSocket:
436
570
  logging.warning(f"Request '{type}' waiting for connection...")
437
571
  await self.connected_event.wait()
438
572
 
439
- msg_id = await self.send(type, payload)
573
+ # Register the pending id BEFORE the frame leaves, so a reply that
574
+ # arrives faster than we can get back here is matched, not dropped.
575
+ msg_id = str(uuid.uuid4())
440
576
  loop = asyncio.get_running_loop()
441
577
  future = loop.create_future()
442
578
  self._pending_requests[msg_id] = future
579
+ try:
580
+ await self.send(type, payload, msg_id=msg_id)
581
+ except BaseException:
582
+ self._pending_requests.pop(msg_id, None)
583
+ raise
443
584
  try:
444
585
  return await asyncio.wait_for(future, timeout=timeout)
445
586
  except asyncio.TimeoutError:
446
587
  self._pending_requests.pop(msg_id, None)
447
588
  return None
448
589
  except asyncio.CancelledError:
590
+ self._pending_requests.pop(msg_id, None)
449
591
  return None
450
592
 
451
593
  def _fail_all_pending_requests(self):
@@ -453,7 +595,20 @@ class MeshSocket:
453
595
  if not future.done(): future.cancel()
454
596
  self._pending_requests.clear()
455
597
 
598
+ # Inbound handler tasks in flight per connection. When all slots are busy
599
+ # the read loop waits, which lets the websocket's own buffer apply
600
+ # backpressure to the peer instead of spawning unbounded tasks.
601
+ MAX_INFLIGHT = 64
602
+
456
603
  async def _listen_loop(self):
604
+ inflight = asyncio.Semaphore(self.MAX_INFLIGHT)
605
+
606
+ async def run(packet):
607
+ try:
608
+ await self._process_packet(packet)
609
+ finally:
610
+ inflight.release()
611
+
457
612
  async for message in self.connection:
458
613
  if self.rate_limit > 0:
459
614
  now = time.time()
@@ -465,19 +620,31 @@ class MeshSocket:
465
620
 
466
621
  try:
467
622
  data = json.loads(message)
468
- asyncio.create_task(self._process_packet(data))
469
- except json.JSONDecodeError:
470
- pass
623
+ except (json.JSONDecodeError, TypeError, ValueError):
624
+ continue
625
+ await inflight.acquire()
626
+ asyncio.create_task(run(data))
471
627
 
472
628
  async def _process_packet(self, packet: dict):
629
+ if not isinstance(packet, dict):
630
+ return
473
631
  msg_id = packet.get('id')
474
632
  msg_type = packet.get('type')
475
633
  payload = packet.get('payload')
476
634
  reply_to = packet.get('reply_to')
477
- if reply_to and reply_to in self._pending_requests:
478
- future = self._pending_requests.pop(reply_to)
479
- if not future.done(): future.set_result(payload)
480
- return
635
+ # Single pre-dispatch gate (server installs it per connection): nothing
636
+ # — not replies, not `ping`, not `node_status` — is processed for a
637
+ # socket the gate has not admitted.
638
+ if self.authorize is not None and not self.authorize(msg_type):
639
+ return
640
+ if reply_to:
641
+ # A reply is only ever a reply. One that matches no pending request
642
+ # (late, duplicate, or forged) is dropped — never re-dispatched as
643
+ # a fresh request of its `type`.
644
+ future = self._pending_requests.pop(reply_to, None)
645
+ if future is not None and not future.done():
646
+ future.set_result(payload)
647
+ return
481
648
  if msg_type in self.handlers:
482
649
  try:
483
650
  response = await self.handlers[msg_type](payload)
@@ -487,13 +654,16 @@ class MeshSocket:
487
654
  logging.error(f"Error processing {msg_type}: {e}")
488
655
 
489
656
  async def _handle_welcome(self, payload):
490
- server_id = payload.get("id")
491
- if server_id:
657
+ # Client mode only: a server-side node never accepts a peer's `welcome`
658
+ # (the handler is not registered there, and this guard is belt-and-braces).
659
+ if self._admitted_event is None:
660
+ return
661
+ server_id = payload.get("id") if isinstance(payload, dict) else None
662
+ if isinstance(server_id, str) and server_id and not self._id_frozen:
492
663
  logging.info(f"{self.name} assigned server ID: {server_id}")
493
664
  self.id = server_id
494
- # Admit the connection (client mode). None in server mode.
495
- if self._admitted_event is not None:
496
- self._admitted_event.set()
665
+ self.freeze_id()
666
+ self._admitted_event.set()
497
667
 
498
668
  async def _handle_handshake(self, payload):
499
669
  t_remote = float(payload.get('t'))