relayfabric-sdk 0.4.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.
- relayfabric_sdk-0.4.0/PKG-INFO +25 -0
- relayfabric_sdk-0.4.0/README.md +14 -0
- relayfabric_sdk-0.4.0/pyproject.toml +22 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/__init__.py +25 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/bridge.py +71 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/cache.py +36 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/harness.py +40 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/ipc.py +104 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/nip01.py +124 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk/runner.py +107 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk.egg-info/PKG-INFO +25 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk.egg-info/SOURCES.txt +19 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk.egg-info/dependency_links.txt +1 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk.egg-info/requires.txt +1 -0
- relayfabric_sdk-0.4.0/relayfabric_sdk.egg-info/top_level.txt +1 -0
- relayfabric_sdk-0.4.0/setup.cfg +4 -0
- relayfabric_sdk-0.4.0/tests/test_bridge.py +62 -0
- relayfabric_sdk-0.4.0/tests/test_cache.py +25 -0
- relayfabric_sdk-0.4.0/tests/test_ipc.py +166 -0
- relayfabric_sdk-0.4.0/tests/test_nip01.py +216 -0
- relayfabric_sdk-0.4.0/tests/test_runner.py +247 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relayfabric-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://docs.relayfabric.org
|
|
7
|
+
Project-URL: Repository, https://github.com/RelayFabric/RelayFabric
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: cbor2
|
|
11
|
+
|
|
12
|
+
# relayfabric-sdk
|
|
13
|
+
|
|
14
|
+
The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
|
|
15
|
+
(`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
|
|
16
|
+
implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
|
|
17
|
+
(`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
|
|
18
|
+
`FakeSock` test harness.
|
|
19
|
+
|
|
20
|
+
A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
|
|
21
|
+
plugin against the daemon-side contract with:
|
|
22
|
+
|
|
23
|
+
switchyardctl plugin test "python my_plugin.py"
|
|
24
|
+
|
|
25
|
+
Docs: https://docs.relayfabric.org/plugin-authors/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# relayfabric-sdk
|
|
2
|
+
|
|
3
|
+
The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
|
|
4
|
+
(`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
|
|
5
|
+
implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
|
|
6
|
+
(`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
|
|
7
|
+
`FakeSock` test harness.
|
|
8
|
+
|
|
9
|
+
A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
|
|
10
|
+
plugin against the daemon-side contract with:
|
|
11
|
+
|
|
12
|
+
switchyardctl plugin test "python my_plugin.py"
|
|
13
|
+
|
|
14
|
+
Docs: https://docs.relayfabric.org/plugin-authors/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "relayfabric-sdk"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["cbor2"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://docs.relayfabric.org"
|
|
12
|
+
Repository = "https://github.com/RelayFabric/RelayFabric"
|
|
13
|
+
|
|
14
|
+
# Not pip-installed by anything in-repo: plugins and their tests consume
|
|
15
|
+
# this package via a sys.path insert to ../../sdk/python. `pip install -e
|
|
16
|
+
# sdk/python` works for out-of-repo consumption but is not required.
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=61"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
include = ["relayfabric_sdk*"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""RelayFabric Python plugin SDK.
|
|
2
|
+
|
|
3
|
+
Consolidates what the plugin fleet shares: the Plugin Protocol v1 frame
|
|
4
|
+
codec (ipc), the sent-message loop-guard cache (cache), shared Bridge
|
|
5
|
+
plumbing (bridge), the main-loop scaffold (runner), the scripted-socket
|
|
6
|
+
test double (harness), and the NIP-01 event primitives (nip01).
|
|
7
|
+
|
|
8
|
+
Plugins import submodules directly (`from relayfabric_sdk import ipc as
|
|
9
|
+
relay_ipc`). The flat names below resolve lazily (PEP 562), so a bare
|
|
10
|
+
`import relayfabric_sdk` — or importing the stdlib-only `bridge`
|
|
11
|
+
submodule — pulls in no third-party dependency.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
_FLAT = {"FakeSock": "harness", "SentCache": "cache", "run_plugin": "runner"}
|
|
15
|
+
|
|
16
|
+
__all__ = list(_FLAT)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __getattr__(name):
|
|
20
|
+
submodule = _FLAT.get(name)
|
|
21
|
+
if submodule is None:
|
|
22
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
23
|
+
from importlib import import_module
|
|
24
|
+
|
|
25
|
+
return getattr(import_module(f".{submodule}", __name__), name)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared Bridge plumbing for the plugin fleet.
|
|
2
|
+
|
|
3
|
+
Module top level is stdlib-only (the package __init__ is lazy, so importing
|
|
4
|
+
this from a plugin's module top level pulls no cbor2/paho/etc.); ipc is
|
|
5
|
+
imported inside the functions that write frames, mirroring the plugins'
|
|
6
|
+
own lazy-import convention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FrameWriter:
|
|
16
|
+
"""Owns the daemon socket file and the write lock every Bridge
|
|
17
|
+
re-declared: all daemon-socket writes go through _send_frame, serialized
|
|
18
|
+
by one lock (handle_event runs on a backend reader thread, handle_send
|
|
19
|
+
on the main thread).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, sock_file):
|
|
23
|
+
self.sock_file = sock_file
|
|
24
|
+
self.write_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
def _send_frame(self, obj):
|
|
27
|
+
from . import ipc
|
|
28
|
+
|
|
29
|
+
with self.write_lock:
|
|
30
|
+
ipc.write_frame(self.sock_file, obj)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def capped_text_send(bridge, frame, label, sent_what, publish):
|
|
34
|
+
"""The endpoint-lookup -> size-cap -> publish -> delivery_result dance
|
|
35
|
+
four plugins carried verbatim (differing only in label and publish call).
|
|
36
|
+
|
|
37
|
+
`bridge` needs: cfg["channels"], cfg["max_text_bytes"], _send_frame,
|
|
38
|
+
sent_cache. `publish(channel_spec, endpoint, body)` raises on failure.
|
|
39
|
+
"""
|
|
40
|
+
from . import ipc
|
|
41
|
+
|
|
42
|
+
corr = frame["corr"]
|
|
43
|
+
endpoint = frame["endpoint"]
|
|
44
|
+
body = frame["body"]
|
|
45
|
+
channel_spec = bridge.cfg["channels"].get(endpoint)
|
|
46
|
+
if channel_spec is None:
|
|
47
|
+
log.warning(f"{label} send to unknown endpoint {endpoint!r}")
|
|
48
|
+
bridge._send_frame(ipc.delivery_result(corr, False, "unknown endpoint"))
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
body_bytes = len(body.encode("utf-8"))
|
|
52
|
+
max_bytes = bridge.cfg["max_text_bytes"]
|
|
53
|
+
if body_bytes > max_bytes:
|
|
54
|
+
# defensive: the daemon should have already truncated to the
|
|
55
|
+
# advertised capabilities.max_payload before sending this frame.
|
|
56
|
+
detail = f"body {body_bytes} B exceeds max_text_bytes {max_bytes} B"
|
|
57
|
+
log.warning(f"{label} send to '{endpoint}' dropped: {detail}")
|
|
58
|
+
bridge._send_frame(ipc.delivery_result(corr, False, detail))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
publish(channel_spec, endpoint, body)
|
|
63
|
+
except Exception as e: # noqa: BLE001 - report the failure, don't crash
|
|
64
|
+
log.warning(f"{label} send to '{endpoint}' failed: {e}")
|
|
65
|
+
bridge._send_frame(ipc.delivery_result(corr, False, str(e)))
|
|
66
|
+
return
|
|
67
|
+
# delivered = send accepted by the backend (spec Sec70), not an
|
|
68
|
+
# end-to-end delivery acknowledgement.
|
|
69
|
+
bridge.sent_cache.record(endpoint, body)
|
|
70
|
+
bridge._send_frame(ipc.delivery_result(corr, True))
|
|
71
|
+
log.info(f"Sent {sent_what} to '{endpoint}' ({body_bytes} B)")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""SentCache: loop guard for echoes of our own bridged posts.
|
|
2
|
+
|
|
3
|
+
Moved from plugins/signal/relayfabric_signal.py into the SDK. Signal uses
|
|
4
|
+
it to catch linked-device sync echoes; meshtastic and meshcore use it
|
|
5
|
+
(with a shorter ttl_secs) to catch radio/firmware echoes of their own
|
|
6
|
+
downlinked messages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SentCache:
|
|
14
|
+
"""Loop guard for linked-device sync echoes of our own bridged posts."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, ttl_secs=86400):
|
|
17
|
+
self.ttl = ttl_secs
|
|
18
|
+
self._entries = {}
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def record(self, group_id, text, now=None):
|
|
22
|
+
now = time.time() if now is None else now
|
|
23
|
+
with self._lock:
|
|
24
|
+
self._prune(now)
|
|
25
|
+
self._entries[(group_id, text)] = now
|
|
26
|
+
|
|
27
|
+
def match(self, group_id, text, now=None):
|
|
28
|
+
now = time.time() if now is None else now
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._prune(now)
|
|
31
|
+
return self._entries.pop((group_id, text), None) is not None
|
|
32
|
+
|
|
33
|
+
def _prune(self, now):
|
|
34
|
+
# O(n) prune per call, fine at gateway volumes
|
|
35
|
+
for key in [k for k, t in self._entries.items() if now - t > self.ttl]:
|
|
36
|
+
del self._entries[key]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""FakeSock: scripted duplex socket double for plugin frame-IO tests.
|
|
2
|
+
|
|
3
|
+
Superset of the write-capture-only fake each plugin suite used to
|
|
4
|
+
re-implement locally (lxmf/signal/meshtastic/meshcore all had a
|
|
5
|
+
byte-identical copy): construct with no arguments for the common case
|
|
6
|
+
(capture frames a Bridge writes via write_frame(), decode them back with
|
|
7
|
+
frames()); optionally pass queued_frames to script the read side too
|
|
8
|
+
(read_frame() consumes them in order, then hits EOFError once exhausted,
|
|
9
|
+
mirroring a closed daemon connection) for main-loop-style tests.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
|
|
14
|
+
from . import ipc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeSock:
|
|
18
|
+
def __init__(self, queued_frames=None):
|
|
19
|
+
self._in = io.BytesIO()
|
|
20
|
+
for obj in queued_frames or []:
|
|
21
|
+
ipc.write_frame(self._in, obj)
|
|
22
|
+
self._in.seek(0)
|
|
23
|
+
self._out = io.BytesIO()
|
|
24
|
+
|
|
25
|
+
def read(self, n):
|
|
26
|
+
return self._in.read(n)
|
|
27
|
+
|
|
28
|
+
def write(self, data):
|
|
29
|
+
self._out.write(data)
|
|
30
|
+
|
|
31
|
+
def flush(self):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def frames(self):
|
|
35
|
+
out, rd = [], io.BytesIO(self._out.getvalue())
|
|
36
|
+
while True:
|
|
37
|
+
try:
|
|
38
|
+
out.append(ipc.read_frame(rd))
|
|
39
|
+
except EOFError:
|
|
40
|
+
return out
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""RelayFabric Plugin Protocol v1 codec: 4-byte BE length prefix + CBOR.
|
|
2
|
+
|
|
3
|
+
Moved from plugins/lxmf/relay_ipc.py into the SDK so the fleet (lxmf,
|
|
4
|
+
signal, meshtastic, meshcore) shares one copy instead of importing across
|
|
5
|
+
plugin directories.
|
|
6
|
+
|
|
7
|
+
Dict key order matters: frames must be byte-identical to the Rust
|
|
8
|
+
relay-ipc encoding (locked by canonical_hello_frame_bytes_are_stable).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
import cbor2
|
|
14
|
+
|
|
15
|
+
MAX_FRAME = 16 * 1024 * 1024
|
|
16
|
+
PROTOCOL_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_frame(fileobj, obj):
|
|
20
|
+
body = cbor2.dumps(obj)
|
|
21
|
+
if len(body) > MAX_FRAME:
|
|
22
|
+
raise ValueError(f"frame {len(body)} B exceeds MAX_FRAME")
|
|
23
|
+
fileobj.write(len(body).to_bytes(4, "big") + body)
|
|
24
|
+
fileobj.flush()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_exact(fileobj, n):
|
|
28
|
+
data = b""
|
|
29
|
+
while len(data) < n:
|
|
30
|
+
chunk = fileobj.read(n - len(data))
|
|
31
|
+
if not chunk:
|
|
32
|
+
raise EOFError("daemon connection closed")
|
|
33
|
+
data += chunk
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_frame(fileobj):
|
|
38
|
+
length = int.from_bytes(_read_exact(fileobj, 4), "big")
|
|
39
|
+
if length > MAX_FRAME:
|
|
40
|
+
raise ValueError(f"frame {length} B exceeds MAX_FRAME")
|
|
41
|
+
return cbor2.loads(_read_exact(fileobj, length))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def capabilities(**overrides):
|
|
45
|
+
caps = {
|
|
46
|
+
"text": True, "direct_messages": False, "groups": False,
|
|
47
|
+
"attachments": False, "location": False, "reactions": False,
|
|
48
|
+
"receipts": False, "presence": False, "max_payload": None,
|
|
49
|
+
}
|
|
50
|
+
caps.update(overrides)
|
|
51
|
+
return caps
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hello(plugin, version, caps):
|
|
55
|
+
return {"t": "hello", "plugin": plugin, "version": version,
|
|
56
|
+
"protocol_version": PROTOCOL_VERSION, "capabilities": caps}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def attachment(filename, mime, data):
|
|
60
|
+
"""Create an attachment dict for inbound frames.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
filename: attachment filename
|
|
64
|
+
mime: MIME type string
|
|
65
|
+
data: bytes payload (encoded as CBOR byte string)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
dict with filename, mime, data keys
|
|
69
|
+
"""
|
|
70
|
+
return {"filename": filename, "mime": mime, "data": data}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def inbound(endpoint, sender, body, created_at_epoch=None, *, attachments=None, priority=None):
|
|
74
|
+
created = None
|
|
75
|
+
if created_at_epoch is not None:
|
|
76
|
+
try:
|
|
77
|
+
created = (datetime.fromtimestamp(created_at_epoch, timezone.utc)
|
|
78
|
+
.isoformat().replace("+00:00", "Z"))
|
|
79
|
+
except (OverflowError, OSError, ValueError):
|
|
80
|
+
# sender-controlled timestamp out of range (e.g. 1e300); bridge
|
|
81
|
+
# the message anyway and let the daemon stamp receive time.
|
|
82
|
+
created = None
|
|
83
|
+
return {"t": "inbound", "endpoint": endpoint, "sender": sender,
|
|
84
|
+
"kind": "text", "body": body, "created_at": created,
|
|
85
|
+
"attachments": attachments or [], "priority": priority}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def delivery_result(corr, delivered, detail=None):
|
|
89
|
+
return {"t": "delivery_result", "corr": corr, "delivered": delivered,
|
|
90
|
+
"detail": detail}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gauges(values):
|
|
94
|
+
"""Create a Gauges frame (design §3, cycle D) from a dict of gauge name
|
|
95
|
+
-> numeric value.
|
|
96
|
+
|
|
97
|
+
Mirrors the Rust side's `PluginToDaemon::Gauges { gauges: BTreeMap<String,
|
|
98
|
+
f64> }`: keys are sorted here too, so a frame built from the same
|
|
99
|
+
name/value set on either side of the wire encodes identically. Values are
|
|
100
|
+
coerced to float (BTreeMap<String, f64> on the Rust side has no integer
|
|
101
|
+
variant). Name sanitization and the 32-gauge cap are enforced daemon-side
|
|
102
|
+
on receipt, not here.
|
|
103
|
+
"""
|
|
104
|
+
return {"t": "gauges", "gauges": {k: float(values[k]) for k in sorted(values)}}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""NIP-01 event primitives: canonical event id, schnorr sign/verify, and
|
|
2
|
+
identity load/generate.
|
|
3
|
+
|
|
4
|
+
Moved verbatim from plugins/nostr/relayfabric_nostr.py (cycle J): NIP-01
|
|
5
|
+
event id/sig are protocol-frozen (BIP-340 schnorr over the exact canonical
|
|
6
|
+
`[0, pubkey, created_at, kind, tags, content]` serialization), so this is a
|
|
7
|
+
shared primitive like ipc/cache/harness rather than nostr-plugin-specific
|
|
8
|
+
code -- the bitchat plugin imports the same functions so there is one
|
|
9
|
+
tested copy of the crypto, not two copies that can drift.
|
|
10
|
+
|
|
11
|
+
Module top level is stdlib-only (hashlib/json/logging/os); coincurve is
|
|
12
|
+
imported lazily inside verify_event/sign_event/load_or_create_identity (the
|
|
13
|
+
fleet's lazy-import convention) so this module stays importable without
|
|
14
|
+
coincurve installed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def event_id(pubkey_hex, created_at, kind, tags, content):
|
|
26
|
+
"""NIP-01 event id: sha256 hex of the canonical serialization
|
|
27
|
+
`[0, pubkey, created_at, kind, tags, content]` -- compact separators,
|
|
28
|
+
UTF-8, no extra whitespace, exactly as NIP-01 specifies. See
|
|
29
|
+
test_relayfabric_nostr.py's EventIdGoldenVectorTests for the locked
|
|
30
|
+
known-answer vector (nsec=1, the secp256k1 generator scalar).
|
|
31
|
+
"""
|
|
32
|
+
serialized = json.dumps(
|
|
33
|
+
[0, pubkey_hex, created_at, kind, tags, content],
|
|
34
|
+
separators=(",", ":"), ensure_ascii=False)
|
|
35
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_event(event):
|
|
39
|
+
"""True iff event's id matches the recomputed NIP-01 sha256 AND its
|
|
40
|
+
schnorr sig verifies (BIP-340) over that id, under event['pubkey'].
|
|
41
|
+
|
|
42
|
+
MUST NOT raise: a relay sends arbitrary dicts (design Sec80 -- a relay
|
|
43
|
+
is untrusted), so any KeyError/TypeError/ValueError from a malformed or
|
|
44
|
+
adversarial event (missing keys, wrong types, non-hex/wrong-length
|
|
45
|
+
id/pubkey/sig, non-dict input entirely) is caught and treated as an
|
|
46
|
+
invalid event, never propagated.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
pubkey_hex = event["pubkey"]
|
|
50
|
+
created_at = event["created_at"]
|
|
51
|
+
kind = event["kind"]
|
|
52
|
+
tags = event["tags"]
|
|
53
|
+
content = event["content"]
|
|
54
|
+
claimed_id = event["id"]
|
|
55
|
+
sig_hex = event["sig"]
|
|
56
|
+
|
|
57
|
+
recomputed_id = event_id(pubkey_hex, created_at, kind, tags, content)
|
|
58
|
+
if recomputed_id != claimed_id:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
from coincurve import PublicKeyXOnly
|
|
62
|
+
|
|
63
|
+
pub = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
|
|
64
|
+
return pub.verify(bytes.fromhex(sig_hex), bytes.fromhex(recomputed_id))
|
|
65
|
+
except Exception: # noqa: BLE001 - malformed/adversarial input, never propagate
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sign_event(privkey_hex, created_at, kind, tags, content):
|
|
70
|
+
"""Build a full signed NIP-01 event {id, pubkey, created_at, kind, tags,
|
|
71
|
+
content, sig} from a 32-byte hex private key ("nsec hex", design Sec1 --
|
|
72
|
+
the raw hex form, not bech32). Round-trips through verify_event().
|
|
73
|
+
"""
|
|
74
|
+
from coincurve import PrivateKey, PublicKeyXOnly
|
|
75
|
+
|
|
76
|
+
priv = PrivateKey(bytes.fromhex(privkey_hex))
|
|
77
|
+
pubkey_hex = PublicKeyXOnly.from_valid_secret(priv.secret).format().hex()
|
|
78
|
+
eid = event_id(pubkey_hex, created_at, kind, tags, content)
|
|
79
|
+
sig_hex = priv.sign_schnorr(bytes.fromhex(eid)).hex()
|
|
80
|
+
return {
|
|
81
|
+
"id": eid,
|
|
82
|
+
"pubkey": pubkey_hex,
|
|
83
|
+
"created_at": created_at,
|
|
84
|
+
"kind": kind,
|
|
85
|
+
"tags": tags,
|
|
86
|
+
"content": content,
|
|
87
|
+
"sig": sig_hex,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_or_create_identity(identity_file):
|
|
92
|
+
"""Load this plugin's Nostr keypair, generating one on first run.
|
|
93
|
+
|
|
94
|
+
If `identity_file` is set and already holds a key (one line, 32-byte
|
|
95
|
+
hex privkey -- the same "nsec hex" form sign_event takes), load it.
|
|
96
|
+
Otherwise generate a fresh secp256k1 key via coincurve; if
|
|
97
|
+
`identity_file` is set, persist the new key there with mode 0600
|
|
98
|
+
(os.open O_CREAT so the restrictive mode is atomic with creation, no
|
|
99
|
+
window where the key sits world-readable) so restarts reuse the same
|
|
100
|
+
identity. A None `identity_file` means a fresh identity every start
|
|
101
|
+
(no path to persist to) -- config-valid per load_config, but every
|
|
102
|
+
restart then publishes under a new pubkey.
|
|
103
|
+
|
|
104
|
+
Logs the public key (hex; loosely "npub", though this is the raw hex
|
|
105
|
+
form, not bech32) exactly once. Never logs the private key.
|
|
106
|
+
|
|
107
|
+
Returns (privkey_hex, pubkey_hex).
|
|
108
|
+
"""
|
|
109
|
+
from coincurve import PrivateKey, PublicKeyXOnly
|
|
110
|
+
|
|
111
|
+
if identity_file and os.path.exists(identity_file):
|
|
112
|
+
with open(identity_file) as f:
|
|
113
|
+
privkey_hex = f.read().strip()
|
|
114
|
+
else:
|
|
115
|
+
privkey_hex = PrivateKey().secret.hex()
|
|
116
|
+
if identity_file:
|
|
117
|
+
fd = os.open(identity_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
118
|
+
with os.fdopen(fd, "w") as f:
|
|
119
|
+
f.write(privkey_hex + "\n")
|
|
120
|
+
|
|
121
|
+
pubkey_hex = PublicKeyXOnly.from_valid_secret(
|
|
122
|
+
PrivateKey(bytes.fromhex(privkey_hex)).secret).format().hex()
|
|
123
|
+
log.info(f"Nostr identity pubkey (npub, hex): {pubkey_hex}")
|
|
124
|
+
return privkey_hex, pubkey_hex
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""run_plugin: shared main-loop scaffold for the plugin fleet.
|
|
2
|
+
|
|
3
|
+
Consolidates the env contract -> Hello/HelloAck handshake -> dispatch-table
|
|
4
|
+
read loop that lxmf/signal/meshtastic/meshcore each hand-rolled identically
|
|
5
|
+
in their main() functions. A plugin adopts it by building a `bridge_factory`
|
|
6
|
+
(cfg_dict, sock) -> object with a `handle_send(frame)` method, and calling
|
|
7
|
+
`run_plugin(name, version, bridge_factory, capabilities)`.
|
|
8
|
+
|
|
9
|
+
`connect` is a dependency-injection seam for tests only: it defaults to a
|
|
10
|
+
real AF_UNIX connect + a single duplex `sock.makefile("rwb")` (one object
|
|
11
|
+
used for both read_frame and write_frame, exactly like FakeSock), and every
|
|
12
|
+
real caller leaves it at the default.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .ipc import hello, read_frame, write_frame
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _connect_socket(sock_path):
|
|
24
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
25
|
+
sock.connect(sock_path)
|
|
26
|
+
return sock.makefile("rwb")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_plugin(plugin_name, version, bridge_factory, capabilities, *,
|
|
30
|
+
socket_env="RELAYFABRIC_SOCKET", config_env="RELAYFABRIC_PLUGIN_CONFIG",
|
|
31
|
+
connect=_connect_socket):
|
|
32
|
+
"""Run a plugin's main loop until shutdown or an unrecoverable error.
|
|
33
|
+
|
|
34
|
+
- Missing `socket_env` -> stderr line + exit 2.
|
|
35
|
+
- Connects, sends Hello(plugin_name, version, capabilities), reads
|
|
36
|
+
HelloAck; a non-"hello_ack" frame or a truthy "error" -> stderr line +
|
|
37
|
+
exit 1. `capabilities` may be a callable taking the parsed config dict
|
|
38
|
+
and returning the caps dict, for plugins whose advertised caps depend
|
|
39
|
+
on config (e.g. a config-derived max_payload).
|
|
40
|
+
- Calls `bridge_factory(cfg_dict, sock)`; if the returned object has a
|
|
41
|
+
`start()`, calls it before entering the read loop.
|
|
42
|
+
- A ValueError/TypeError from the capabilities callable or
|
|
43
|
+
bridge_factory (the plugins' load_config validation errors) -> clean
|
|
44
|
+
"invalid config" stderr line + exit 1.
|
|
45
|
+
- Read loop: "send" -> bridge.handle_send(frame); "send_direct" ->
|
|
46
|
+
bridge.handle_send_direct(frame) if present, else ignored; "shutdown"
|
|
47
|
+
-> bridge.stop() if present, then exit 0; unknown "t" -> ignored;
|
|
48
|
+
(EOFError, OSError, ValueError) while reading -> stderr line + exit 1.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
sock_path = os.environ[socket_env]
|
|
52
|
+
except KeyError:
|
|
53
|
+
print(f"{plugin_name}: missing required env var {socket_env}", file=sys.stderr)
|
|
54
|
+
sys.exit(2)
|
|
55
|
+
|
|
56
|
+
raw_cfg = json.loads(os.environ.get(config_env, "{}"))
|
|
57
|
+
# Scrub the resolved config (may carry secrets substituted by the daemon
|
|
58
|
+
# from a ${env:}/${file:} reference) out of our own environment so any
|
|
59
|
+
# child process this plugin spawns (e.g. lxmf's media.py running ffmpeg
|
|
60
|
+
# over attacker-supplied audio) doesn't inherit it.
|
|
61
|
+
os.environ.pop(config_env, None)
|
|
62
|
+
sock = connect(sock_path)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
caps = capabilities(raw_cfg) if callable(capabilities) else capabilities
|
|
66
|
+
except (ValueError, TypeError) as e:
|
|
67
|
+
print(f"{plugin_name}: invalid config: {e}", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
write_frame(sock, hello(plugin_name, version, caps))
|
|
71
|
+
ack = read_frame(sock)
|
|
72
|
+
if ack.get("t") != "hello_ack" or ack.get("error"):
|
|
73
|
+
print(f"{plugin_name}: hello rejected: {ack.get('error')}", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
bridge = bridge_factory(raw_cfg, sock)
|
|
78
|
+
except (ValueError, TypeError) as e:
|
|
79
|
+
print(f"{plugin_name}: invalid config: {e}", file=sys.stderr)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
start = getattr(bridge, "start", None)
|
|
82
|
+
if start is not None:
|
|
83
|
+
start()
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
frame = read_frame(sock)
|
|
88
|
+
except (EOFError, OSError, ValueError) as e:
|
|
89
|
+
# ValueError: oversize/corrupt frame (read_frame's own MAX_FRAME
|
|
90
|
+
# check). The stream is desynced at that point, so exit rather
|
|
91
|
+
# than continue -- there is no way to resume mid-frame.
|
|
92
|
+
print(f"{plugin_name}: daemon connection lost, exiting: {e}", file=sys.stderr)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
kind = frame.get("t")
|
|
96
|
+
if kind == "send":
|
|
97
|
+
bridge.handle_send(frame)
|
|
98
|
+
elif kind == "send_direct":
|
|
99
|
+
handle_send_direct = getattr(bridge, "handle_send_direct", None)
|
|
100
|
+
if handle_send_direct is not None:
|
|
101
|
+
handle_send_direct(frame)
|
|
102
|
+
elif kind == "shutdown":
|
|
103
|
+
stop = getattr(bridge, "stop", None)
|
|
104
|
+
if stop is not None:
|
|
105
|
+
stop()
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
# unknown t: ignore, keep looping
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: relayfabric-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://docs.relayfabric.org
|
|
7
|
+
Project-URL: Repository, https://github.com/RelayFabric/RelayFabric
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: cbor2
|
|
11
|
+
|
|
12
|
+
# relayfabric-sdk
|
|
13
|
+
|
|
14
|
+
The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
|
|
15
|
+
(`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
|
|
16
|
+
implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
|
|
17
|
+
(`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
|
|
18
|
+
`FakeSock` test harness.
|
|
19
|
+
|
|
20
|
+
A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
|
|
21
|
+
plugin against the daemon-side contract with:
|
|
22
|
+
|
|
23
|
+
switchyardctl plugin test "python my_plugin.py"
|
|
24
|
+
|
|
25
|
+
Docs: https://docs.relayfabric.org/plugin-authors/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
relayfabric_sdk/__init__.py
|
|
4
|
+
relayfabric_sdk/bridge.py
|
|
5
|
+
relayfabric_sdk/cache.py
|
|
6
|
+
relayfabric_sdk/harness.py
|
|
7
|
+
relayfabric_sdk/ipc.py
|
|
8
|
+
relayfabric_sdk/nip01.py
|
|
9
|
+
relayfabric_sdk/runner.py
|
|
10
|
+
relayfabric_sdk.egg-info/PKG-INFO
|
|
11
|
+
relayfabric_sdk.egg-info/SOURCES.txt
|
|
12
|
+
relayfabric_sdk.egg-info/dependency_links.txt
|
|
13
|
+
relayfabric_sdk.egg-info/requires.txt
|
|
14
|
+
relayfabric_sdk.egg-info/top_level.txt
|
|
15
|
+
tests/test_bridge.py
|
|
16
|
+
tests/test_cache.py
|
|
17
|
+
tests/test_ipc.py
|
|
18
|
+
tests/test_nip01.py
|
|
19
|
+
tests/test_runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cbor2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
relayfabric_sdk
|