meshsocket 0.1.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.
- meshsocket-0.1.0/LICENSE +21 -0
- meshsocket-0.1.0/PKG-INFO +81 -0
- meshsocket-0.1.0/README.md +58 -0
- meshsocket-0.1.0/meshsocket/__init__.py +10 -0
- meshsocket-0.1.0/meshsocket.egg-info/PKG-INFO +81 -0
- meshsocket-0.1.0/meshsocket.egg-info/SOURCES.txt +11 -0
- meshsocket-0.1.0/meshsocket.egg-info/dependency_links.txt +1 -0
- meshsocket-0.1.0/meshsocket.egg-info/requires.txt +2 -0
- meshsocket-0.1.0/meshsocket.egg-info/top_level.txt +3 -0
- meshsocket-0.1.0/pyproject.toml +37 -0
- meshsocket-0.1.0/setup.cfg +4 -0
- meshsocket-0.1.0/socketCore.py +473 -0
- meshsocket-0.1.0/socket_server.py +370 -0
meshsocket-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carter Beaudoin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meshsocket
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
|
|
5
|
+
Author: Carter Beaudoin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Mariner10/MeshSocket
|
|
8
|
+
Project-URL: Repository, https://github.com/Mariner10/MeshSocket
|
|
9
|
+
Keywords: websocket,mesh,networking,rpc,pubsub,asyncio
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: System :: Networking
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: websockets>=12
|
|
21
|
+
Requires-Dist: certifi
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# MeshSocket (Python)
|
|
25
|
+
|
|
26
|
+
A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
|
|
27
|
+
communicate by **broadcast**, **direct routing**, and **request/response** — all over a
|
|
28
|
+
simple JSON wire protocol.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install meshsocket
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart (client)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from meshsocket import MeshSocket
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
sock = MeshSocket(url="ws://localhost:8765", name="my-node",
|
|
44
|
+
channel="demo", role="device", can_broadcast=True)
|
|
45
|
+
|
|
46
|
+
async def on_toggle(payload):
|
|
47
|
+
print("got", payload)
|
|
48
|
+
return {"ok": True} # a returned value is delivered as the reply
|
|
49
|
+
|
|
50
|
+
sock.on("toggle", on_toggle)
|
|
51
|
+
|
|
52
|
+
await sock.start() # connect + identify
|
|
53
|
+
await sock.wait_until_ready()
|
|
54
|
+
await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
|
|
55
|
+
await asyncio.sleep(60)
|
|
56
|
+
await sock.stop()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Wire protocol
|
|
62
|
+
|
|
63
|
+
Every message is a JSON object:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{ "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If a handler returns a value, the receiver sends a reply with `reply_to` set to the
|
|
70
|
+
original `id`, enabling request/response round-trips.
|
|
71
|
+
|
|
72
|
+
## Compatibility
|
|
73
|
+
|
|
74
|
+
`from meshsocket import MeshSocket` is the supported import. The historical
|
|
75
|
+
`from socketCore import MeshSocket` still works (that module is published alongside the
|
|
76
|
+
package) so existing code keeps running while it migrates.
|
|
77
|
+
|
|
78
|
+
## Other language clients
|
|
79
|
+
|
|
80
|
+
Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
|
|
81
|
+
<https://github.com/Mariner10/MeshSocket>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# MeshSocket (Python)
|
|
2
|
+
|
|
3
|
+
A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
|
|
4
|
+
communicate by **broadcast**, **direct routing**, and **request/response** — all over a
|
|
5
|
+
simple JSON wire protocol.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install meshsocket
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart (client)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from meshsocket import MeshSocket
|
|
18
|
+
|
|
19
|
+
async def main():
|
|
20
|
+
sock = MeshSocket(url="ws://localhost:8765", name="my-node",
|
|
21
|
+
channel="demo", role="device", can_broadcast=True)
|
|
22
|
+
|
|
23
|
+
async def on_toggle(payload):
|
|
24
|
+
print("got", payload)
|
|
25
|
+
return {"ok": True} # a returned value is delivered as the reply
|
|
26
|
+
|
|
27
|
+
sock.on("toggle", on_toggle)
|
|
28
|
+
|
|
29
|
+
await sock.start() # connect + identify
|
|
30
|
+
await sock.wait_until_ready()
|
|
31
|
+
await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
|
|
32
|
+
await asyncio.sleep(60)
|
|
33
|
+
await sock.stop()
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Wire protocol
|
|
39
|
+
|
|
40
|
+
Every message is a JSON object:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{ "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If a handler returns a value, the receiver sends a reply with `reply_to` set to the
|
|
47
|
+
original `id`, enabling request/response round-trips.
|
|
48
|
+
|
|
49
|
+
## Compatibility
|
|
50
|
+
|
|
51
|
+
`from meshsocket import MeshSocket` is the supported import. The historical
|
|
52
|
+
`from socketCore import MeshSocket` still works (that module is published alongside the
|
|
53
|
+
package) so existing code keeps running while it migrates.
|
|
54
|
+
|
|
55
|
+
## Other language clients
|
|
56
|
+
|
|
57
|
+
Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
|
|
58
|
+
<https://github.com/Mariner10/MeshSocket>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""MeshSocket — lightweight WebSocket mesh networking.
|
|
2
|
+
|
|
3
|
+
`from meshsocket import MeshSocket` is the supported public import. The historical
|
|
4
|
+
`from socketCore import MeshSocket` continues to work (the module is published alongside)
|
|
5
|
+
so existing code keeps running while it migrates to the package name.
|
|
6
|
+
"""
|
|
7
|
+
from socketCore import MeshSocket, LogColors
|
|
8
|
+
|
|
9
|
+
__all__ = ["MeshSocket", "LogColors"]
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meshsocket
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
|
|
5
|
+
Author: Carter Beaudoin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Mariner10/MeshSocket
|
|
8
|
+
Project-URL: Repository, https://github.com/Mariner10/MeshSocket
|
|
9
|
+
Keywords: websocket,mesh,networking,rpc,pubsub,asyncio
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: System :: Networking
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: websockets>=12
|
|
21
|
+
Requires-Dist: certifi
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# MeshSocket (Python)
|
|
25
|
+
|
|
26
|
+
A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
|
|
27
|
+
communicate by **broadcast**, **direct routing**, and **request/response** — all over a
|
|
28
|
+
simple JSON wire protocol.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install meshsocket
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart (client)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from meshsocket import MeshSocket
|
|
41
|
+
|
|
42
|
+
async def main():
|
|
43
|
+
sock = MeshSocket(url="ws://localhost:8765", name="my-node",
|
|
44
|
+
channel="demo", role="device", can_broadcast=True)
|
|
45
|
+
|
|
46
|
+
async def on_toggle(payload):
|
|
47
|
+
print("got", payload)
|
|
48
|
+
return {"ok": True} # a returned value is delivered as the reply
|
|
49
|
+
|
|
50
|
+
sock.on("toggle", on_toggle)
|
|
51
|
+
|
|
52
|
+
await sock.start() # connect + identify
|
|
53
|
+
await sock.wait_until_ready()
|
|
54
|
+
await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
|
|
55
|
+
await asyncio.sleep(60)
|
|
56
|
+
await sock.stop()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Wire protocol
|
|
62
|
+
|
|
63
|
+
Every message is a JSON object:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{ "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If a handler returns a value, the receiver sends a reply with `reply_to` set to the
|
|
70
|
+
original `id`, enabling request/response round-trips.
|
|
71
|
+
|
|
72
|
+
## Compatibility
|
|
73
|
+
|
|
74
|
+
`from meshsocket import MeshSocket` is the supported import. The historical
|
|
75
|
+
`from socketCore import MeshSocket` still works (that module is published alongside the
|
|
76
|
+
package) so existing code keeps running while it migrates.
|
|
77
|
+
|
|
78
|
+
## Other language clients
|
|
79
|
+
|
|
80
|
+
Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
|
|
81
|
+
<https://github.com/Mariner10/MeshSocket>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
socketCore.py
|
|
5
|
+
socket_server.py
|
|
6
|
+
meshsocket/__init__.py
|
|
7
|
+
meshsocket.egg-info/PKG-INFO
|
|
8
|
+
meshsocket.egg-info/SOURCES.txt
|
|
9
|
+
meshsocket.egg-info/dependency_links.txt
|
|
10
|
+
meshsocket.egg-info/requires.txt
|
|
11
|
+
meshsocket.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "meshsocket"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Carter Beaudoin" }]
|
|
12
|
+
keywords = ["websocket", "mesh", "networking", "rpc", "pubsub", "asyncio"]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"websockets>=12",
|
|
17
|
+
"certifi",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Topic :: System :: Networking",
|
|
24
|
+
"Framework :: AsyncIO",
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/Mariner10/MeshSocket"
|
|
31
|
+
Repository = "https://github.com/Mariner10/MeshSocket"
|
|
32
|
+
|
|
33
|
+
# Explicit layout (disables flat-layout auto-discovery, which would trip over the
|
|
34
|
+
# test files and the empty package __init__ in this directory).
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
py-modules = ["socketCore", "socket_server"]
|
|
37
|
+
packages = ["meshsocket"]
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import certifi
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
import uuid
|
|
7
|
+
import time
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import websockets
|
|
11
|
+
from collections import deque
|
|
12
|
+
from typing import Callable, Dict, Any, List, Optional
|
|
13
|
+
|
|
14
|
+
# ANSI Colors for nicer logs
|
|
15
|
+
class LogColors:
|
|
16
|
+
HEADER = '\033[95m'
|
|
17
|
+
BLUE = '\033[94m'
|
|
18
|
+
GREEN = '\033[92m'
|
|
19
|
+
WARNING = '\033[93m'
|
|
20
|
+
FAIL = '\033[91m'
|
|
21
|
+
ENDC = '\033[0m'
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
|
|
24
|
+
|
|
25
|
+
class SocketLoggingHandler(logging.Handler):
|
|
26
|
+
def __init__(self, socket: 'MeshSocket'):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.socket = socket
|
|
29
|
+
self._is_logging = False # To prevent recursion
|
|
30
|
+
|
|
31
|
+
def emit(self, record):
|
|
32
|
+
if not self.socket.is_running or self._is_logging:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
log_entry = self.format(record)
|
|
36
|
+
# We use asyncio.create_task because emit is called from non-async contexts
|
|
37
|
+
try:
|
|
38
|
+
loop = asyncio.get_running_loop()
|
|
39
|
+
|
|
40
|
+
async def send_log():
|
|
41
|
+
if self._is_logging: return
|
|
42
|
+
self._is_logging = True
|
|
43
|
+
try:
|
|
44
|
+
await self.socket.send("service_log", {
|
|
45
|
+
"level": record.levelname,
|
|
46
|
+
"msg": log_entry,
|
|
47
|
+
"name": self.socket.name,
|
|
48
|
+
"timestamp": record.created
|
|
49
|
+
})
|
|
50
|
+
except Exception:
|
|
51
|
+
# Silently ignore errors in the logging handler to avoid loops
|
|
52
|
+
pass
|
|
53
|
+
finally:
|
|
54
|
+
self._is_logging = False
|
|
55
|
+
|
|
56
|
+
loop.create_task(send_log())
|
|
57
|
+
except RuntimeError:
|
|
58
|
+
# No running event loop
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
class MeshSocket:
|
|
62
|
+
def __init__(self,
|
|
63
|
+
url: str = None,
|
|
64
|
+
connection=None,
|
|
65
|
+
name="Node",
|
|
66
|
+
max_offline_buffer: int = 0,
|
|
67
|
+
offline_file_path: str = None,
|
|
68
|
+
on_reconnect: Callable = None,
|
|
69
|
+
on_disconnect: Callable = None,
|
|
70
|
+
auth_token: str = None,
|
|
71
|
+
rate_limit: int = 0,
|
|
72
|
+
channel: str = None,
|
|
73
|
+
role: str = None,
|
|
74
|
+
can_broadcast: bool | None = None,
|
|
75
|
+
can_route: bool | None = None,
|
|
76
|
+
can_cross_channel_route: bool | None = None,
|
|
77
|
+
can_monitor: bool | None = None,
|
|
78
|
+
broadcast_scope: str | None = None):
|
|
79
|
+
|
|
80
|
+
self.url = url
|
|
81
|
+
self.connection = connection
|
|
82
|
+
self.auth_token = auth_token or os.getenv('MESH_AUTH_TOKEN')
|
|
83
|
+
self.channel = channel or os.getenv("MESH_CHANNEL")
|
|
84
|
+
self.role = role or os.getenv("MESH_ROLE")
|
|
85
|
+
self.can_broadcast = can_broadcast
|
|
86
|
+
self.can_route = can_route
|
|
87
|
+
self.can_cross_channel_route = can_cross_channel_route
|
|
88
|
+
self.can_monitor = can_monitor
|
|
89
|
+
self.broadcast_scope = broadcast_scope or os.getenv("MESH_BROADCAST_SCOPE")
|
|
90
|
+
|
|
91
|
+
if name == "Node":
|
|
92
|
+
self.name = os.getenv('CONTAINER_NAME') or os.getenv('HOSTNAME') or "Node"
|
|
93
|
+
else:
|
|
94
|
+
self.name = name
|
|
95
|
+
|
|
96
|
+
self.id = str(uuid.uuid4())
|
|
97
|
+
|
|
98
|
+
# Callbacks
|
|
99
|
+
self.on_reconnect_callback = on_reconnect
|
|
100
|
+
self.on_disconnect_callback = on_disconnect
|
|
101
|
+
|
|
102
|
+
# Buffering state
|
|
103
|
+
self.max_offline_buffer = max_offline_buffer
|
|
104
|
+
self.offline_file_path = offline_file_path
|
|
105
|
+
self._ram_buffer: deque = deque()
|
|
106
|
+
|
|
107
|
+
# Rate limiting — track timestamps of last `rate_limit` messages
|
|
108
|
+
self.rate_limit = rate_limit
|
|
109
|
+
self._msg_times: Optional[deque] = deque(maxlen=rate_limit) if rate_limit > 0 else None
|
|
110
|
+
|
|
111
|
+
# State
|
|
112
|
+
self.is_running = False
|
|
113
|
+
|
|
114
|
+
# Lazy-initialised inside the event loop to avoid DeprecationWarning
|
|
115
|
+
# in Python 3.10+ when constructing outside an async context.
|
|
116
|
+
self._connected_event: Optional[asyncio.Event] = None
|
|
117
|
+
self._server_mode_connected: bool = connection is not None
|
|
118
|
+
|
|
119
|
+
# Admission gate (client mode): set when the server's `welcome` confirms
|
|
120
|
+
# our identify was accepted, so readiness/on_reconnect don't fire for a
|
|
121
|
+
# rejected identify (e.g. a duplicate name) that the server silently closes.
|
|
122
|
+
# Stays None in server mode.
|
|
123
|
+
self._admitted_event: Optional[asyncio.Event] = None
|
|
124
|
+
|
|
125
|
+
# Handlers
|
|
126
|
+
self.handlers: Dict[str, Callable] = {}
|
|
127
|
+
self._pending_requests: Dict[str, asyncio.Future] = {}
|
|
128
|
+
|
|
129
|
+
self.on("handshake", self._handle_handshake)
|
|
130
|
+
self.on("ping", self._handle_ping)
|
|
131
|
+
self.on("status_request", self._handle_status_request)
|
|
132
|
+
self.on("welcome", self._handle_welcome)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def connected_event(self) -> asyncio.Event:
|
|
136
|
+
if self._connected_event is None:
|
|
137
|
+
self._connected_event = asyncio.Event()
|
|
138
|
+
if self._server_mode_connected:
|
|
139
|
+
self._connected_event.set()
|
|
140
|
+
self._server_mode_connected = False
|
|
141
|
+
return self._connected_event
|
|
142
|
+
|
|
143
|
+
def __str__(self):
|
|
144
|
+
return f"{self.name}, {self.url}"
|
|
145
|
+
|
|
146
|
+
def on(self, type: str, func: Callable = None):
|
|
147
|
+
if func is None:
|
|
148
|
+
def wrapper(f):
|
|
149
|
+
self.handlers[type] = f
|
|
150
|
+
return f
|
|
151
|
+
return wrapper
|
|
152
|
+
self.handlers[type] = func
|
|
153
|
+
return func
|
|
154
|
+
|
|
155
|
+
def off(self, type: str):
|
|
156
|
+
self.handlers.pop(type, None)
|
|
157
|
+
|
|
158
|
+
def setup_logging(self, level=logging.INFO):
|
|
159
|
+
"""Attaches a SocketLoggingHandler to the root logger."""
|
|
160
|
+
handler = SocketLoggingHandler(self)
|
|
161
|
+
handler.setLevel(level)
|
|
162
|
+
formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
|
|
163
|
+
handler.setFormatter(formatter)
|
|
164
|
+
logging.getLogger().addHandler(handler)
|
|
165
|
+
logging.info(f"Socket logging enabled for {self.name}")
|
|
166
|
+
|
|
167
|
+
def _build_identity_payload(self) -> Dict[str, Any]:
|
|
168
|
+
payload: Dict[str, Any] = {
|
|
169
|
+
"name": self.name,
|
|
170
|
+
"token": self.auth_token
|
|
171
|
+
}
|
|
172
|
+
if self.channel:
|
|
173
|
+
payload["channel"] = self.channel
|
|
174
|
+
if self.role:
|
|
175
|
+
payload["role"] = self.role
|
|
176
|
+
if self.can_broadcast is not None:
|
|
177
|
+
payload["can_broadcast"] = self.can_broadcast
|
|
178
|
+
if self.can_route is not None:
|
|
179
|
+
payload["can_route"] = self.can_route
|
|
180
|
+
if self.can_cross_channel_route is not None:
|
|
181
|
+
payload["can_cross_channel_route"] = self.can_cross_channel_route
|
|
182
|
+
if self.can_monitor is not None:
|
|
183
|
+
payload["can_monitor"] = self.can_monitor
|
|
184
|
+
if self.broadcast_scope:
|
|
185
|
+
payload["broadcast_scope"] = self.broadcast_scope
|
|
186
|
+
return payload
|
|
187
|
+
|
|
188
|
+
async def report_status(self, metrics: Dict[str, Any] = None):
|
|
189
|
+
"""Broadcasts the current status/metrics of this node."""
|
|
190
|
+
status_payload = {
|
|
191
|
+
"name": self.name,
|
|
192
|
+
"id": self.id,
|
|
193
|
+
"status": "online",
|
|
194
|
+
"uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0,
|
|
195
|
+
"metrics": metrics or {}
|
|
196
|
+
}
|
|
197
|
+
await self.send("node_status", status_payload)
|
|
198
|
+
|
|
199
|
+
# --- CLIENT MODE METHODS ---
|
|
200
|
+
async def start(self):
|
|
201
|
+
if not self.url:
|
|
202
|
+
raise ValueError("Cannot use start() without a URL.")
|
|
203
|
+
self._start_time = time.time()
|
|
204
|
+
self.is_running = True
|
|
205
|
+
asyncio.create_task(self._maintain_connection())
|
|
206
|
+
|
|
207
|
+
async def wait_until_ready(self):
|
|
208
|
+
await self.connected_event.wait()
|
|
209
|
+
|
|
210
|
+
async def stop(self):
|
|
211
|
+
self.is_running = False
|
|
212
|
+
if self.connection:
|
|
213
|
+
await self.connection.close()
|
|
214
|
+
|
|
215
|
+
# --- SERVER MODE METHODS ---
|
|
216
|
+
async def listen(self):
|
|
217
|
+
if not self.connection:
|
|
218
|
+
raise ValueError("Server mode requires an existing connection.")
|
|
219
|
+
self._start_time = time.time()
|
|
220
|
+
try:
|
|
221
|
+
await self._listen_loop()
|
|
222
|
+
except websockets.exceptions.ConnectionClosed:
|
|
223
|
+
logging.warning(f"{self.name} connection closed normally.")
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logging.error(f"{self.name} listen error: {e}")
|
|
226
|
+
raise e
|
|
227
|
+
|
|
228
|
+
# --- INTERNAL CORE ---
|
|
229
|
+
async def _maintain_connection(self):
|
|
230
|
+
retry_delay = 2
|
|
231
|
+
while self.is_running:
|
|
232
|
+
listen_task = None
|
|
233
|
+
admitted = False
|
|
234
|
+
try:
|
|
235
|
+
logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.url}...{LogColors.ENDC}")
|
|
236
|
+
ssl_context = ssl.create_default_context(cafile=certifi.where()) if self.url.startswith("wss://") else None
|
|
237
|
+
async with websockets.connect(self.url, ssl=ssl_context) as ws:
|
|
238
|
+
self.connection = ws
|
|
239
|
+
retry_delay = 2
|
|
240
|
+
|
|
241
|
+
# Arm the admission gate before identify so a welcome delivered
|
|
242
|
+
# by the concurrently-running listen loop can't be missed.
|
|
243
|
+
self._admitted_event = asyncio.Event()
|
|
244
|
+
|
|
245
|
+
# Send identify directly on the socket: connected_event is not
|
|
246
|
+
# set yet, so the normal send() path would buffer or raise.
|
|
247
|
+
identify_packet = json.dumps({
|
|
248
|
+
"id": str(uuid.uuid4()), "type": "identify",
|
|
249
|
+
"payload": self._build_identity_payload(), "reply_to": None,
|
|
250
|
+
})
|
|
251
|
+
await ws.send(identify_packet)
|
|
252
|
+
|
|
253
|
+
# Pump messages concurrently so the server's welcome — or a close
|
|
254
|
+
# that rejects the identify (e.g. a duplicate name) — is observed
|
|
255
|
+
# while we wait for admission.
|
|
256
|
+
listen_task = asyncio.create_task(self._listen_loop())
|
|
257
|
+
admitted = await self._await_admission(listen_task, timeout=10)
|
|
258
|
+
if not admitted:
|
|
259
|
+
# Never admitted: surface as a failed attempt and retry with
|
|
260
|
+
# backoff instead of reporting the connection as up.
|
|
261
|
+
listen_task.cancel()
|
|
262
|
+
raise ConnectionError(
|
|
263
|
+
"identify not admitted (no welcome) — likely a duplicate name or rejected token"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
self.connected_event.set()
|
|
267
|
+
logging.info(f"{LogColors.GREEN}{self.name} Connected!{LogColors.ENDC}")
|
|
268
|
+
|
|
269
|
+
await self._flush_offline_queue()
|
|
270
|
+
|
|
271
|
+
if self.on_reconnect_callback:
|
|
272
|
+
try:
|
|
273
|
+
if inspect.iscoroutinefunction(self.on_reconnect_callback):
|
|
274
|
+
await self.on_reconnect_callback()
|
|
275
|
+
else:
|
|
276
|
+
self.on_reconnect_callback()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logging.error(f"Error in on_reconnect callback: {e}")
|
|
279
|
+
|
|
280
|
+
await listen_task
|
|
281
|
+
|
|
282
|
+
except Exception as e:
|
|
283
|
+
# Catch everything (not just OSError/ConnectionClosed): a server
|
|
284
|
+
# restart behind Cloudflare surfaces as InvalidStatus (HTTP 521),
|
|
285
|
+
# which previously escaped and killed this task for good.
|
|
286
|
+
logging.warning(f"{LogColors.WARNING}{self.name} disconnected: {e}{LogColors.ENDC}")
|
|
287
|
+
|
|
288
|
+
finally:
|
|
289
|
+
if listen_task and not listen_task.done():
|
|
290
|
+
listen_task.cancel()
|
|
291
|
+
self.connected_event.clear()
|
|
292
|
+
self.connection = None
|
|
293
|
+
self._fail_all_pending_requests()
|
|
294
|
+
# Pair on_disconnect to the admitted up-edge: an unadmitted attempt
|
|
295
|
+
# never reported connected, so it must not report a disconnect.
|
|
296
|
+
if admitted and self.on_disconnect_callback:
|
|
297
|
+
try:
|
|
298
|
+
if inspect.iscoroutinefunction(self.on_disconnect_callback):
|
|
299
|
+
await self.on_disconnect_callback()
|
|
300
|
+
else:
|
|
301
|
+
self.on_disconnect_callback()
|
|
302
|
+
except Exception as err:
|
|
303
|
+
logging.error(f"Error in on_disconnect callback: {err}")
|
|
304
|
+
|
|
305
|
+
if self.is_running:
|
|
306
|
+
logging.info(f"{self.name} retrying in {retry_delay}s...")
|
|
307
|
+
await asyncio.sleep(retry_delay)
|
|
308
|
+
retry_delay = min(retry_delay * 2, 30)
|
|
309
|
+
|
|
310
|
+
async def _await_admission(self, listen_task: asyncio.Task, timeout: float) -> bool:
|
|
311
|
+
"""Wait until the server's welcome admits this connection, or the socket
|
|
312
|
+
closes / times out first. Returns True only on a real admission."""
|
|
313
|
+
admit_wait = asyncio.ensure_future(self._admitted_event.wait())
|
|
314
|
+
done, _ = await asyncio.wait(
|
|
315
|
+
{admit_wait, listen_task},
|
|
316
|
+
timeout=timeout,
|
|
317
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
318
|
+
)
|
|
319
|
+
if admit_wait in done:
|
|
320
|
+
return True
|
|
321
|
+
admit_wait.cancel()
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
# --- SEND LOGIC ---
|
|
325
|
+
async def send(self, type: str, payload: Any = None, reply_to: str = None) -> Optional[str]:
|
|
326
|
+
msg_id = str(uuid.uuid4())
|
|
327
|
+
packet = {"id": msg_id, "type": type, "payload": payload, "reply_to": reply_to}
|
|
328
|
+
packet_str = json.dumps(packet)
|
|
329
|
+
|
|
330
|
+
# CASE A: Connected - Send immediately
|
|
331
|
+
if self.connection and self.connected_event.is_set():
|
|
332
|
+
try:
|
|
333
|
+
await self.connection.send(packet_str)
|
|
334
|
+
return msg_id
|
|
335
|
+
except websockets.exceptions.ConnectionClosed:
|
|
336
|
+
logging.warning(f"Send failed mid-flight, attempting to buffer '{type}'")
|
|
337
|
+
|
|
338
|
+
# CASE B: Disconnected & Buffering Enabled
|
|
339
|
+
if self.max_offline_buffer > 0:
|
|
340
|
+
await self._handle_offline_buffering(packet_str)
|
|
341
|
+
return msg_id # Return ID even if buffered
|
|
342
|
+
|
|
343
|
+
# CASE C: Disconnected & No Buffering
|
|
344
|
+
raise ConnectionError("Socket is not connected and buffering is disabled.")
|
|
345
|
+
|
|
346
|
+
async def emit(self, type: str, payload: Any = None, reply_to: str = None) -> Optional[str]:
|
|
347
|
+
return await self.send(type, payload, reply_to)
|
|
348
|
+
|
|
349
|
+
async def _handle_offline_buffering(self, packet_str: str):
|
|
350
|
+
if len(self._ram_buffer) >= self.max_offline_buffer:
|
|
351
|
+
if self.offline_file_path:
|
|
352
|
+
await self._dump_ram_to_disk()
|
|
353
|
+
await self._append_to_disk(packet_str)
|
|
354
|
+
else:
|
|
355
|
+
self._ram_buffer.popleft()
|
|
356
|
+
self._ram_buffer.append(packet_str)
|
|
357
|
+
logging.warning("Buffer full (no file path). Dropped oldest message.")
|
|
358
|
+
else:
|
|
359
|
+
self._ram_buffer.append(packet_str)
|
|
360
|
+
|
|
361
|
+
async def _dump_ram_to_disk(self):
|
|
362
|
+
if not self._ram_buffer:
|
|
363
|
+
return
|
|
364
|
+
loop = asyncio.get_running_loop()
|
|
365
|
+
await loop.run_in_executor(None, self._write_lines_to_file, list(self._ram_buffer))
|
|
366
|
+
self._ram_buffer.clear()
|
|
367
|
+
|
|
368
|
+
async def _append_to_disk(self, packet_str: str):
|
|
369
|
+
loop = asyncio.get_running_loop()
|
|
370
|
+
await loop.run_in_executor(None, self._write_lines_to_file, [packet_str])
|
|
371
|
+
|
|
372
|
+
def _write_lines_to_file(self, lines: List[str]):
|
|
373
|
+
with open(self.offline_file_path, "a") as f:
|
|
374
|
+
for line in lines:
|
|
375
|
+
f.write(line + "\n")
|
|
376
|
+
|
|
377
|
+
async def _flush_offline_queue(self):
|
|
378
|
+
if self.offline_file_path and os.path.exists(self.offline_file_path):
|
|
379
|
+
logging.info(f"{LogColors.WARNING}Flushing disk buffer...{LogColors.ENDC}")
|
|
380
|
+
try:
|
|
381
|
+
with open(self.offline_file_path, "r") as f:
|
|
382
|
+
for line in f:
|
|
383
|
+
if line.strip():
|
|
384
|
+
await self.connection.send(line.strip())
|
|
385
|
+
os.remove(self.offline_file_path)
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logging.error(f"Failed to flush disk buffer: {e}")
|
|
388
|
+
|
|
389
|
+
if self._ram_buffer:
|
|
390
|
+
logging.info(f"{LogColors.WARNING}Flushing RAM buffer ({len(self._ram_buffer)} items)...{LogColors.ENDC}")
|
|
391
|
+
while self._ram_buffer:
|
|
392
|
+
await self.connection.send(self._ram_buffer.popleft())
|
|
393
|
+
|
|
394
|
+
# --- REQUEST / RESPONSE ---
|
|
395
|
+
async def request(self, type: str, payload: Any = None, timeout: float = 5.0):
|
|
396
|
+
if self.url and not self.connected_event.is_set():
|
|
397
|
+
logging.warning(f"Request '{type}' waiting for connection...")
|
|
398
|
+
await self.connected_event.wait()
|
|
399
|
+
|
|
400
|
+
msg_id = await self.send(type, payload)
|
|
401
|
+
loop = asyncio.get_running_loop()
|
|
402
|
+
future = loop.create_future()
|
|
403
|
+
self._pending_requests[msg_id] = future
|
|
404
|
+
try:
|
|
405
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
406
|
+
except asyncio.TimeoutError:
|
|
407
|
+
self._pending_requests.pop(msg_id, None)
|
|
408
|
+
return None
|
|
409
|
+
except asyncio.CancelledError:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
def _fail_all_pending_requests(self):
|
|
413
|
+
for future in self._pending_requests.values():
|
|
414
|
+
if not future.done(): future.cancel()
|
|
415
|
+
self._pending_requests.clear()
|
|
416
|
+
|
|
417
|
+
async def _listen_loop(self):
|
|
418
|
+
async for message in self.connection:
|
|
419
|
+
if self.rate_limit > 0:
|
|
420
|
+
now = time.time()
|
|
421
|
+
if len(self._msg_times) == self.rate_limit and (now - self._msg_times[0]) < 1.0:
|
|
422
|
+
logging.warning(f"Rate limit exceeded for {self.name}, closing connection.")
|
|
423
|
+
await self.connection.close(1008, "Rate limit exceeded")
|
|
424
|
+
return
|
|
425
|
+
self._msg_times.append(now)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
data = json.loads(message)
|
|
429
|
+
asyncio.create_task(self._process_packet(data))
|
|
430
|
+
except json.JSONDecodeError:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
async def _process_packet(self, packet: dict):
|
|
434
|
+
msg_id = packet.get('id')
|
|
435
|
+
msg_type = packet.get('type')
|
|
436
|
+
payload = packet.get('payload')
|
|
437
|
+
reply_to = packet.get('reply_to')
|
|
438
|
+
if reply_to and reply_to in self._pending_requests:
|
|
439
|
+
future = self._pending_requests.pop(reply_to)
|
|
440
|
+
if not future.done(): future.set_result(payload)
|
|
441
|
+
return
|
|
442
|
+
if msg_type in self.handlers:
|
|
443
|
+
try:
|
|
444
|
+
response = await self.handlers[msg_type](payload)
|
|
445
|
+
if response is not None:
|
|
446
|
+
await self.send(type=msg_type, payload=response, reply_to=msg_id)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logging.error(f"Error processing {msg_type}: {e}")
|
|
449
|
+
|
|
450
|
+
async def _handle_welcome(self, payload):
|
|
451
|
+
server_id = payload.get("id")
|
|
452
|
+
if server_id:
|
|
453
|
+
logging.info(f"{self.name} assigned server ID: {server_id}")
|
|
454
|
+
self.id = server_id
|
|
455
|
+
# Admit the connection (client mode). None in server mode.
|
|
456
|
+
if self._admitted_event is not None:
|
|
457
|
+
self._admitted_event.set()
|
|
458
|
+
|
|
459
|
+
async def _handle_handshake(self, payload):
|
|
460
|
+
t_remote = float(payload.get('t'))
|
|
461
|
+
return {"server_id": self.id, "l": time.time() - t_remote}
|
|
462
|
+
|
|
463
|
+
async def _handle_ping(self, _):
|
|
464
|
+
return "pong"
|
|
465
|
+
|
|
466
|
+
async def _handle_status_request(self, _):
|
|
467
|
+
return {
|
|
468
|
+
"name": self.name,
|
|
469
|
+
"id": self.id,
|
|
470
|
+
"status": "online",
|
|
471
|
+
"uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0,
|
|
472
|
+
"memory_usage": "unknown"
|
|
473
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
import websockets
|
|
4
|
+
from socketCore import MeshSocket, LogColors
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
_MAX_PENDING_PER_IP = 10
|
|
10
|
+
_pending_conns: Dict[str, int] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MeshServer:
|
|
14
|
+
def __init__(self,
|
|
15
|
+
host: str = "0.0.0.0",
|
|
16
|
+
port: int = 8765,
|
|
17
|
+
rate_limit: int = 0,
|
|
18
|
+
max_size: int = 1_048_576,
|
|
19
|
+
auth_handler: Optional[Callable] = None,
|
|
20
|
+
on_startup: Optional[Callable] = None,
|
|
21
|
+
on_authenticated: Optional[Callable] = None):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.port = port
|
|
24
|
+
self.rate_limit = rate_limit
|
|
25
|
+
self.max_size = max_size
|
|
26
|
+
|
|
27
|
+
# auth_handler(token, remote_ip) → bool
|
|
28
|
+
# Default: compare against MESH_AUTH_TOKEN env var.
|
|
29
|
+
self._auth_handler = auth_handler or self._default_auth
|
|
30
|
+
# on_startup() — called once before the server starts accepting connections.
|
|
31
|
+
self._on_startup = on_startup
|
|
32
|
+
# on_authenticated(client, remote_ip, token) — called after a client passes auth.
|
|
33
|
+
self._on_authenticated = on_authenticated
|
|
34
|
+
|
|
35
|
+
self.clients: Dict[str, MeshSocket] = {}
|
|
36
|
+
self.clients_by_name: Dict[str, MeshSocket] = {}
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _default_auth(token: str, remote_ip: str) -> bool:
|
|
40
|
+
server_token = os.getenv("MESH_AUTH_TOKEN")
|
|
41
|
+
if not server_token:
|
|
42
|
+
return True
|
|
43
|
+
return token == server_token
|
|
44
|
+
|
|
45
|
+
def _parse_bool(self, value: Any, default: bool = False) -> bool:
|
|
46
|
+
if isinstance(value, bool):
|
|
47
|
+
return value
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
def _same_channel(self, source: MeshSocket, target: MeshSocket) -> bool:
|
|
53
|
+
return getattr(source, "channel", "default") == getattr(target, "channel", "default")
|
|
54
|
+
|
|
55
|
+
def _can_receive_broadcast(self, source: MeshSocket, target: MeshSocket) -> bool:
|
|
56
|
+
if getattr(source, "broadcast_scope", "channel") == "global":
|
|
57
|
+
return True
|
|
58
|
+
return self._same_channel(source, target)
|
|
59
|
+
|
|
60
|
+
def _client_summary(self, client: MeshSocket) -> Dict[str, str]:
|
|
61
|
+
return {
|
|
62
|
+
"id": client.id,
|
|
63
|
+
"name": client.name,
|
|
64
|
+
"channel": getattr(client, "channel", "default"),
|
|
65
|
+
"role": getattr(client, "role", "node"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def _client_ip(self, websocket) -> str:
|
|
69
|
+
request_headers = getattr(websocket, "request_headers", None)
|
|
70
|
+
if request_headers is None and getattr(websocket, "request", None) is not None:
|
|
71
|
+
request_headers = websocket.request.headers
|
|
72
|
+
|
|
73
|
+
if request_headers:
|
|
74
|
+
x_real_ip = request_headers.get("X-Real-IP")
|
|
75
|
+
if x_real_ip:
|
|
76
|
+
return x_real_ip.strip()
|
|
77
|
+
|
|
78
|
+
x_forwarded_for = request_headers.get("X-Forwarded-For")
|
|
79
|
+
if x_forwarded_for:
|
|
80
|
+
first_hop = x_forwarded_for.split(",", 1)[0].strip()
|
|
81
|
+
if first_hop:
|
|
82
|
+
return first_hop
|
|
83
|
+
|
|
84
|
+
if websocket.remote_address:
|
|
85
|
+
return websocket.remote_address[0]
|
|
86
|
+
return "unknown"
|
|
87
|
+
|
|
88
|
+
async def start(self):
|
|
89
|
+
if self._on_startup:
|
|
90
|
+
self._on_startup()
|
|
91
|
+
logging.info(f"{LogColors.HEADER}Starting Server on ws://{self.host}:{self.port}{LogColors.ENDC}")
|
|
92
|
+
async with websockets.serve(
|
|
93
|
+
self._handle_connection,
|
|
94
|
+
self.host,
|
|
95
|
+
self.port,
|
|
96
|
+
max_size=self.max_size,
|
|
97
|
+
):
|
|
98
|
+
await asyncio.Future()
|
|
99
|
+
|
|
100
|
+
async def _handle_connection(self, websocket):
|
|
101
|
+
remote_ip = self._client_ip(websocket)
|
|
102
|
+
logging.info(f"New connection from {remote_ip}")
|
|
103
|
+
|
|
104
|
+
pending = _pending_conns.get(remote_ip, 0)
|
|
105
|
+
if pending >= _MAX_PENDING_PER_IP:
|
|
106
|
+
logging.warning(
|
|
107
|
+
f"{LogColors.FAIL}Rejected {remote_ip} — "
|
|
108
|
+
f"{pending} pending connections already{LogColors.ENDC}"
|
|
109
|
+
)
|
|
110
|
+
await websocket.close(4429, "Too many pending connections")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
_pending_conns[remote_ip] = pending + 1
|
|
114
|
+
try:
|
|
115
|
+
client = MeshSocket(
|
|
116
|
+
connection=websocket,
|
|
117
|
+
name=f"Client-{id(websocket)}",
|
|
118
|
+
rate_limit=self.rate_limit,
|
|
119
|
+
)
|
|
120
|
+
client.channel = "default"
|
|
121
|
+
client.role = "unknown"
|
|
122
|
+
client.can_broadcast = False
|
|
123
|
+
client.can_route = False
|
|
124
|
+
client.can_cross_channel_route = False
|
|
125
|
+
client.can_monitor = False
|
|
126
|
+
client.broadcast_scope = "channel"
|
|
127
|
+
|
|
128
|
+
allowed_origins = {
|
|
129
|
+
origin.strip()
|
|
130
|
+
for origin in os.getenv(
|
|
131
|
+
"MESH_ALLOWED_ORIGINS",
|
|
132
|
+
"http://127.0.0.1,http://localhost"
|
|
133
|
+
).split(",")
|
|
134
|
+
if origin.strip()
|
|
135
|
+
}
|
|
136
|
+
request_headers = getattr(websocket, "request_headers", None)
|
|
137
|
+
if request_headers is None and getattr(websocket, "request", None) is not None:
|
|
138
|
+
request_headers = websocket.request.headers
|
|
139
|
+
origin = request_headers.get("Origin") if request_headers else None
|
|
140
|
+
if origin and not any(origin == allowed or origin.startswith(allowed) for allowed in allowed_origins):
|
|
141
|
+
logging.warning(f"{LogColors.FAIL}Origin rejected: {origin}{LogColors.ENDC}")
|
|
142
|
+
await websocket.close(1008, "origin not allowed")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
authenticated: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
146
|
+
|
|
147
|
+
@client.on('identify')
|
|
148
|
+
async def handle_identify(payload):
|
|
149
|
+
payload = payload or {}
|
|
150
|
+
requested_name = payload.get('name', client.name)
|
|
151
|
+
client_token = payload.get('token', '')
|
|
152
|
+
|
|
153
|
+
if not self._auth_handler(client_token, remote_ip):
|
|
154
|
+
logging.warning(
|
|
155
|
+
f"{LogColors.FAIL}Auth failed (bad/missing token) for {remote_ip}{LogColors.ENDC}"
|
|
156
|
+
)
|
|
157
|
+
if not authenticated.done():
|
|
158
|
+
authenticated.set_result(False)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
incumbent = self.clients_by_name.get(requested_name)
|
|
162
|
+
if incumbent is not None and incumbent is not client:
|
|
163
|
+
# Last-writer-wins: a reconnecting peer reclaims its name. The
|
|
164
|
+
# old socket is usually a half-open ghost the server hasn't
|
|
165
|
+
# reaped yet; rejecting the newcomer instead would leave it
|
|
166
|
+
# silently retrying forever. Drop the incumbent from the roster
|
|
167
|
+
# now (so the newcomer can register cleanly below) and close it
|
|
168
|
+
# out of band. The identity-checked cleanup in the connection
|
|
169
|
+
# finally keeps the ghost's teardown from clobbering this entry.
|
|
170
|
+
logging.warning(
|
|
171
|
+
f"{LogColors.WARNING}Name '{requested_name}' reclaimed by {remote_ip} — "
|
|
172
|
+
f"evicting stale client {incumbent.id}{LogColors.ENDC}"
|
|
173
|
+
)
|
|
174
|
+
self.clients.pop(incumbent.id, None)
|
|
175
|
+
self.clients_by_name.pop(requested_name, None)
|
|
176
|
+
asyncio.create_task(incumbent.stop())
|
|
177
|
+
|
|
178
|
+
new_id = str(uuid.uuid4())
|
|
179
|
+
while new_id in self.clients:
|
|
180
|
+
new_id = str(uuid.uuid4())
|
|
181
|
+
|
|
182
|
+
client.id = new_id
|
|
183
|
+
client.name = requested_name
|
|
184
|
+
client.channel = payload.get('channel') or "default"
|
|
185
|
+
client.role = payload.get('role') or "node"
|
|
186
|
+
client.can_broadcast = self._parse_bool(payload.get('can_broadcast'), client.role in {"dashboard", "browser", "mobile", "node"})
|
|
187
|
+
client.can_route = self._parse_bool(payload.get('can_route'), client.role in {"browser", "mobile", "dashboard", "node"})
|
|
188
|
+
client.can_cross_channel_route = self._parse_bool(payload.get('can_cross_channel_route'), False)
|
|
189
|
+
client.can_monitor = self._parse_bool(payload.get('can_monitor'), client.role in {"dashboard", "browser"})
|
|
190
|
+
client.broadcast_scope = payload.get('broadcast_scope') or ("global" if client.can_monitor else "channel")
|
|
191
|
+
|
|
192
|
+
logging.info(
|
|
193
|
+
f"{LogColors.GREEN}Identified: '{client.name}' → {client.id}{LogColors.ENDC}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if self._on_authenticated:
|
|
197
|
+
self._on_authenticated(client, remote_ip, client_token)
|
|
198
|
+
|
|
199
|
+
if not authenticated.done():
|
|
200
|
+
authenticated.set_result(True)
|
|
201
|
+
|
|
202
|
+
await client.send("welcome", {"id": client.id, "name": client.name})
|
|
203
|
+
await self._broadcast_client_list()
|
|
204
|
+
|
|
205
|
+
# Register handlers BEFORE the listen loop starts so the first
|
|
206
|
+
# request after the identify ack can't race past registration and
|
|
207
|
+
# get dropped. Permission flags stay False until identify, so
|
|
208
|
+
# nothing here is reachable pre-auth.
|
|
209
|
+
@client.on("broadcast_request")
|
|
210
|
+
async def on_broadcast(payload):
|
|
211
|
+
if not client.can_broadcast:
|
|
212
|
+
return {"error": "broadcast not allowed", "status": "failed"}
|
|
213
|
+
await self.broadcast("broadcast", payload, sender=client)
|
|
214
|
+
return {"status": "sent"}
|
|
215
|
+
|
|
216
|
+
@client.on("iCloud_data_Broadcast")
|
|
217
|
+
async def on_iCloud_broadcast(payload):
|
|
218
|
+
if not client.can_broadcast:
|
|
219
|
+
return {"error": "broadcast not allowed", "status": "failed"}
|
|
220
|
+
await self.broadcast("iCloudListen", payload, sender=client)
|
|
221
|
+
return {"status": "sent"}
|
|
222
|
+
|
|
223
|
+
@client.on("place_visit_Broadcast")
|
|
224
|
+
async def on_place_visit_broadcast(payload):
|
|
225
|
+
if not client.can_broadcast:
|
|
226
|
+
return {"error": "broadcast not allowed", "status": "failed"}
|
|
227
|
+
await self.broadcast("placeVisitListen", payload, sender=client)
|
|
228
|
+
return {"status": "sent"}
|
|
229
|
+
|
|
230
|
+
@client.on("request_prediction")
|
|
231
|
+
async def on_prediction_request(payload):
|
|
232
|
+
if not client.can_broadcast:
|
|
233
|
+
return {"error": "broadcast not allowed", "status": "failed"}
|
|
234
|
+
await self.broadcast("request_prediction", payload, sender=client)
|
|
235
|
+
return {"status": "forwarded"}
|
|
236
|
+
|
|
237
|
+
@client.on("prediction_result")
|
|
238
|
+
async def on_prediction_result(payload):
|
|
239
|
+
if not client.can_broadcast:
|
|
240
|
+
return {"error": "broadcast not allowed", "status": "failed"}
|
|
241
|
+
await self.broadcast("prediction_result", payload, sender=client)
|
|
242
|
+
return {"status": "forwarded"}
|
|
243
|
+
|
|
244
|
+
@client.on("service_log")
|
|
245
|
+
async def on_service_log(payload):
|
|
246
|
+
if not client.can_broadcast:
|
|
247
|
+
return {"error": "log broadcast not allowed", "status": "failed"}
|
|
248
|
+
await self.broadcast("service_log", payload, sender=client)
|
|
249
|
+
return {"status": "broadcasted"}
|
|
250
|
+
|
|
251
|
+
@client.on("node_status")
|
|
252
|
+
async def on_node_status(payload):
|
|
253
|
+
await self.broadcast("node_status", payload, sender=client)
|
|
254
|
+
return {"status": "broadcasted"}
|
|
255
|
+
|
|
256
|
+
@client.on('route_msg')
|
|
257
|
+
async def on_route(payload):
|
|
258
|
+
target_id = payload.get('target_id')
|
|
259
|
+
msg_type = payload.get('type')
|
|
260
|
+
data = payload.get('payload')
|
|
261
|
+
|
|
262
|
+
target = self.clients.get(target_id)
|
|
263
|
+
if target:
|
|
264
|
+
if not client.can_route:
|
|
265
|
+
return {"error": "routing not allowed", "status": "failed"}
|
|
266
|
+
if not client.can_cross_channel_route and not self._same_channel(client, target):
|
|
267
|
+
return {"error": "cross-channel route denied", "status": "failed"}
|
|
268
|
+
response = await target.request(msg_type, data, timeout=5.0)
|
|
269
|
+
return response
|
|
270
|
+
return {"error": "Target not found", "status": "failed"}
|
|
271
|
+
|
|
272
|
+
@client.on("get_nodes")
|
|
273
|
+
async def on_get_nodes(payload):
|
|
274
|
+
client_list = [
|
|
275
|
+
self._client_summary(c)
|
|
276
|
+
for c in self.clients.values()
|
|
277
|
+
if c.id != client.id and (client.can_monitor and (client.broadcast_scope == "global" or self._same_channel(client, c)))
|
|
278
|
+
]
|
|
279
|
+
return {"clients": client_list}
|
|
280
|
+
|
|
281
|
+
@client.on('route_msg_noreply')
|
|
282
|
+
async def on_noreply_route(payload):
|
|
283
|
+
target_name = payload.get('target_name')
|
|
284
|
+
msg_type = payload.get('type')
|
|
285
|
+
data = payload.get('payload')
|
|
286
|
+
|
|
287
|
+
target = self.clients_by_name.get(target_name)
|
|
288
|
+
if target:
|
|
289
|
+
if not client.can_route:
|
|
290
|
+
return {"error": "routing not allowed", "status": "failed"}
|
|
291
|
+
if not client.can_cross_channel_route and not self._same_channel(client, target):
|
|
292
|
+
return {"error": "cross-channel route denied", "status": "failed"}
|
|
293
|
+
await target.send(msg_type, data)
|
|
294
|
+
else:
|
|
295
|
+
return {"error": "Target not found", "status": "failed"}
|
|
296
|
+
|
|
297
|
+
listen_task = asyncio.create_task(client.listen())
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
is_auth = await asyncio.wait_for(authenticated, timeout=5.0)
|
|
301
|
+
except asyncio.TimeoutError:
|
|
302
|
+
logging.warning(
|
|
303
|
+
f"{LogColors.FAIL}Auth timeout for {remote_ip} — closing silently{LogColors.ENDC}"
|
|
304
|
+
)
|
|
305
|
+
listen_task.cancel()
|
|
306
|
+
await client.stop()
|
|
307
|
+
return
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logging.error(f"Auth error for {remote_ip}: {e}")
|
|
310
|
+
listen_task.cancel()
|
|
311
|
+
await client.stop()
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
if not is_auth:
|
|
315
|
+
listen_task.cancel()
|
|
316
|
+
await client.stop()
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
self.clients[client.id] = client
|
|
320
|
+
self.clients_by_name[client.name] = client
|
|
321
|
+
logging.info(
|
|
322
|
+
f"{LogColors.GREEN}'{client.name}' connected. "
|
|
323
|
+
f"Total clients: {len(self.clients)}{LogColors.ENDC}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
await listen_task
|
|
329
|
+
finally:
|
|
330
|
+
self.clients.pop(client.id, None)
|
|
331
|
+
# Only release the name if it still points at *this* client — an
|
|
332
|
+
# evicted incumbent must not pop the entry of the peer that just
|
|
333
|
+
# reclaimed its name (see the eviction path in handle_identify).
|
|
334
|
+
if self.clients_by_name.get(client.name) is client:
|
|
335
|
+
self.clients_by_name.pop(client.name, None)
|
|
336
|
+
logging.info(
|
|
337
|
+
f"{LogColors.WARNING}'{client.name}' disconnected. "
|
|
338
|
+
f"Remaining: {len(self.clients)}{LogColors.ENDC}"
|
|
339
|
+
)
|
|
340
|
+
await self._broadcast_client_list()
|
|
341
|
+
finally:
|
|
342
|
+
_pending_conns[remote_ip] = max(0, _pending_conns.get(remote_ip, 1) - 1)
|
|
343
|
+
|
|
344
|
+
async def _broadcast_client_list(self):
|
|
345
|
+
for client in self.clients.values():
|
|
346
|
+
if not getattr(client, "can_monitor", False):
|
|
347
|
+
continue
|
|
348
|
+
if getattr(client, "broadcast_scope", "channel") == "global":
|
|
349
|
+
visible_clients = [self._client_summary(peer) for peer in self.clients.values()]
|
|
350
|
+
else:
|
|
351
|
+
visible_clients = [self._client_summary(peer) for peer in self.clients.values() if self._same_channel(client, peer)]
|
|
352
|
+
await client.send('server_client_list', {'clients': visible_clients})
|
|
353
|
+
|
|
354
|
+
async def broadcast(self, type: str, payload: dict, sender: MeshSocket | None = None):
|
|
355
|
+
if not self.clients:
|
|
356
|
+
return
|
|
357
|
+
tasks = []
|
|
358
|
+
for peer in self.clients.values():
|
|
359
|
+
if sender and not self._can_receive_broadcast(sender, peer):
|
|
360
|
+
continue
|
|
361
|
+
tasks.append(peer.send(type, payload))
|
|
362
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
server = MeshServer()
|
|
367
|
+
try:
|
|
368
|
+
asyncio.run(server.start())
|
|
369
|
+
except KeyboardInterrupt:
|
|
370
|
+
print("Server stopped.")
|