fleetmesh-strfry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ """fleetmesh-strfry: the strfry relay bridge for a Fleetmesh node.
2
+
3
+ Two ways to run it, both over the same classes:
4
+
5
+ - in the node's process: `fleetmesh_strfry.plugin:StrfryBridgePlugin` in the
6
+ node's `plugins` config block;
7
+ - beside the node: `python -m fleetmesh_strfry run --dir NODE_DIR`.
8
+
9
+ This package imports `node.protocols.nostr` (`client`, `eventstore`),
10
+ `node.storage.grants`, `node.keystore`, `node.interfaces`, `node.compartment`
11
+ and `fleetmesh_core` at runtime: it runs on the node host with the meshnode
12
+ and core checkouts on PYTHONPATH. It has no other dependency beyond the standard library.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .bridge import StrfryRelayBridge
17
+ from .pipe import StrfryPipe
18
+ from .plugin import PLUGIN_REF, StrfryBridgePlugin, cmd_strfry, register_cli
19
+ from .policy import (
20
+ DEFAULT_STRFRY_URL,
21
+ MESH_CONTROL_KINDS,
22
+ RateLimiter,
23
+ StrfryPluginRunner,
24
+ StrfryPolicyConfig,
25
+ StrfryPolicyEngine,
26
+ StrfryPolicyError,
27
+ )
28
+
29
+ __version__ = '0.1.0'
30
+
31
+ __all__ = [
32
+ 'DEFAULT_STRFRY_URL', 'MESH_CONTROL_KINDS', 'PLUGIN_REF', 'RateLimiter',
33
+ 'StrfryBridgePlugin', 'StrfryPipe', 'StrfryPluginRunner', 'StrfryPolicyConfig',
34
+ 'StrfryPolicyEngine', 'StrfryPolicyError', 'StrfryRelayBridge',
35
+ 'cmd_strfry', 'register_cli', '__version__',
36
+ ]
@@ -0,0 +1,122 @@
1
+ """`python -m fleetmesh_strfry`: the strfry bridge as its own service.
2
+
3
+ python -m fleetmesh_strfry run --dir NODE_DIR [--url ws://127.0.0.1:7777] [--every 30]
4
+ python -m fleetmesh_strfry plugin --dir NODE_DIR # strfry writePolicy stdio runner
5
+ python -m fleetmesh_strfry status|export|import|eval --dir NODE_DIR [flags]
6
+
7
+ `run` opens the node directory's stores the way `meshnode strfry` did, runs
8
+ a StrfryRelayBridge against the strfry URL, and drains the egress queue
9
+ every `--every` seconds until SIGTERM/SIGINT. The other verbs are the
10
+ former `meshnode strfry DIR ACTION` with `--dir` instead of a positional.
11
+
12
+ Exit codes: 0 on a clean stop or an accepted `eval`; 1 when `eval` rejects;
13
+ 2 when the arguments are unusable (no such directory, no keys).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import signal
20
+ import sys
21
+ import threading
22
+ from typing import Optional, Sequence
23
+
24
+ from .bridge import StrfryRelayBridge
25
+ from .plugin import ACTIONS, STORE_FILE, add_strfry_arguments, cmd_strfry, open_stores
26
+ from .policy import DEFAULT_STRFRY_URL, StrfryPolicyConfig, StrfryPolicyEngine
27
+
28
+ EXIT_OK = 0
29
+ EXIT_USAGE = 2
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ ap = argparse.ArgumentParser(prog='python -m fleetmesh_strfry',
34
+ description=(__doc__ or '').split('\n\n')[0])
35
+ sub = ap.add_subparsers(dest='action', required=True)
36
+
37
+ run = sub.add_parser('run', help='bridge the node store and a strfry relay until stopped')
38
+ run.add_argument('--dir', required=True, help='the node directory (keys.json, store.sqlite, grants.sqlite)')
39
+ run.add_argument('--url', default=DEFAULT_STRFRY_URL, help='strfry WebSocket URL')
40
+ run.add_argument('--every', type=float, default=30.0, help='seconds between egress flushes')
41
+ run.add_argument('--store-file', default=STORE_FILE, help=argparse.SUPPRESS)
42
+ run.add_argument('--enforce-grants', action='store_true', help='enforce bilateral peering grants')
43
+ run.add_argument('--shadow', action='store_true', help='use shadowReject instead of reject')
44
+ run.add_argument('--iterations', type=int, default=0, help=argparse.SUPPRESS) # tests: stop after N flushes
45
+
46
+ for action in ACTIONS:
47
+ p = sub.add_parser(action, help='the former `meshnode strfry DIR %s`' % action)
48
+ p.add_argument('--dir', required=True, help='the node directory')
49
+ p.add_argument('--store-file', default=STORE_FILE, help=argparse.SUPPRESS)
50
+ p.add_argument('--json', action='store_true', help='print the structure, for scripts')
51
+ add_strfry_arguments(p)
52
+ return ap
53
+
54
+
55
+ def _usable(directory: str) -> Optional[str]:
56
+ if not os.path.isdir(directory):
57
+ return 'no such directory: %s' % directory
58
+ if not os.path.exists(os.path.join(directory, 'keys.json')):
59
+ return 'not a node directory (no keys.json): %s' % directory
60
+ return None
61
+
62
+
63
+ def serve(directory: str, url: str, every: float, store_file: str = STORE_FILE,
64
+ enforce_grants: bool = False, shadow: bool = False, iterations: int = 0,
65
+ stop: Optional[threading.Event] = None) -> int:
66
+ """Run the bridge until `stop` is set (SIGTERM/SIGINT set it) or `iterations` flushes are done."""
67
+ st, gr = open_stores(directory, store_file)
68
+ engine = StrfryPolicyEngine(config=StrfryPolicyConfig(enforce_grants=enforce_grants, shadow_reject=shadow),
69
+ store_backend=st, grant_store=gr)
70
+ bridge = StrfryRelayBridge(store_backend=st, strfry_url=url, policy_engine=engine)
71
+ stop = stop or threading.Event()
72
+
73
+ def _on_signal(signum: int, _frame: object) -> None:
74
+ stop.set()
75
+
76
+ main_thread = threading.current_thread() is threading.main_thread()
77
+ if main_thread:
78
+ for sig in (signal.SIGTERM, signal.SIGINT):
79
+ signal.signal(sig, _on_signal)
80
+
81
+ print('fleetmesh-strfry: bridging %s <-> %s every %gs' % (directory, url, every), file=sys.stderr, flush=True)
82
+ bridge.start()
83
+ done = 0
84
+ try:
85
+ while not stop.is_set():
86
+ if stop.wait(every):
87
+ break
88
+ sent = bridge.flush_egress()
89
+ done += 1
90
+ if sent:
91
+ print('fleetmesh-strfry: flushed %d egress events' % sent, file=sys.stderr, flush=True)
92
+ if iterations and done >= iterations:
93
+ break
94
+ finally:
95
+ bridge.stop()
96
+ for s in (st, gr):
97
+ close = getattr(s, 'close', None)
98
+ if callable(close):
99
+ try:
100
+ close()
101
+ except Exception:
102
+ pass
103
+ print('fleetmesh-strfry: stopped; %s' % bridge.stats(), file=sys.stderr, flush=True)
104
+ return EXIT_OK
105
+
106
+
107
+ def main(argv: Optional[Sequence[str]] = None) -> int:
108
+ ap = build_parser()
109
+ a = ap.parse_args(argv)
110
+ why = _usable(a.dir)
111
+ if why is not None:
112
+ print('fleetmesh-strfry: %s' % why, file=sys.stderr)
113
+ return EXIT_USAGE
114
+ if a.action == 'run':
115
+ return serve(a.dir, a.url, a.every, store_file=a.store_file, enforce_grants=a.enforce_grants,
116
+ shadow=a.shadow, iterations=a.iterations)
117
+ code = cmd_strfry(a)
118
+ return int(code) if isinstance(code, int) else EXIT_OK
119
+
120
+
121
+ if __name__ == '__main__':
122
+ sys.exit(main())
@@ -0,0 +1,236 @@
1
+ """Live Streaming Bridge (StrfryRelayBridge).
2
+
3
+ Real-time bidirectional streaming between a local/remote strfry relay (via WebSocket)
4
+ and the Fleetmesh node store and P2P gossip planes.
5
+
6
+ Moved verbatim from meshnode's node/strfry_bridge.py; only the import paths changed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import collections
11
+ import threading
12
+ import time
13
+ from typing import Any, Callable, Deque, Optional, Sequence
14
+
15
+ from node.protocols.nostr import client as relay
16
+ from node.interfaces import NostrEvent
17
+
18
+ from .policy import DEFAULT_STRFRY_URL, StrfryPolicyEngine
19
+
20
+
21
+ class StrfryRelayBridge:
22
+ """Live bidirectional WebSocket streaming bridge connecting strfry and Fleetmesh."""
23
+
24
+ def __init__(
25
+ self,
26
+ store_backend: Any,
27
+ strfry_url: str = DEFAULT_STRFRY_URL,
28
+ policy_engine: Optional[StrfryPolicyEngine] = None,
29
+ kinds: Optional[Sequence[int]] = None,
30
+ clock: Optional[Callable[[], int]] = None,
31
+ ) -> None:
32
+ self.store: Any = store_backend
33
+ self.strfry_url: str = strfry_url
34
+ self.policy: StrfryPolicyEngine = policy_engine or StrfryPolicyEngine(store_backend=store_backend)
35
+ self.kinds: list[int] = list(kinds or [0, 1, 3, 6, 7, 1059, 11801, 11802, 11803, 30801, 30802, 30803])
36
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
37
+
38
+ self.connected: bool = False
39
+ self.last_connected_at: Optional[int] = None
40
+ self.ingress_count: int = 0
41
+ self.egress_count: int = 0
42
+ self.errors_count: int = 0
43
+ self.last_error: Optional[str] = None
44
+
45
+ self._relay: Optional[relay.Relay] = None
46
+ self._thread: Optional[threading.Thread] = None
47
+ self._stop_event: threading.Event = threading.Event()
48
+ self._egress_queue: Deque[NostrEvent] = collections.deque(maxlen=1000)
49
+ self._lock: threading.Lock = threading.Lock()
50
+
51
+ def enqueue(self, event: NostrEvent) -> None:
52
+ """Queue `event` for strfry; it goes out on the next flush_egress.
53
+
54
+ The queue holds the newest 1000: when full, the oldest is dropped."""
55
+ if isinstance(event, dict):
56
+ with self._lock:
57
+ self._egress_queue.append(event)
58
+
59
+ def start(self) -> None:
60
+ """Launch background streaming bridge worker."""
61
+ with self._lock:
62
+ if self._thread is not None and self._thread.is_alive():
63
+ return
64
+ self._stop_event.clear()
65
+ self._thread = threading.Thread(target=self._run_loop, name=f'strfry-bridge-{self.strfry_url}', daemon=True)
66
+ self._thread.start()
67
+
68
+ def stop(self) -> None:
69
+ """Stop background worker and close WebSocket."""
70
+ self._stop_event.set()
71
+ with self._lock:
72
+ if self._relay is not None:
73
+ try:
74
+ self._relay.close()
75
+ except Exception:
76
+ pass
77
+ self._relay = None
78
+ self.connected = False
79
+ if self._thread is not None and self._thread.is_alive():
80
+ self._thread.join(timeout=1.0)
81
+ self._thread = None
82
+
83
+ def publish_to_strfry(self, event: NostrEvent) -> tuple[bool, str]:
84
+ """Publish an outbound event to the strfry relay after egress policy check."""
85
+ # 1. Egress Policy Evaluation
86
+ verdict = self.policy.evaluate_egress(event)
87
+ act = verdict.get('action', 'allow')
88
+ if act == 'drop':
89
+ return False, f"egress dropped: {verdict.get('msg', 'policy')}"
90
+ elif act == 'shadowDrop':
91
+ return True, "shadow dropped"
92
+ elif act == 'redact':
93
+ # A redacted event is the same event with fewer tags; its id and sig
94
+ # are deliberately left as they were - the relay is expected to
95
+ # refuse it, and the audit trail shows what was attempted.
96
+ event = {**event, 'tags': verdict.get('redacted_tags', [])}
97
+
98
+ # 2. WebSocket publication via dedicated relay client to prevent socket contention
99
+ pub_client = relay.Relay(self.strfry_url, timeout=5.0)
100
+ try:
101
+ pub_client.connect()
102
+ ok_pub, reason = pub_client.publish(event)
103
+ if ok_pub:
104
+ self.egress_count += 1
105
+ return ok_pub, reason
106
+ except Exception as e:
107
+ self.errors_count += 1
108
+ self.last_error = str(e)
109
+ self.enqueue(event)
110
+ return False, str(e)
111
+ finally:
112
+ pub_client.close()
113
+
114
+ def _run_loop(self) -> None:
115
+ consecutive_errors = 0
116
+ while not self._stop_event.is_set():
117
+ try:
118
+ r = relay.Relay(self.strfry_url, timeout=10.0)
119
+ r.connect()
120
+ with self._lock:
121
+ self._relay = r
122
+ self.connected = True
123
+ self.last_connected_at = int(time.time())
124
+ self.last_error = None
125
+ consecutive_errors = 0
126
+
127
+ # Subscribe to strfry
128
+ sid = r.subscribe([{'kinds': self.kinds}], ready=False)
129
+
130
+ # Anything queued while strfry was away goes out now, not
131
+ # the next time strfry happens to send us something.
132
+ self.flush_egress()
133
+
134
+ # Process incoming stream
135
+ for ev in r.events(sid):
136
+ if self._stop_event.is_set():
137
+ break
138
+
139
+ # Pass through writePolicy engine
140
+ verdict = self.policy.evaluate_ingress({'type': 'new', 'event': ev})
141
+ if verdict.get('action') == 'accept':
142
+ self.ingress_count += 1
143
+ if self.policy.store is not self.store and hasattr(self.store, 'insert_event'):
144
+ self.store.insert_event(ev, self.clock())
145
+ if hasattr(self.store, 'note_origin'):
146
+ try:
147
+ self.store.note_origin(ev.get('id', ''), getattr(self.store, 'LOCAL', 'local'))
148
+ except Exception:
149
+ pass
150
+
151
+ # Flush pending egress
152
+ self.flush_egress()
153
+
154
+ except Exception as e:
155
+ consecutive_errors += 1
156
+ self.errors_count += 1
157
+ self.last_error = f'{type(e).__name__}: {e}'
158
+ with self._lock:
159
+ self.connected = False
160
+ if self._relay is not None:
161
+ try:
162
+ self._relay.close()
163
+ except Exception:
164
+ pass
165
+ self._relay = None
166
+
167
+ if not self._stop_event.is_set():
168
+ backoff = min(10.0, 0.5 * (2 ** min(consecutive_errors, 4)))
169
+ self._stop_event.wait(backoff)
170
+
171
+ def flush_egress(self) -> int:
172
+ """Send queued egress to strfry; returns how many went out.
173
+
174
+ Driven from three places: after every (re)connect of the read loop,
175
+ after every inbound event, and from the plugin's tick() - so a quiet
176
+ strfry that sends nothing still gets what was queued for it. What
177
+ cannot be sent because strfry is unreachable is put back at the
178
+ front of the queue for the next attempt, in order.
179
+ """
180
+ raw_items: list[NostrEvent] = []
181
+ with self._lock:
182
+ while self._egress_queue and len(raw_items) < 20:
183
+ raw_items.append(self._egress_queue.popleft())
184
+ if not raw_items:
185
+ return 0
186
+ items: list[NostrEvent] = []
187
+ for ev in raw_items:
188
+ verdict = self.policy.evaluate_egress(ev)
189
+ act = verdict.get('action', 'allow')
190
+ # A redacted event is a different event: its id and signature
191
+ # cover the tags, so strfry would refuse it. Withhold it instead.
192
+ if act in ('drop', 'shadowDrop', 'redact'):
193
+ continue
194
+ items.append(ev)
195
+ if not items:
196
+ return 0
197
+ sent = 0
198
+ pub_client = relay.Relay(self.strfry_url, timeout=5.0)
199
+ try:
200
+ pub_client.connect()
201
+ while items:
202
+ ev = items[0]
203
+ try:
204
+ ok_p, _ = pub_client.publish(ev)
205
+ except Exception:
206
+ break # connection gone; keep this and the rest
207
+ items.pop(0)
208
+ if ok_p:
209
+ self.egress_count += 1
210
+ sent += 1
211
+ # a relay that says "false" has judged the event; retrying it is pointless
212
+ except Exception as e:
213
+ self.last_error = f'{type(e).__name__}: {e}'
214
+ finally:
215
+ pub_client.close()
216
+ if items:
217
+ with self._lock:
218
+ self._egress_queue.extendleft(reversed(items))
219
+ return sent
220
+
221
+ def pending_egress(self) -> int:
222
+ with self._lock:
223
+ return len(self._egress_queue)
224
+
225
+ def stats(self) -> dict[str, Any]:
226
+ return {
227
+ 'strfry_url': self.strfry_url,
228
+ 'connected': self.connected,
229
+ 'last_connected_at': self.last_connected_at,
230
+ 'ingress_count': self.ingress_count,
231
+ 'egress_count': self.egress_count,
232
+ 'errors_count': self.errors_count,
233
+ 'last_error': self.last_error,
234
+ 'pending_egress': self.pending_egress(),
235
+ 'policy': self.policy.stats(),
236
+ }
@@ -0,0 +1,78 @@
1
+ """High-throughput bulk stream pipe (StrfryPipe): JSONL export/import compatible with strfry.
2
+
3
+ - Streaming JSONL export (`NodeStore` -> strfry export format).
4
+ - Streaming JSONL import (`strfry export` -> `NodeStore`) with deduplication and origin tracking.
5
+
6
+ Moved verbatim from meshnode's node/strfry_bridge.py; only the import paths changed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from typing import Any, Callable, Optional, TextIO
13
+ from fleetmesh_core.storage import store # noqa: E402
14
+
15
+
16
+ class StrfryPipe:
17
+ """High-throughput bulk streaming export and import compatible with strfry."""
18
+
19
+ @staticmethod
20
+ def export_jsonl(store_backend: Any, out_stream: TextIO) -> int:
21
+ """Stream events from NodeStore out to JSONL (strfry export format)."""
22
+ count = 0
23
+ if hasattr(store_backend, 'db') and hasattr(store_backend.db, 'execute'):
24
+ cur = store_backend.db.execute('SELECT json FROM events ORDER BY created_at ASC, id ASC')
25
+ for row in cur:
26
+ out_stream.write(row[0] + '\n')
27
+ count += 1
28
+ elif hasattr(store_backend, 'all'):
29
+ for ev in store_backend.all():
30
+ out_stream.write(json.dumps(ev, separators=(',', ':')) + '\n')
31
+ count += 1
32
+ out_stream.flush()
33
+ return count
34
+
35
+ @staticmethod
36
+ def import_jsonl(
37
+ store_backend: Any,
38
+ in_stream: TextIO,
39
+ verify: bool = True,
40
+ clock_fn: Optional[Callable[[], int]] = None,
41
+ ) -> tuple[int, int, int]:
42
+ """Stream events from JSONL (strfry export format) into NodeStore.
43
+
44
+ Returns (imported, duplicates, errors).
45
+ """
46
+ now = (clock_fn or (lambda: int(time.time())))()
47
+ imported = 0
48
+ duplicates = 0
49
+ errors = 0
50
+
51
+ for line in in_stream:
52
+ line = line.strip()
53
+ if not line:
54
+ continue
55
+
56
+ try:
57
+ ev = json.loads(line)
58
+ if not isinstance(ev, dict) or 'id' not in ev:
59
+ errors += 1
60
+ continue
61
+
62
+ if verify and not store.verify_event(ev):
63
+ errors += 1
64
+ continue
65
+
66
+ res = store_backend.insert_event(ev, now)
67
+ if res == 'stored':
68
+ imported += 1
69
+ if hasattr(store_backend, 'note_origin'):
70
+ store_backend.note_origin(ev['id'], getattr(store_backend, 'LOCAL', 'local'))
71
+ elif res == 'duplicate':
72
+ duplicates += 1
73
+ else:
74
+ errors += 1
75
+ except Exception:
76
+ errors += 1
77
+
78
+ return imported, duplicates, errors