nodalwire 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.
- nodalwire-0.1.0/LICENSE +21 -0
- nodalwire-0.1.0/PKG-INFO +20 -0
- nodalwire-0.1.0/README.md +7 -0
- nodalwire-0.1.0/pyproject.toml +46 -0
- nodalwire-0.1.0/src/nodalwire/__init__.py +16 -0
- nodalwire-0.1.0/src/nodalwire/__main__.py +3 -0
- nodalwire-0.1.0/src/nodalwire/cli.py +47 -0
- nodalwire-0.1.0/src/nodalwire/client/__init__.py +5 -0
- nodalwire-0.1.0/src/nodalwire/client/python/__init__.py +5 -0
- nodalwire-0.1.0/src/nodalwire/client/python/node.py +275 -0
- nodalwire-0.1.0/src/nodalwire/config.py +121 -0
- nodalwire-0.1.0/src/nodalwire/protocol.py +33 -0
- nodalwire-0.1.0/src/nodalwire/server/__init__.py +5 -0
- nodalwire-0.1.0/src/nodalwire/server/relay.py +207 -0
nodalwire-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nitipit Nontasuwan
|
|
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.
|
nodalwire-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodalwire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A bounded native ZeroMQ byte relay for trusted local Python applications.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: cyclopts>=4.11.1,<5
|
|
8
|
+
Requires-Dist: pyzmq>=27.1.0
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: Repository, https://github.com/keenlycode/nodalwire
|
|
11
|
+
Project-URL: Issues, https://github.com/keenlycode/nodalwire/issues
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# NodalWire
|
|
15
|
+
|
|
16
|
+
A local byte relay, designed for AI agents to use and maintain.
|
|
17
|
+
|
|
18
|
+
- Usage: [tests/usage/](tests/usage/)
|
|
19
|
+
- Python: [src/nodalwire/](src/nodalwire/)
|
|
20
|
+
- Node.js: [src/nodalwire/client/node/](src/nodalwire/client/node/)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nodalwire"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A bounded native ZeroMQ byte relay for trusted local Python applications."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"cyclopts>=4.11.1,<5",
|
|
11
|
+
"pyzmq>=27.1.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/keenlycode/nodalwire"
|
|
16
|
+
Issues = "https://github.com/keenlycode/nodalwire/issues"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
nodalwire = "nodalwire.cli:app"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=9.0.3,<9.1",
|
|
24
|
+
"ruff>=0.8.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-name = "nodalwire"
|
|
33
|
+
source-exclude = ["**/__pycache__/**", "src/nodalwire/client/node"]
|
|
34
|
+
wheel-exclude = ["nodalwire/client/node"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
extend-exclude = ["src/nodalwire/client/node"]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py312"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
addopts = ["--import-mode=importlib"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""NodalWire: a central byte relay for trusted local Python applications."""
|
|
2
|
+
|
|
3
|
+
from .client import Message, Node
|
|
4
|
+
from .config import RelayConfig, load_config
|
|
5
|
+
from .protocol import ReceiptTimeout, RelayError
|
|
6
|
+
from .server import Relay
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Message",
|
|
10
|
+
"Node",
|
|
11
|
+
"ReceiptTimeout",
|
|
12
|
+
"Relay",
|
|
13
|
+
"RelayConfig",
|
|
14
|
+
"RelayError",
|
|
15
|
+
"load_config",
|
|
16
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Small foreground service; configuration is loaded once at startup."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import signal
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cyclopts import App
|
|
8
|
+
|
|
9
|
+
from .config import RelayConfig, load_config
|
|
10
|
+
from .server import Relay
|
|
11
|
+
|
|
12
|
+
app = App(help="Run the loopback-only byte relay for trusted local applications.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _serve(config: RelayConfig) -> None:
|
|
16
|
+
relay = Relay(config)
|
|
17
|
+
endpoint = await relay.start()
|
|
18
|
+
stop = asyncio.Event()
|
|
19
|
+
loop = asyncio.get_running_loop()
|
|
20
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
21
|
+
loop.add_signal_handler(sig, stop.set)
|
|
22
|
+
runner = asyncio.create_task(relay.run())
|
|
23
|
+
waiter = asyncio.create_task(stop.wait())
|
|
24
|
+
print(endpoint, flush=True)
|
|
25
|
+
try:
|
|
26
|
+
completed, _ = await asyncio.wait([runner, waiter], return_when=asyncio.FIRST_COMPLETED)
|
|
27
|
+
if runner in completed:
|
|
28
|
+
await runner # Surface a service failure, not a false successful shutdown.
|
|
29
|
+
finally:
|
|
30
|
+
runner.cancel()
|
|
31
|
+
waiter.cancel()
|
|
32
|
+
await asyncio.gather(runner, waiter, return_exceptions=True)
|
|
33
|
+
await relay.close()
|
|
34
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
35
|
+
loop.remove_signal_handler(sig)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.default
|
|
39
|
+
def main(*, config: Path | None = None) -> None:
|
|
40
|
+
"""Start the relay; prints the bound endpoint after native bind succeeds.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
config
|
|
45
|
+
Startup cooperative TOML policy. Default: open admission on loopback.
|
|
46
|
+
"""
|
|
47
|
+
asyncio.run(_serve(load_config(config) if config is not None else RelayConfig()))
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Async DEALER node with one bounded inbox and transport-owned receipt correlation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import zmq
|
|
8
|
+
import zmq.asyncio
|
|
9
|
+
|
|
10
|
+
from ...config import local_endpoint
|
|
11
|
+
from ...protocol import (
|
|
12
|
+
CLOSE,
|
|
13
|
+
DATA,
|
|
14
|
+
ERROR,
|
|
15
|
+
HEARTBEAT,
|
|
16
|
+
HELLO,
|
|
17
|
+
OK,
|
|
18
|
+
SEND,
|
|
19
|
+
VERSION,
|
|
20
|
+
ReceiptTimeout,
|
|
21
|
+
RelayError,
|
|
22
|
+
valid_name,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Message:
|
|
28
|
+
sender: str
|
|
29
|
+
payload: bytes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Node:
|
|
33
|
+
"""Connect by logical name; send bytes, receive sender+bytes, and close.
|
|
34
|
+
|
|
35
|
+
Native queues, inbox and outstanding requests are bounded. When the inbox is
|
|
36
|
+
full, reading pauses (including receipts); drain it concurrently with sending.
|
|
37
|
+
Socket queue acceptance is not application delivery or execution.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
name: str,
|
|
43
|
+
endpoint: str,
|
|
44
|
+
*,
|
|
45
|
+
timeout: float = 5.0,
|
|
46
|
+
send_hwm: int = 64,
|
|
47
|
+
receive_hwm: int = 64,
|
|
48
|
+
inbox_size: int = 64,
|
|
49
|
+
max_pending: int = 128,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not valid_name(name):
|
|
52
|
+
raise ValueError("invalid logical node name")
|
|
53
|
+
if timeout <= 0:
|
|
54
|
+
raise ValueError("timeout must be positive")
|
|
55
|
+
for value in (send_hwm, receive_hwm, inbox_size, max_pending):
|
|
56
|
+
if type(value) is not int or value <= 0:
|
|
57
|
+
raise ValueError("queue limits must be positive integers")
|
|
58
|
+
endpoint = local_endpoint(endpoint)
|
|
59
|
+
self.name = name
|
|
60
|
+
self.endpoint = endpoint
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.max_pending = max_pending
|
|
63
|
+
self.context = zmq.asyncio.Context()
|
|
64
|
+
self.socket = self.context.socket(zmq.DEALER)
|
|
65
|
+
self.socket.setsockopt(zmq.LINGER, 0)
|
|
66
|
+
self.socket.setsockopt(zmq.IPV6, int(endpoint.startswith("tcp://[")))
|
|
67
|
+
self.socket.setsockopt(zmq.IDENTITY, secrets.token_bytes(16))
|
|
68
|
+
self.socket.setsockopt(zmq.IMMEDIATE, 1)
|
|
69
|
+
self.socket.setsockopt(zmq.SNDHWM, send_hwm)
|
|
70
|
+
self.socket.setsockopt(zmq.RCVHWM, receive_hwm)
|
|
71
|
+
self._inbox: asyncio.Queue[Message] = asyncio.Queue(maxsize=inbox_size)
|
|
72
|
+
self._pending: dict[bytes, tuple[bytes, asyncio.Future[tuple[bytes, ...]]]] = {}
|
|
73
|
+
self._receiver: asyncio.Task[None] | None = None
|
|
74
|
+
self._heartbeat: asyncio.Task[None] | None = None
|
|
75
|
+
self._token: bytes | None = None
|
|
76
|
+
self._max_payload = 0
|
|
77
|
+
self._closed = asyncio.Event()
|
|
78
|
+
self._cleanup_done = asyncio.Event()
|
|
79
|
+
self._cleanup_task: asyncio.Task[None] | None = None
|
|
80
|
+
self._closing = False
|
|
81
|
+
try:
|
|
82
|
+
self.socket.connect(endpoint)
|
|
83
|
+
except BaseException:
|
|
84
|
+
self.socket.close(linger=0)
|
|
85
|
+
self.context.term()
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
async def connect(cls, name: str, endpoint: str = "tcp://127.0.0.1:5555", **options) -> "Node":
|
|
90
|
+
"""Await admission; failure closes owned resources. No reconnect or replay."""
|
|
91
|
+
node = cls(name, endpoint, **options)
|
|
92
|
+
node._receiver = asyncio.create_task(node._read(), name=f"relay-inbox-{name}")
|
|
93
|
+
try:
|
|
94
|
+
token, lease, payload_limit = await node._request(
|
|
95
|
+
HELLO, name.encode("ascii"), initial=True
|
|
96
|
+
)
|
|
97
|
+
node._token = token
|
|
98
|
+
node._max_payload = int(payload_limit)
|
|
99
|
+
node.socket.setsockopt(zmq.MAXMSGSIZE, node._max_payload + 4096)
|
|
100
|
+
node._heartbeat = asyncio.create_task(
|
|
101
|
+
node._beat(float(lease) / 3), name=f"relay-heartbeat-{name}"
|
|
102
|
+
)
|
|
103
|
+
return node
|
|
104
|
+
except BaseException:
|
|
105
|
+
await node._cleanup()
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
async def _request(
|
|
109
|
+
self,
|
|
110
|
+
command: bytes,
|
|
111
|
+
*frames: bytes,
|
|
112
|
+
initial: bool = False,
|
|
113
|
+
timeout: float | None = None,
|
|
114
|
+
) -> tuple[bytes, ...]:
|
|
115
|
+
if self._closed.is_set():
|
|
116
|
+
raise RelayError("closed")
|
|
117
|
+
if len(self._pending) >= self.max_pending:
|
|
118
|
+
raise RelayError("pending_full")
|
|
119
|
+
request = secrets.token_hex(16).encode("ascii")
|
|
120
|
+
token = b"" if initial else self._token
|
|
121
|
+
assert token is not None
|
|
122
|
+
future = asyncio.get_running_loop().create_future()
|
|
123
|
+
self._pending[request] = (token, future)
|
|
124
|
+
body = [VERSION, command]
|
|
125
|
+
if not initial:
|
|
126
|
+
body.append(token)
|
|
127
|
+
body.extend([request, *frames])
|
|
128
|
+
try:
|
|
129
|
+
async with asyncio.timeout(self.timeout if timeout is None else timeout):
|
|
130
|
+
try:
|
|
131
|
+
await self.socket.send_multipart(body, flags=0 if initial else zmq.DONTWAIT)
|
|
132
|
+
except zmq.Again as exc:
|
|
133
|
+
raise RelayError("local_queue_full") from exc
|
|
134
|
+
return await future
|
|
135
|
+
except TimeoutError as exc:
|
|
136
|
+
raise ReceiptTimeout("routing receipt timed out; effect uncertain") from exc
|
|
137
|
+
finally:
|
|
138
|
+
self._pending.pop(request, None)
|
|
139
|
+
if not future.done():
|
|
140
|
+
future.cancel()
|
|
141
|
+
elif not future.cancelled():
|
|
142
|
+
future.exception() # Consume a concurrent close/receiver failure.
|
|
143
|
+
|
|
144
|
+
async def _read(self) -> None:
|
|
145
|
+
try:
|
|
146
|
+
while True:
|
|
147
|
+
frames = await self.socket.recv_multipart()
|
|
148
|
+
if len(frames) < 4 or frames[0] != VERSION:
|
|
149
|
+
continue
|
|
150
|
+
command, token = frames[1:3]
|
|
151
|
+
if command == DATA and len(frames) == 5 and token == self._token:
|
|
152
|
+
sender = frames[3].decode("ascii")
|
|
153
|
+
if valid_name(sender) and len(frames[4]) <= self._max_payload:
|
|
154
|
+
await self._inbox.put(Message(sender, frames[4]))
|
|
155
|
+
continue
|
|
156
|
+
request = frames[3]
|
|
157
|
+
pending = self._pending.get(request)
|
|
158
|
+
if pending is None or pending[0] != token or pending[1].done():
|
|
159
|
+
continue
|
|
160
|
+
future = pending[1]
|
|
161
|
+
if command == OK:
|
|
162
|
+
extra = tuple(frames[4:])
|
|
163
|
+
if token == b"" and len(extra) == 3:
|
|
164
|
+
# Install incarnation before any DATA already behind HELLO's reply.
|
|
165
|
+
self._token = extra[0]
|
|
166
|
+
self._max_payload = int(extra[2])
|
|
167
|
+
future.set_result(extra)
|
|
168
|
+
elif command == ERROR and len(frames) == 5:
|
|
169
|
+
future.set_exception(RelayError(frames[4].decode("ascii")))
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
raise
|
|
172
|
+
except Exception:
|
|
173
|
+
self._closed.set()
|
|
174
|
+
self._fail_pending("connection_failed")
|
|
175
|
+
|
|
176
|
+
def _fail_pending(self, code: str) -> None:
|
|
177
|
+
for _, future in self._pending.values():
|
|
178
|
+
if not future.done():
|
|
179
|
+
future.set_exception(RelayError(code))
|
|
180
|
+
|
|
181
|
+
async def _beat(self, interval: float) -> None:
|
|
182
|
+
assert self._token is not None
|
|
183
|
+
while True:
|
|
184
|
+
await asyncio.sleep(interval)
|
|
185
|
+
try:
|
|
186
|
+
await self.socket.send_multipart(
|
|
187
|
+
[VERSION, HEARTBEAT, self._token], flags=zmq.DONTWAIT
|
|
188
|
+
)
|
|
189
|
+
except zmq.Again:
|
|
190
|
+
pass # No heartbeat backlog; lease may expire under local congestion.
|
|
191
|
+
|
|
192
|
+
async def send(self, destination: str, payload: bytes, *, timeout: float | None = None) -> None:
|
|
193
|
+
"""Wait for Router queue acceptance or status (never remote execution).
|
|
194
|
+
|
|
195
|
+
Timeout, cancellation and interrupted lifecycle have uncertain effects.
|
|
196
|
+
"""
|
|
197
|
+
if self._closing or self._closed.is_set():
|
|
198
|
+
raise RelayError("closed")
|
|
199
|
+
if not valid_name(destination):
|
|
200
|
+
raise ValueError("invalid destination name")
|
|
201
|
+
if not isinstance(payload, bytes):
|
|
202
|
+
raise TypeError("payload must be bytes")
|
|
203
|
+
if len(payload) > self._max_payload:
|
|
204
|
+
raise RelayError("payload_too_large")
|
|
205
|
+
await self._request(SEND, destination.encode("ascii"), payload, timeout=timeout)
|
|
206
|
+
|
|
207
|
+
async def receive(self) -> Message:
|
|
208
|
+
"""Read the single inbox; queued data remains readable after close."""
|
|
209
|
+
if not self._inbox.empty():
|
|
210
|
+
return self._inbox.get_nowait()
|
|
211
|
+
if self._closed.is_set():
|
|
212
|
+
raise RelayError("closed")
|
|
213
|
+
message = asyncio.create_task(self._inbox.get())
|
|
214
|
+
closed = asyncio.create_task(self._closed.wait())
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.wait([message, closed], return_when=asyncio.FIRST_COMPLETED)
|
|
217
|
+
if message.done():
|
|
218
|
+
return message.result()
|
|
219
|
+
raise RelayError("closed")
|
|
220
|
+
finally:
|
|
221
|
+
for task in (message, closed):
|
|
222
|
+
task.cancel()
|
|
223
|
+
await asyncio.gather(message, closed, return_exceptions=True)
|
|
224
|
+
|
|
225
|
+
async def _cleanup(self) -> None:
|
|
226
|
+
# Cleanup has independent ownership: cancellation of a close/connect caller
|
|
227
|
+
# must not interrupt joins or leave native resources and tasks behind.
|
|
228
|
+
if self._cleanup_task is None:
|
|
229
|
+
self._cleanup_task = asyncio.create_task(
|
|
230
|
+
self._dispose(), name=f"relay-close-{self.name}"
|
|
231
|
+
)
|
|
232
|
+
cancelled = False
|
|
233
|
+
while True:
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.shield(self._cleanup_task)
|
|
236
|
+
break
|
|
237
|
+
except asyncio.CancelledError:
|
|
238
|
+
cancelled = True
|
|
239
|
+
if cancelled:
|
|
240
|
+
raise asyncio.CancelledError
|
|
241
|
+
|
|
242
|
+
async def _dispose(self) -> None:
|
|
243
|
+
self._closed.set()
|
|
244
|
+
self._fail_pending("closed")
|
|
245
|
+
for task in (self._heartbeat, self._receiver):
|
|
246
|
+
if task is not None:
|
|
247
|
+
task.cancel()
|
|
248
|
+
try:
|
|
249
|
+
await asyncio.gather(
|
|
250
|
+
*(task for task in (self._heartbeat, self._receiver) if task is not None),
|
|
251
|
+
return_exceptions=True,
|
|
252
|
+
)
|
|
253
|
+
finally:
|
|
254
|
+
# A repeated close can return only after this native cleanup boundary.
|
|
255
|
+
self.socket.close(linger=0)
|
|
256
|
+
self.context.term()
|
|
257
|
+
self._cleanup_done.set()
|
|
258
|
+
|
|
259
|
+
async def close(self, *, timeout: float | None = None) -> None:
|
|
260
|
+
"""Attempt registration release, always clean up; failed receipt stays uncertain."""
|
|
261
|
+
if self._closing:
|
|
262
|
+
await self._cleanup_done.wait()
|
|
263
|
+
return
|
|
264
|
+
self._closing = True
|
|
265
|
+
try:
|
|
266
|
+
if self._token is not None and not self._closed.is_set():
|
|
267
|
+
await self._request(CLOSE, timeout=timeout)
|
|
268
|
+
finally:
|
|
269
|
+
await self._cleanup()
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> "Node":
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
275
|
+
await self.close()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Startup-only relay policy and native transport limits (TOML configuration)."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .protocol import valid_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _name_list(names: object) -> bool:
|
|
12
|
+
return isinstance(names, (tuple, list)) and all(valid_name(name) for name in names)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def local_endpoint(endpoint: str, *, bind: bool = False) -> str:
|
|
16
|
+
"""Validate before allocating resources; normalize localhost without DNS.
|
|
17
|
+
|
|
18
|
+
Only literal loopback addresses (or localhost) are supported. A wildcard
|
|
19
|
+
port is allowed for binds, never connects; wildcard/nonlocal hosts are not.
|
|
20
|
+
"""
|
|
21
|
+
if not isinstance(endpoint, str) or not endpoint.startswith("tcp://"):
|
|
22
|
+
raise ValueError("local relay endpoint must use tcp://")
|
|
23
|
+
host, separator, port = endpoint[6:].rpartition(":")
|
|
24
|
+
if not separator or not (
|
|
25
|
+
(bind and port == "*")
|
|
26
|
+
or (port.isascii() and port.isdecimal() and 1 <= int(port) <= 65535)
|
|
27
|
+
):
|
|
28
|
+
raise ValueError("invalid TCP endpoint port")
|
|
29
|
+
if host == "localhost":
|
|
30
|
+
host = "127.0.0.1"
|
|
31
|
+
try:
|
|
32
|
+
address = ipaddress.ip_address(host[1:-1] if host.startswith("[") else host)
|
|
33
|
+
except ValueError as exc:
|
|
34
|
+
raise ValueError("endpoint must use a literal loopback address or localhost") from exc
|
|
35
|
+
if not address.is_loopback or "%" in host:
|
|
36
|
+
raise ValueError("endpoint must be loopback-only")
|
|
37
|
+
expected = f"[{address}]" if address.version == 6 else str(address)
|
|
38
|
+
if host != expected:
|
|
39
|
+
raise ValueError("invalid TCP loopback address syntax")
|
|
40
|
+
return f"tcp://{expected}:{port}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RelayConfig:
|
|
45
|
+
endpoint: str = "tcp://127.0.0.1:5555"
|
|
46
|
+
lease_seconds: float = 10.0
|
|
47
|
+
send_hwm: int = 64
|
|
48
|
+
receive_hwm: int = 64
|
|
49
|
+
max_payload_bytes: int = 1_048_576
|
|
50
|
+
allowed_names: tuple[str, ...] | None = None
|
|
51
|
+
networks: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
52
|
+
deny: tuple[tuple[str, str], ...] = ()
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
object.__setattr__(self, "endpoint", local_endpoint(self.endpoint, bind=True))
|
|
56
|
+
if type(self.lease_seconds) not in (float, int) or not 0.1 <= self.lease_seconds <= 3600:
|
|
57
|
+
raise ValueError("lease_seconds must be between 0.1 and 3600")
|
|
58
|
+
for name in ("send_hwm", "receive_hwm", "max_payload_bytes"):
|
|
59
|
+
value = getattr(self, name)
|
|
60
|
+
if type(value) is not int or value <= 0:
|
|
61
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
62
|
+
if self.allowed_names is not None and not _name_list(self.allowed_names):
|
|
63
|
+
raise ValueError("allowed_names must be an array of valid names")
|
|
64
|
+
if not isinstance(self.networks, dict):
|
|
65
|
+
raise ValueError("networks must map group names to member arrays")
|
|
66
|
+
for group, members in self.networks.items():
|
|
67
|
+
if not valid_name(group) or not _name_list(members):
|
|
68
|
+
raise ValueError("invalid network or member name array")
|
|
69
|
+
if not isinstance(self.deny, (tuple, list)):
|
|
70
|
+
raise ValueError("deny must be an array of directional rules")
|
|
71
|
+
for rule in self.deny:
|
|
72
|
+
if (
|
|
73
|
+
not isinstance(rule, (tuple, list))
|
|
74
|
+
or len(rule) != 2
|
|
75
|
+
or any(not self._valid_selector(selector) for selector in rule)
|
|
76
|
+
):
|
|
77
|
+
raise ValueError("deny entries need two name, @network or * selectors")
|
|
78
|
+
|
|
79
|
+
def _valid_selector(self, selector: str) -> bool:
|
|
80
|
+
return isinstance(selector, str) and (
|
|
81
|
+
selector == "*"
|
|
82
|
+
or valid_name(selector)
|
|
83
|
+
or (selector.startswith("@") and selector[1:] in self.networks)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _matches(self, selector: str, name: str) -> bool:
|
|
87
|
+
if selector == "*":
|
|
88
|
+
return True
|
|
89
|
+
if selector.startswith("@"):
|
|
90
|
+
return name in self.networks[selector[1:]]
|
|
91
|
+
return selector == name
|
|
92
|
+
|
|
93
|
+
def permits(self, source: str, destination: str) -> bool:
|
|
94
|
+
return not any(
|
|
95
|
+
self._matches(left, source) and self._matches(right, destination)
|
|
96
|
+
for left, right in self.deny
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_config(path: Path) -> RelayConfig:
|
|
101
|
+
"""Load cooperative local policy once; obsolete/unknown options are errors."""
|
|
102
|
+
with path.open("rb") as source:
|
|
103
|
+
data = tomllib.load(source)
|
|
104
|
+
obsolete = {"curve", "allow_nonlocal"}.intersection(data)
|
|
105
|
+
if obsolete:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"unsupported local-only configuration: {', '.join(sorted(obsolete))}; "
|
|
108
|
+
"security mode and nonlocal endpoints have been removed"
|
|
109
|
+
)
|
|
110
|
+
unknown = data.keys() - {item.name for item in fields(RelayConfig)}
|
|
111
|
+
if unknown:
|
|
112
|
+
raise ValueError(f"unknown relay configuration: {', '.join(sorted(unknown))}")
|
|
113
|
+
config = RelayConfig(**data) # Validate arrays before converting; strings aren't arrays.
|
|
114
|
+
return RelayConfig(
|
|
115
|
+
**{
|
|
116
|
+
**data,
|
|
117
|
+
"allowed_names": None if config.allowed_names is None else tuple(config.allowed_names),
|
|
118
|
+
"networks": {name: tuple(members) for name, members in config.networks.items()},
|
|
119
|
+
"deny": tuple(tuple(rule) for rule in config.deny),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Private wire vocabulary; application payloads remain opaque bytes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
VERSION = b"AR1"
|
|
6
|
+
HELLO = b"HELLO"
|
|
7
|
+
SEND = b"SEND"
|
|
8
|
+
HEARTBEAT = b"HEARTBEAT"
|
|
9
|
+
CLOSE = b"CLOSE"
|
|
10
|
+
OK = b"OK"
|
|
11
|
+
ERROR = b"ERROR"
|
|
12
|
+
DATA = b"DATA"
|
|
13
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def valid_name(name: str) -> bool:
|
|
17
|
+
return isinstance(name, str) and _NAME.fullmatch(name) is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RelayError(Exception):
|
|
21
|
+
"""Relay status: Router rejection, local rejection or lifecycle interruption.
|
|
22
|
+
|
|
23
|
+
Lifecycle codes closed/connection_failed can interrupt an already-sent request;
|
|
24
|
+
they do not prove no effect. No status is a remote execution result.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, code: str):
|
|
28
|
+
self.code = code
|
|
29
|
+
super().__init__(code)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReceiptTimeout(TimeoutError):
|
|
33
|
+
"""No receipt arrived in time; the route's effect is uncertain. Never retry blindly."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""One ROUTER socket: admission, lease ownership and nonblocking opaque forwarding."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import zmq
|
|
8
|
+
import zmq.asyncio
|
|
9
|
+
|
|
10
|
+
from ..config import RelayConfig
|
|
11
|
+
from ..protocol import CLOSE, DATA, ERROR, HEARTBEAT, HELLO, OK, SEND, VERSION, valid_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class _Registration:
|
|
16
|
+
name: str
|
|
17
|
+
address: bytes
|
|
18
|
+
token: bytes
|
|
19
|
+
expires: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Relay:
|
|
23
|
+
"""Owns a local context/socket; call start(), run(), and finally close().
|
|
24
|
+
|
|
25
|
+
Every destination send uses ROUTER_MANDATORY and DONTWAIT. A successful receipt
|
|
26
|
+
means native socket queue acceptance only. There is no Python outbound queue.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: RelayConfig | None = None) -> None:
|
|
30
|
+
self.config = config or RelayConfig()
|
|
31
|
+
self.context: zmq.Context | None = None
|
|
32
|
+
self.socket: zmq.asyncio.Socket | None = None
|
|
33
|
+
self.endpoint: str | None = None
|
|
34
|
+
self._names: dict[str, _Registration] = {}
|
|
35
|
+
self._addresses: dict[bytes, _Registration] = {}
|
|
36
|
+
self._running = False
|
|
37
|
+
|
|
38
|
+
async def start(self) -> str:
|
|
39
|
+
if self.context is not None:
|
|
40
|
+
raise RuntimeError("relay already started")
|
|
41
|
+
self.context = zmq.Context()
|
|
42
|
+
try:
|
|
43
|
+
self.socket = zmq.asyncio.Socket(self.context, zmq.ROUTER)
|
|
44
|
+
self.socket.setsockopt(zmq.LINGER, 0)
|
|
45
|
+
self.socket.setsockopt(zmq.IPV6, int(self.config.endpoint.startswith("tcp://[")))
|
|
46
|
+
self.socket.setsockopt(zmq.ROUTER_MANDATORY, 1)
|
|
47
|
+
self.socket.setsockopt(zmq.ROUTER_HANDOVER, 0)
|
|
48
|
+
self.socket.setsockopt(zmq.SNDHWM, self.config.send_hwm)
|
|
49
|
+
self.socket.setsockopt(zmq.RCVHWM, self.config.receive_hwm)
|
|
50
|
+
self.socket.setsockopt(zmq.MAXMSGSIZE, self.config.max_payload_bytes + 4096)
|
|
51
|
+
self.socket.bind(self.config.endpoint)
|
|
52
|
+
self.endpoint = self.socket.getsockopt(zmq.LAST_ENDPOINT).decode("utf-8")
|
|
53
|
+
return self.endpoint
|
|
54
|
+
except BaseException:
|
|
55
|
+
await self.close()
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def _remove(self, registration: _Registration) -> None:
|
|
59
|
+
# Fence removal by object identity even if a name/address was later reused.
|
|
60
|
+
if self._names.get(registration.name) is registration:
|
|
61
|
+
del self._names[registration.name]
|
|
62
|
+
if self._addresses.get(registration.address) is registration:
|
|
63
|
+
del self._addresses[registration.address]
|
|
64
|
+
|
|
65
|
+
def _expire(self) -> None:
|
|
66
|
+
now = time.monotonic()
|
|
67
|
+
for registration in tuple(self._names.values()):
|
|
68
|
+
if registration.expires <= now:
|
|
69
|
+
self._remove(registration)
|
|
70
|
+
|
|
71
|
+
async def _emit(self, address: bytes, *frames: bytes) -> str | None:
|
|
72
|
+
assert self.socket is not None
|
|
73
|
+
try:
|
|
74
|
+
await self.socket.send_multipart([address, VERSION, *frames], flags=zmq.DONTWAIT)
|
|
75
|
+
except zmq.Again:
|
|
76
|
+
return "destination_full"
|
|
77
|
+
except zmq.ZMQError as exc:
|
|
78
|
+
if exc.errno == zmq.EHOSTUNREACH:
|
|
79
|
+
return "destination_unavailable"
|
|
80
|
+
raise
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
async def _error(self, address: bytes, token: bytes, request: bytes, code: str) -> None:
|
|
84
|
+
# If a control response cannot be queued, caller sees timeout/uncertain effect.
|
|
85
|
+
await self._emit(address, ERROR, token, request, code.encode("ascii"))
|
|
86
|
+
|
|
87
|
+
async def _hello(self, address: bytes, frames: list[bytes]) -> None:
|
|
88
|
+
if len(frames) != 2 or not 1 <= len(frames[0]) <= 64:
|
|
89
|
+
return
|
|
90
|
+
request, encoded_name = frames
|
|
91
|
+
try:
|
|
92
|
+
name = encoded_name.decode("ascii")
|
|
93
|
+
except UnicodeDecodeError:
|
|
94
|
+
name = ""
|
|
95
|
+
if not valid_name(name):
|
|
96
|
+
await self._error(address, b"", request, "invalid_name")
|
|
97
|
+
return
|
|
98
|
+
if self.config.allowed_names is not None and name not in self.config.allowed_names:
|
|
99
|
+
await self._error(address, b"", request, "admission_denied")
|
|
100
|
+
return
|
|
101
|
+
if name in self._names or address in self._addresses:
|
|
102
|
+
await self._error(address, b"", request, "duplicate_name")
|
|
103
|
+
return
|
|
104
|
+
registration = _Registration(
|
|
105
|
+
name,
|
|
106
|
+
address,
|
|
107
|
+
secrets.token_hex(16).encode("ascii"),
|
|
108
|
+
time.monotonic() + self.config.lease_seconds,
|
|
109
|
+
)
|
|
110
|
+
self._names[name] = registration
|
|
111
|
+
self._addresses[address] = registration
|
|
112
|
+
error = await self._emit(
|
|
113
|
+
address,
|
|
114
|
+
OK,
|
|
115
|
+
b"",
|
|
116
|
+
request,
|
|
117
|
+
registration.token,
|
|
118
|
+
str(self.config.lease_seconds).encode("ascii"),
|
|
119
|
+
str(self.config.max_payload_bytes).encode("ascii"),
|
|
120
|
+
)
|
|
121
|
+
if error is not None:
|
|
122
|
+
self._remove(registration)
|
|
123
|
+
|
|
124
|
+
async def _handle(self, packet: list[zmq.Frame]) -> None:
|
|
125
|
+
if len(packet) < 3 or bytes(packet[1]) != VERSION:
|
|
126
|
+
return
|
|
127
|
+
address, command = bytes(packet[0]), bytes(packet[2])
|
|
128
|
+
frames = [bytes(frame) for frame in packet[3:]]
|
|
129
|
+
if command == HELLO:
|
|
130
|
+
await self._hello(address, frames)
|
|
131
|
+
return
|
|
132
|
+
if not frames:
|
|
133
|
+
return
|
|
134
|
+
token = frames[0]
|
|
135
|
+
request = frames[1] if len(frames) > 1 and 1 <= len(frames[1]) <= 64 else b""
|
|
136
|
+
registration = self._addresses.get(address)
|
|
137
|
+
if registration is None or registration.token != token:
|
|
138
|
+
if request:
|
|
139
|
+
await self._error(address, token, request, "not_registered")
|
|
140
|
+
return
|
|
141
|
+
registration.expires = time.monotonic() + self.config.lease_seconds
|
|
142
|
+
if command == HEARTBEAT and len(frames) == 1:
|
|
143
|
+
return
|
|
144
|
+
if command == CLOSE and len(frames) == 2 and request:
|
|
145
|
+
self._remove(registration)
|
|
146
|
+
await self._emit(address, OK, token, request)
|
|
147
|
+
return
|
|
148
|
+
if command != SEND or len(frames) != 4 or not request:
|
|
149
|
+
if request:
|
|
150
|
+
await self._error(address, token, request, "invalid_command")
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
destination = frames[2].decode("ascii")
|
|
154
|
+
except UnicodeDecodeError:
|
|
155
|
+
destination = ""
|
|
156
|
+
payload = frames[3]
|
|
157
|
+
if not valid_name(destination):
|
|
158
|
+
error = "invalid_destination"
|
|
159
|
+
elif len(payload) > self.config.max_payload_bytes:
|
|
160
|
+
error = "payload_too_large"
|
|
161
|
+
elif not self.config.permits(registration.name, destination):
|
|
162
|
+
error = "route_denied"
|
|
163
|
+
elif destination not in self._names:
|
|
164
|
+
error = "destination_unavailable"
|
|
165
|
+
else:
|
|
166
|
+
target = self._names[destination]
|
|
167
|
+
error = await self._emit(
|
|
168
|
+
target.address, DATA, target.token, registration.name.encode("ascii"), payload
|
|
169
|
+
)
|
|
170
|
+
if error is None:
|
|
171
|
+
await self._emit(address, OK, token, request)
|
|
172
|
+
else:
|
|
173
|
+
await self._error(address, token, request, error)
|
|
174
|
+
|
|
175
|
+
async def run(self) -> None:
|
|
176
|
+
if self.socket is None or self.socket.closed:
|
|
177
|
+
raise RuntimeError("start the relay before run()")
|
|
178
|
+
if self._running:
|
|
179
|
+
raise RuntimeError("relay is already running")
|
|
180
|
+
self._running = True
|
|
181
|
+
try:
|
|
182
|
+
while True:
|
|
183
|
+
self._expire()
|
|
184
|
+
if await self.socket.poll(timeout=max(1, int(self.config.lease_seconds * 100))):
|
|
185
|
+
packet = await self.socket.recv_multipart(copy=False)
|
|
186
|
+
self._expire() # Expiry before handling, not after an old heartbeat.
|
|
187
|
+
await self._handle(packet)
|
|
188
|
+
finally:
|
|
189
|
+
self._running = False
|
|
190
|
+
|
|
191
|
+
async def close(self) -> None:
|
|
192
|
+
"""Stop run() before closing; no persistent service or native resources remain."""
|
|
193
|
+
if self._running:
|
|
194
|
+
raise RuntimeError("cancel and await run() before closing the relay")
|
|
195
|
+
if self.socket is not None:
|
|
196
|
+
self.socket.close(linger=0)
|
|
197
|
+
if self.context is not None:
|
|
198
|
+
self.context.term()
|
|
199
|
+
self._names.clear()
|
|
200
|
+
self._addresses.clear()
|
|
201
|
+
|
|
202
|
+
async def __aenter__(self) -> "Relay":
|
|
203
|
+
await self.start()
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
207
|
+
await self.close()
|