nativerelay 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.
collectors/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Native platform collectors."""
@@ -0,0 +1,3 @@
1
+ """Linux kernel telemetry collector."""
2
+ from .collector import LinuxCollector
3
+ __all__ = ["LinuxCollector"]
@@ -0,0 +1,319 @@
1
+ """Linux process connector and fanotify collectors (Linux only, stdlib only).
2
+
3
+ Process lifecycle uses NETLINK_CONNECTOR/CN_PROC. File events use a fanotify
4
+ mount mark, filtered to one requested directory in userspace. No polling is
5
+ used; permission and queue errors are surfaced in capabilities/health.
6
+ """
7
+ from __future__ import annotations
8
+ import ctypes
9
+ import errno
10
+ from dataclasses import replace
11
+ import os
12
+ import platform
13
+ import re
14
+ import select
15
+ import socket
16
+ import struct
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from nativerelay.collector import Collector
22
+ from nativerelay.model import Capability, CollectorStatus, Event, EventType, Process, Resource
23
+ from nativerelay.stream import EventStream
24
+
25
+ NETLINK_CONNECTOR = 11
26
+ CN_IDX_PROC = 1
27
+ CN_VAL_PROC = 1
28
+ NLMSG_DONE = 3
29
+ NLMSG_OVERRUN = 4
30
+ PROC_EVENT_FORK = 0x00000001
31
+ PROC_EVENT_EXIT = 0x80000000
32
+ PROC_CN_MCAST_LISTEN = 1
33
+ FAN_CLOEXEC = 0x00000001
34
+ FAN_CLASS_NOTIF = 0x00000000
35
+ FAN_NONBLOCK = 0x00000002
36
+ FAN_MARK_ADD = 0x00000001
37
+ FAN_MARK_MOUNT = 0x00000010
38
+ FAN_MODIFY = 0x00000002
39
+ FAN_OPEN = 0x00000020
40
+ FAN_Q_OVERFLOW = 0x00004000
41
+ FANOTIFY_METADATA_VERSION = 3
42
+ FANOTIFY_METADATA_LEN = 24
43
+ FAN_EVENT_METADATA_FMT = "=IBBHQii"
44
+
45
+ class LinuxCollector(Collector):
46
+ name = "linux-kernel"
47
+ def __init__(self, scope: str, *, include_command: bool = False):
48
+ if platform.system() != "Linux":
49
+ raise OSError("LinuxCollector is available only on Linux")
50
+ self.scope = Path(scope).resolve(strict=True)
51
+ if not self.scope.is_dir(): raise ValueError("scope must be an existing directory")
52
+ self.include_command = include_command
53
+ self._stream = None
54
+ self._sockets = []
55
+ self._threads = []
56
+ self._stop = threading.Event()
57
+ self._publish_lock = threading.Lock()
58
+ self._sequence = 0
59
+ self.health = {"process": "not_started", "filesystem": "not_started", "dropped": 0, "errors": []}
60
+ self._caps = self._initial_capabilities()
61
+
62
+ def _initial_capabilities(self):
63
+ return tuple(Capability(t, CollectorStatus.SUPPORTED if t in (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED, EventType.FILE_OPENED, EventType.FILE_MODIFIED) else CollectorStatus.UNSUPPORTED,
64
+ "collector has not started" if t in (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED, EventType.FILE_OPENED, EventType.FILE_MODIFIED) else
65
+ "This collector does not infer namespace changes from file content notifications",
66
+ "host process table" if t.value.startswith("process") else str(self.scope),
67
+ "kernel PID/TGID" if t.value.startswith("process") else "fanotify event PID") for t in EventType)
68
+
69
+ @property
70
+ def capabilities(self): return self._caps
71
+
72
+ @property
73
+ def status(self):
74
+ losses = self._stream.losses if self._stream is not None else getattr(self, "_last_stream_losses", {})
75
+ generation = self._stream.loss_generation if self._stream is not None else getattr(self, "_last_loss_generation", 0)
76
+ return {**self.health, "errors": list(self.health["errors"]), "losses": losses, "loss_generation": generation}
77
+
78
+ def start(self, stream: EventStream):
79
+ if self._stream is not None: raise RuntimeError("collector already started")
80
+ self._stop = threading.Event()
81
+ self._publish_lock = threading.Lock()
82
+ self._sequence = 0
83
+ self.health = {"process": "not_started", "filesystem": "not_started", "dropped": 0, "errors": []}
84
+ self._caps = self._initial_capabilities()
85
+ self._stream = stream
86
+ self._last_stream_losses = {}
87
+ self._start_process()
88
+ self._start_filesystem()
89
+
90
+ def stop(self):
91
+ if self._stream is None: return
92
+ self._stop.set()
93
+ for sock in self._sockets:
94
+ try:
95
+ if isinstance(sock, int): os.close(sock)
96
+ else: sock.close()
97
+ except OSError: pass
98
+ for thread in self._threads: thread.join(timeout=2)
99
+ self._last_stream_losses = self._stream.losses
100
+ self._last_loss_generation = self._stream.loss_generation
101
+ self._stream.close()
102
+ self._sockets.clear(); self._threads.clear(); self._stream = None
103
+
104
+ def _failure(self, subsystem, exc):
105
+ status = CollectorStatus.PERMISSION_DENIED if isinstance(exc, PermissionError) or getattr(exc, "errno", None) in (errno.EPERM, errno.EACCES) else CollectorStatus.UNAVAILABLE
106
+ self.health[subsystem] = status.value
107
+ self.health["errors"].append(f"{subsystem}: {exc}")
108
+ affected = (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED) if subsystem == "process" else (EventType.FILE_OPENED, EventType.FILE_MODIFIED)
109
+ self._caps = tuple(Capability(c.event_type, status if c.event_type in affected else c.status,
110
+ str(exc) if c.event_type in affected else c.reason, c.scope, c.attribution) for c in self._caps)
111
+
112
+ def _start_process(self):
113
+ try:
114
+ sock = socket.socket(socket.AF_NETLINK, socket.SOCK_DGRAM, NETLINK_CONNECTOR)
115
+ sock.bind((os.getpid(), CN_IDX_PROC))
116
+ payload = struct.pack("=IIIIHHI", CN_IDX_PROC, CN_VAL_PROC, 0, 0, 4, 0, PROC_CN_MCAST_LISTEN)
117
+ header = struct.pack("=IHHII", 16 + len(payload), NLMSG_DONE, 0, 1, os.getpid())
118
+ sock.send(header + payload)
119
+ self._sockets.append(sock)
120
+ # CN_PROC may silently ignore subscribers outside the initial PID/user namespaces.
121
+ # Require its connector acknowledgement before claiming process coverage.
122
+ deadline = time.monotonic() + 2.0
123
+ while self.health["process"] != "available":
124
+ remaining = deadline - time.monotonic()
125
+ if remaining <= 0:
126
+ raise TimeoutError("CN_PROC sent no subscription acknowledgement; process events may be disabled or this PID namespace may not be the kernel's initial namespace")
127
+ sock.settimeout(remaining)
128
+ packet = sock.recv(65536)
129
+ try: self._parse_proc_packet(packet)
130
+ except (ValueError, struct.error, OSError) as exc:
131
+ self.health["errors"].append(f"malformed process event during startup: {exc}")
132
+ self._record_native_loss("cn_proc_malformed_packet", count=None)
133
+ if self.health["process"] in (CollectorStatus.PERMISSION_DENIED.value, CollectorStatus.UNAVAILABLE.value): break
134
+ if self.health["process"] != "available":
135
+ sock.close()
136
+ self._sockets.remove(sock)
137
+ return
138
+ sock.settimeout(.5)
139
+ thread = threading.Thread(target=self._process_loop, args=(sock,), daemon=True, name="nativerelay-proc")
140
+ thread.start(); self._threads.append(thread)
141
+ except OSError as exc:
142
+ self._failure("process", exc)
143
+
144
+ def _process_loop(self, sock):
145
+ while not self._stop.is_set():
146
+ try: packet = sock.recv(65536)
147
+ except socket.timeout: continue
148
+ except OSError:
149
+ if not self._stop.is_set(): self._failure("process", OSError("process connector socket closed"))
150
+ break
151
+ try: self._parse_proc_packet(packet)
152
+ except (ValueError, struct.error, OSError) as exc:
153
+ self.health["errors"].append(f"malformed process event: {exc}")
154
+ self._record_native_loss("cn_proc_malformed_packet", count=None)
155
+
156
+ def _parse_proc_packet(self, packet):
157
+ # Netlink header (16), connector header (20), then proc_event data.
158
+ offset = 0
159
+ while offset < len(packet):
160
+ if len(packet) - offset < 16: raise ValueError("truncated netlink header")
161
+ nl_len, nl_type, _, _, _ = struct.unpack_from("=IHHII", packet, offset)
162
+ if nl_len < 16 or offset + nl_len > len(packet): raise ValueError("invalid netlink message length")
163
+ if nl_type == NLMSG_OVERRUN:
164
+ self._record_native_loss("cn_proc_netlink_overrun", count=None)
165
+ offset += (nl_len + 3) & ~3
166
+ continue
167
+ if len(packet) - offset < 36 or nl_len < 36: raise ValueError("truncated netlink connector message")
168
+ cn = offset + 16
169
+ idx, val = struct.unpack_from("=II", packet, cn)
170
+ payload_len = struct.unpack_from("=H", packet, cn + 16)[0]
171
+ if payload_len > nl_len - 36: raise ValueError("connector payload exceeds netlink message")
172
+ if idx == CN_IDX_PROC and val == CN_VAL_PROC and payload_len >= 16:
173
+ body = cn + 20
174
+ what = struct.unpack_from("=I", packet, body)[0]
175
+ data = body + 16
176
+ if what == 0 and payload_len >= 20:
177
+ error = struct.unpack_from("=i", packet, data)[0]
178
+ if error:
179
+ code = abs(error)
180
+ self._failure("process", OSError(code, os.strerror(code)))
181
+ else:
182
+ self.health["process"] = "available"
183
+ self._set_status((EventType.PROCESS_STARTED, EventType.PROCESS_EXITED), CollectorStatus.AVAILABLE, "Linux NETLINK_CONNECTOR/CN_PROC notifications acknowledged")
184
+ elif what == PROC_EVENT_FORK and payload_len >= 32:
185
+ _, parent_tgid, child_pid, child_tgid = struct.unpack_from("=IIII", packet, data)
186
+ if child_pid == child_tgid:
187
+ self._emit_process(EventType.PROCESS_STARTED, child_tgid, parent_tgid)
188
+ elif what == PROC_EVENT_EXIT and payload_len >= 32:
189
+ pid, tgid, code, sig = struct.unpack_from("=IIII", packet, data)
190
+ if pid == tgid:
191
+ self._emit_process(EventType.PROCESS_EXITED, tgid, None,
192
+ {"exitStatus": code, "exitSignal": sig, "leaderThreadExit": True})
193
+ offset += nl_len
194
+ def _process_info(self, pid, parent_pid):
195
+ proc = Path("/proc") / str(pid)
196
+ try:
197
+ stat = (proc / "stat").read_text()
198
+ # comm may contain spaces and ')' characters; fields after final ')' are stable.
199
+ tail = stat[stat.rfind(")") + 2:].split()
200
+ parent_pid = int(tail[1]) if parent_pid is None and len(tail) > 1 else parent_pid
201
+ except (OSError, ValueError, IndexError): pass
202
+ try: executable = os.readlink(proc / "exe")
203
+ except OSError: executable = None
204
+ command = None
205
+ if self.include_command:
206
+ try: command = tuple((proc / "cmdline").read_bytes().decode(errors="replace").rstrip("\0").split("\0"))
207
+ except OSError: pass
208
+ return Process(pid=pid, parent_pid=parent_pid, executable=executable, command=command)
209
+
210
+ def _emit_process(self, typ, pid, ppid, metadata=None):
211
+ self._publish(Event(typ, "linux", self.name, self._process_info(pid, ppid), metadata=metadata or {}))
212
+
213
+ def _start_filesystem(self):
214
+ try:
215
+ libc = ctypes.CDLL(None, use_errno=True)
216
+ init = libc.fanotify_init
217
+ init.argtypes = [ctypes.c_uint, ctypes.c_uint]
218
+ init.restype = ctypes.c_int
219
+ fd = init(FAN_CLOEXEC | FAN_CLASS_NOTIF | FAN_NONBLOCK, os.O_RDONLY | os.O_LARGEFILE if hasattr(os, "O_LARGEFILE") else os.O_RDONLY)
220
+ if fd < 0: self._raise_errno()
221
+ self._fanotify_fd = fd
222
+ mark = libc.fanotify_mark
223
+ mark.argtypes = [ctypes.c_int, ctypes.c_uint, ctypes.c_uint64, ctypes.c_int, ctypes.c_char_p]
224
+ mark.restype = ctypes.c_int
225
+ mountpoint = self._mountpoint(self.scope)
226
+ if mark(fd, FAN_MARK_ADD | FAN_MARK_MOUNT, FAN_OPEN | FAN_MODIFY, -100, os.fsencode(mountpoint)) < 0:
227
+ os.close(fd); self._raise_errno()
228
+ self._sockets.append(fd)
229
+ self.health["filesystem"] = "available"
230
+ self._set_status((EventType.FILE_OPENED, EventType.FILE_MODIFIED), CollectorStatus.AVAILABLE, f"fanotify mount notifications filtered to {self.scope}")
231
+ thread = threading.Thread(target=self._fanotify_loop, daemon=True, name="nativerelay-fanotify")
232
+ thread.start(); self._threads.append(thread)
233
+ except OSError as exc:
234
+ self._failure("filesystem", exc)
235
+
236
+ @staticmethod
237
+ def _raise_errno():
238
+ code = ctypes.get_errno()
239
+ raise OSError(code, os.strerror(code))
240
+
241
+ @staticmethod
242
+ def _mountpoint(path):
243
+ best = Path("/")
244
+ for line in Path("/proc/self/mountinfo").read_text().splitlines():
245
+ fields = line.split()
246
+ if len(fields) > 4:
247
+ decoded = re.sub(r"\\([0-7]{3})", lambda match: chr(int(match.group(1), 8)), fields[4])
248
+ try: mount = Path(decoded).resolve()
249
+ except OSError: continue
250
+ if path == mount or mount in path.parents:
251
+ if len(str(mount)) > len(str(best)): best = mount
252
+ return best
253
+
254
+ def _fanotify_loop(self):
255
+ poller = select.poll()
256
+ poller.register(self._fanotify_fd, select.POLLIN | select.POLLERR | select.POLLHUP)
257
+ while not self._stop.is_set():
258
+ ready = poller.poll(500)
259
+ if not ready: continue
260
+ try: data = os.read(self._fanotify_fd, 65536)
261
+ except BlockingIOError: continue
262
+ except OSError:
263
+ if not self._stop.is_set(): self._failure("filesystem", OSError("fanotify descriptor closed"))
264
+ return
265
+ self._parse_fanotify_data(data)
266
+
267
+ def _parse_fanotify_data(self, data):
268
+ offset = 0
269
+ while offset + FANOTIFY_METADATA_LEN <= len(data):
270
+ try:
271
+ length, version, _, metadata_len, mask, event_fd, pid = struct.unpack_from(FAN_EVENT_METADATA_FMT, data, offset)
272
+ if length < FANOTIFY_METADATA_LEN or offset + length > len(data): raise ValueError("invalid fanotify event length")
273
+ if version != FANOTIFY_METADATA_VERSION or metadata_len < FANOTIFY_METADATA_LEN or metadata_len > length: raise ValueError("unsupported fanotify metadata version")
274
+ if mask & FAN_Q_OVERFLOW:
275
+ self._record_native_loss("fanotify_queue_overflow", count=None)
276
+ elif event_fd >= 0:
277
+ self._handle_file_event(mask, event_fd, pid)
278
+ offset += length
279
+ except (ValueError, struct.error) as exc:
280
+ self.health["errors"].append(f"malformed fanotify event: {exc}")
281
+ self._record_native_loss("fanotify_malformed_event", count=None)
282
+ break
283
+ if offset < len(data) and len(data) - offset < FANOTIFY_METADATA_LEN:
284
+ self.health["errors"].append("truncated fanotify metadata")
285
+ self._record_native_loss("fanotify_truncated_metadata", count=None)
286
+ def _handle_file_event(self, mask, fd, pid):
287
+ try:
288
+ path = Path(os.readlink(f"/proc/self/fd/{fd}")).resolve(strict=False)
289
+ if path != self.scope and self.scope not in path.parents: return
290
+ if mask & FAN_OPEN:
291
+ self._publish(Event(EventType.FILE_OPENED, "linux", self.name, self._process_info(pid, None), Resource("file", str(path))))
292
+ if mask & FAN_MODIFY:
293
+ self._publish(Event(EventType.FILE_MODIFIED, "linux", self.name, self._process_info(pid, None), Resource("file", str(path))))
294
+ except (OSError, ValueError) as exc:
295
+ self.health["errors"].append(f"file event resolution: {exc}")
296
+ lost = int(bool(mask & FAN_OPEN)) + int(bool(mask & FAN_MODIFY))
297
+ if lost: self._record_native_loss("fanotify_path_resolution", count=lost)
298
+ finally:
299
+ try: os.close(fd)
300
+ except OSError: pass
301
+
302
+ def _record_native_loss(self, reason, *, count):
303
+ """Record detected native loss and reserve a collector sequence marker."""
304
+ with self._publish_lock:
305
+ sequence = self._sequence
306
+ self._sequence += 1
307
+ if self._stream is not None:
308
+ self._stream.report_loss(self.name, reason, count=count, sequence=sequence)
309
+
310
+ def _publish(self, event):
311
+ with self._publish_lock:
312
+ sequenced = replace(event, sequence=self._sequence)
313
+ self._sequence += 1
314
+ if self._stream is not None and not self._stream.publish(sequenced):
315
+ self.health["dropped"] += 1
316
+
317
+ def _set_status(self, event_types, status, reason):
318
+ self._caps = tuple(Capability(c.event_type, status if c.event_type in event_types else c.status,
319
+ reason if c.event_type in event_types else c.reason, c.scope, c.attribution) for c in self._caps)
@@ -0,0 +1,4 @@
1
+ """NativeRelay platform-neutral event API."""
2
+ from .model import Capability, CollectorStatus, Event, EventType, Process, Resource
3
+ from .stream import EventStream, StreamClosed
4
+ __all__ = ["Capability", "CollectorStatus", "Event", "EventType", "Process", "Resource", "EventStream", "StreamClosed"]
nativerelay/build.py ADDED
@@ -0,0 +1,49 @@
1
+ """Tiny offline PEP 517 wheel builder; runtime remains standard-library only."""
2
+ from pathlib import Path
3
+ import base64
4
+ import csv
5
+ import hashlib
6
+ import io
7
+ from zipfile import ZIP_DEFLATED, ZipFile
8
+
9
+ NAME = "nativerelay"
10
+ VERSION = "0.1.0"
11
+
12
+ def get_requires_for_build_wheel(config_settings=None):
13
+ return []
14
+
15
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
16
+ output = Path(wheel_directory)
17
+ output.mkdir(parents=True, exist_ok=True)
18
+ filename = f"{NAME}-{VERSION}-py3-none-any.whl"
19
+ dist = f"{NAME}-{VERSION}.dist-info"
20
+ files = []
21
+ for package in (Path("nativerelay"), Path("collectors")):
22
+ for path in package.rglob("*.py"):
23
+ if "__pycache__" not in path.parts:
24
+ files.append(path)
25
+ record = []
26
+ with ZipFile(output / filename, "w", ZIP_DEFLATED) as wheel:
27
+ for path in files:
28
+ content = path.read_bytes()
29
+ wheel.writestr(path.as_posix(), content)
30
+ record.append(_record_row(path.as_posix(), content))
31
+ metadata = f"{dist}/METADATA"
32
+ content = b"Metadata-Version: 2.1\nName: nativerelay\nVersion: 0.1.0\nRequires-Python: >=3.11\nSummary: Local-first normalized native OS telemetry\n\n"
33
+ wheel.writestr(metadata, content); record.append(_record_row(metadata, content))
34
+ wheel_file = f"{dist}/WHEEL"
35
+ content = b"Wheel-Version: 1.0\nGenerator: nativerelay.build\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
36
+ wheel.writestr(wheel_file, content); record.append(_record_row(wheel_file, content))
37
+ entrypoints = f"{dist}/entry_points.txt"
38
+ content = b"[console_scripts]\nnativerelay = nativerelay.cli:main\n"
39
+ wheel.writestr(entrypoints, content); record.append(_record_row(entrypoints, content))
40
+ record_path = f"{dist}/RECORD"
41
+ record.append((record_path, "", ""))
42
+ stream = io.StringIO(newline="")
43
+ csv.writer(stream, lineterminator="\n").writerows(record)
44
+ wheel.writestr(record_path, stream.getvalue())
45
+ return filename
46
+
47
+ def _record_row(path, content):
48
+ digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii")
49
+ return (path, f"sha256={digest}", str(len(content)))
nativerelay/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ """NativeRelay local development CLI."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+ from dataclasses import asdict
6
+ from collectors.linux import LinuxCollector
7
+ from .stream import EventStream
8
+
9
+ def main(argv=None):
10
+ parser = argparse.ArgumentParser(prog="nativerelay", description="Observe local Linux kernel events")
11
+ commands = parser.add_subparsers(dest="command", required=True)
12
+ observe = commands.add_parser("observe", help="stream process events and scoped file open/write events")
13
+ observe.add_argument("--scope", required=True, help="existing directory to include in file events")
14
+ observe.add_argument("--json", action="store_true", help="emit one JSON event per line")
15
+ observe.add_argument("--include-command", action="store_true", help="include argv (may contain sensitive data)")
16
+ commands.add_parser("capabilities", help="show collector capabilities for a scope")
17
+ capabilities = commands.choices["capabilities"]
18
+ capabilities.add_argument("--scope", required=True)
19
+ args = parser.parse_args(argv)
20
+ try: collector = LinuxCollector(args.scope, include_command=getattr(args, "include_command", False))
21
+ except (OSError, ValueError) as exc:
22
+ print(f"nativerelay: {exc}", file=sys.stderr); return 2
23
+ if args.command == "capabilities":
24
+ collector.start(EventStream(1))
25
+ try:
26
+ print(json.dumps({"platform": "linux", "collector": collector.name,
27
+ "scope": str(collector.scope), "health": collector.status,
28
+ "capabilities": [
29
+ {**asdict(c), "event_type": c.event_type.value, "status": c.status.value}
30
+ for c in collector.capabilities]}, indent=2))
31
+ finally: collector.stop()
32
+ return 0
33
+ stream = EventStream()
34
+ try:
35
+ collector.start(stream)
36
+ print("NativeRelay Linux observe (Ctrl-C to stop)", file=sys.stderr)
37
+ last_dropped = -1
38
+ last_loss_generation = -1
39
+ while True:
40
+ try: event = stream.receive(timeout=1)
41
+ except TimeoutError:
42
+ status = collector.status
43
+ if status["errors"] or status["dropped"] != last_dropped or status["loss_generation"] != last_loss_generation:
44
+ print(json.dumps({"collector_status": status}), file=sys.stderr)
45
+ collector.health["errors"].clear()
46
+ last_dropped = status["dropped"]
47
+ last_loss_generation = status["loss_generation"]
48
+ continue
49
+ print(event.to_json() if args.json else _human(event), flush=True)
50
+ except KeyboardInterrupt:
51
+ return 0
52
+ finally: collector.stop()
53
+
54
+ def _human(event):
55
+ proc = f"pid={event.process.pid}"
56
+ if event.process.parent_pid is not None: proc += f" ppid={event.process.parent_pid}"
57
+ if event.process.executable: proc += f" exe={event.process.executable}"
58
+ resource = f" {event.resource.path}" if event.resource else ""
59
+ metadata = " " + json.dumps(event.metadata) if event.metadata else ""
60
+ return f"{event.timestamp} {event.type.value} {proc}{resource}{metadata}"
61
+
62
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Platform-neutral collector contract."""
2
+ from abc import ABC, abstractmethod
3
+ from .model import Capability
4
+ from .stream import EventStream
5
+
6
+ class Collector(ABC):
7
+ @property
8
+ @abstractmethod
9
+ def capabilities(self) -> tuple[Capability, ...]: ...
10
+ @abstractmethod
11
+ def start(self, stream: EventStream) -> None: ...
12
+ @abstractmethod
13
+ def stop(self) -> None: ...
nativerelay/model.py ADDED
@@ -0,0 +1,113 @@
1
+ """Version 1 normalized event and capability model.
2
+
3
+ Timestamps are timezone-aware ISO-8601 instants. ``sequence`` is optional and, when a
4
+ collector supplies it, orders events as normalized by that collector only.
5
+ """
6
+ from __future__ import annotations
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+ import hashlib
11
+ import json
12
+ import uuid
13
+ from typing import Any
14
+
15
+ class EventType(str, Enum):
16
+ PROCESS_STARTED = "process.started"
17
+ PROCESS_EXITED = "process.exited"
18
+ FILE_OPENED = "file.opened"
19
+ FILE_CREATED = "file.created"
20
+ FILE_MODIFIED = "file.modified"
21
+ FILE_DELETED = "file.deleted"
22
+ FILE_RENAMED = "file.renamed"
23
+
24
+ class CollectorStatus(str, Enum):
25
+ SUPPORTED = "supported"
26
+ AVAILABLE = "available"
27
+ DEGRADED = "degraded"
28
+ PERMISSION_DENIED = "permission_denied"
29
+ UNAVAILABLE = "unavailable"
30
+ UNSUPPORTED = "unsupported"
31
+
32
+ @dataclass(frozen=True)
33
+ class Process:
34
+ pid: int
35
+ parent_pid: int | None = None
36
+ executable: str | None = None
37
+ command: tuple[str, ...] | None = None
38
+ def __post_init__(self):
39
+ if not isinstance(self.pid, int) or self.pid <= 0 or (self.parent_pid is not None and (not isinstance(self.parent_pid, int) or self.parent_pid < 0)):
40
+ raise ValueError("process IDs must be positive (parent may be zero)")
41
+ if self.executable is not None and not isinstance(self.executable, str): raise ValueError("executable must be a string")
42
+ if self.command is not None and not all(isinstance(x, str) for x in self.command):
43
+ raise ValueError("command must contain strings")
44
+
45
+ @dataclass(frozen=True)
46
+ class Resource:
47
+ type: str
48
+ path: str
49
+ def __post_init__(self):
50
+ if not isinstance(self.type, str) or not isinstance(self.path, str) or not self.type or not self.path:
51
+ raise ValueError("resource type and path are required")
52
+
53
+ @dataclass(frozen=True)
54
+ class Event:
55
+ type: EventType
56
+ platform: str
57
+ collector: str
58
+ process: Process
59
+ resource: Resource | None = None
60
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
61
+ metadata: dict[str, Any] = field(default_factory=dict)
62
+ evidence: str = "observed"
63
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
64
+ sequence: int | None = None
65
+ def __post_init__(self):
66
+ if not isinstance(self.type, EventType): object.__setattr__(self, "type", EventType(self.type))
67
+ if not isinstance(self.process, Process): raise ValueError("process must be a Process")
68
+ if self.resource is not None and not isinstance(self.resource, Resource): raise ValueError("resource must be a Resource")
69
+ if not all(isinstance(value, str) and value for value in (self.platform, self.collector, self.id)):
70
+ raise ValueError("platform, collector, and id are required")
71
+ if self.evidence not in ("observed", "derived"): raise ValueError("evidence must be observed or derived")
72
+ try:
73
+ parsed_timestamp = datetime.fromisoformat(self.timestamp.replace("Z", "+00:00"))
74
+ if parsed_timestamp.utcoffset() is None: raise ValueError("timezone required")
75
+ except (ValueError, AttributeError) as exc: raise ValueError("timestamp must be ISO-8601 with timezone") from exc
76
+ if self.sequence is not None and (not isinstance(self.sequence, int) or self.sequence < 0):
77
+ raise ValueError("sequence must be a non-negative integer or None")
78
+ if not isinstance(self.metadata, dict): raise ValueError("metadata must be an object")
79
+ try: json.dumps(self.metadata)
80
+ except (TypeError, ValueError) as exc: raise ValueError("metadata must be JSON serializable") from exc
81
+ @staticmethod
82
+ def stable_id(collector: str, boot_id: str, sequence: int) -> str:
83
+ raw = f"{collector}\0{boot_id}\0{sequence}".encode()
84
+ return hashlib.sha256(raw).hexdigest()
85
+ def to_dict(self):
86
+ return {"id": self.id, "timestamp": self.timestamp, "type": self.type.value,
87
+ "platform": self.platform, "collector": self.collector, "process": asdict(self.process),
88
+ "resource": asdict(self.resource) if self.resource else None,
89
+ "metadata": self.metadata, "evidence": self.evidence, "sequence": self.sequence, "schema_version": 1}
90
+ def to_json(self): return json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=True)
91
+
92
+ @dataclass(frozen=True)
93
+ class Capability:
94
+ event_type: EventType
95
+ status: CollectorStatus
96
+ reason: str
97
+ scope: str
98
+ attribution: str
99
+ def __post_init__(self):
100
+ if not isinstance(self.event_type, EventType): object.__setattr__(self, "event_type", EventType(self.event_type))
101
+ if not isinstance(self.status, CollectorStatus): object.__setattr__(self, "status", CollectorStatus(self.status))
102
+ if not self.reason or not self.scope or not self.attribution: raise ValueError("capability reason, scope, and attribution are required")
103
+
104
+ def decode_event(data: str) -> Event:
105
+ try:
106
+ raw = json.loads(data)
107
+ return Event(type=EventType(raw["type"]), platform=raw["platform"], collector=raw["collector"],
108
+ process=Process(**{**raw["process"], "command": tuple(raw["process"]["command"]) if raw["process"].get("command") is not None else None}),
109
+ resource=Resource(**raw["resource"]) if raw.get("resource") else None,
110
+ timestamp=raw["timestamp"], metadata=raw.get("metadata", {}), evidence=raw["evidence"], id=raw["id"],
111
+ sequence=raw.get("sequence"))
112
+ except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
113
+ raise ValueError(f"invalid NativeRelay event: {exc}") from exc
nativerelay/stream.py ADDED
@@ -0,0 +1,111 @@
1
+ """Bounded synchronous event stream shared by collectors."""
2
+ from queue import Queue, Full, Empty
3
+ from threading import Lock
4
+ from .model import Event
5
+
6
+
7
+ class StreamClosed(RuntimeError):
8
+ """Raised after a producer has closed the stream and queued events drain."""
9
+
10
+
11
+ class EventStream:
12
+ def __init__(self, capacity=1024):
13
+ if capacity <= 0: raise ValueError("capacity must be positive")
14
+ self._queue = Queue(maxsize=capacity)
15
+ self.dropped = 0
16
+ self._closed = False
17
+ self._lock = Lock()
18
+ self._close_error = None
19
+ self._loss_generation = 0
20
+ self._losses = {}
21
+
22
+ @property
23
+ def closed(self):
24
+ with self._lock: return self._closed
25
+
26
+ @property
27
+ def error(self):
28
+ with self._lock: return self._close_error
29
+
30
+ @property
31
+ def loss_generation(self):
32
+ """Monotonic counter; changes whenever a producer reports detected loss."""
33
+ with self._lock: return self._loss_generation
34
+
35
+ @property
36
+ def losses(self):
37
+ """Copy of loss totals grouped by source and reason."""
38
+ with self._lock:
39
+ return {source: {reason: dict(totals) for reason, totals in reasons.items()}
40
+ for source, reasons in self._losses.items()}
41
+
42
+ def _record_loss_locked(self, source, reason, count, sequence):
43
+ totals = self._losses.setdefault(source, {}).setdefault(reason, {
44
+ "occurrences": 0, "known_dropped": 0, "unknown_count": False,
45
+ "last_sequence": None,
46
+ })
47
+ totals["occurrences"] += 1
48
+ if count is None:
49
+ totals["unknown_count"] = True
50
+ else:
51
+ totals["known_dropped"] += count
52
+ if sequence is not None: totals["last_sequence"] = sequence
53
+ self._loss_generation += 1
54
+
55
+ def report_loss(self, source, reason, *, count=None, sequence=None):
56
+ """Report an observed loss; count=None means the lost-event count is unknown."""
57
+ if not isinstance(source, str) or not source: raise ValueError("loss source is required")
58
+ if not isinstance(reason, str) or not reason: raise ValueError("loss reason is required")
59
+ if count is not None and (not isinstance(count, int) or count < 0):
60
+ raise ValueError("loss count must be a non-negative integer or None")
61
+ if sequence is not None and (not isinstance(sequence, int) or sequence < 0):
62
+ raise ValueError("loss sequence must be a non-negative integer or None")
63
+ with self._lock:
64
+ self._record_loss_locked(source, reason, count, sequence)
65
+
66
+ def publish(self, event: Event):
67
+ if not isinstance(event, Event): raise TypeError("event must be a NativeRelay Event")
68
+ with self._lock:
69
+ if self._closed:
70
+ self.dropped += 1
71
+ self._record_loss_locked(event.collector, "event_stream_closed", 1, event.sequence)
72
+ return False
73
+ try: self._queue.put_nowait(event); return True
74
+ except Full:
75
+ self.dropped += 1
76
+ self._record_loss_locked(event.collector, "event_stream_full", 1, event.sequence)
77
+ return False
78
+
79
+ def close(self, error: BaseException | None = None):
80
+ """Stop accepting events; buffered events remain available to consumers."""
81
+ with self._lock:
82
+ if self._closed: return
83
+ self._closed = True
84
+ self._close_error = error
85
+ # Wake a blocked receiver if the queue is empty. A full queue already
86
+ # guarantees that no receiver can be blocked waiting for an event.
87
+ try: self._queue.put_nowait(_CLOSED)
88
+ except Full: pass
89
+
90
+ def receive(self, timeout=None):
91
+ with self._lock:
92
+ if self._closed and self._queue.empty():
93
+ raise StreamClosed("NativeRelay event stream is closed")
94
+ try: item = self._queue.get(timeout=timeout)
95
+ except Empty as exc:
96
+ if self.closed: raise StreamClosed("NativeRelay event stream is closed") from exc
97
+ raise TimeoutError("no NativeRelay event available") from exc
98
+ if item is _CLOSED:
99
+ # Keep closure visible to every later receive call.
100
+ try: self._queue.put_nowait(_CLOSED)
101
+ except Full: pass
102
+ raise StreamClosed("NativeRelay event stream is closed")
103
+ return item
104
+
105
+ def __iter__(self):
106
+ while True:
107
+ try: yield self.receive()
108
+ except StreamClosed: return
109
+
110
+
111
+ _CLOSED = object()
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.1
2
+ Name: nativerelay
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Summary: Local-first normalized native OS telemetry
6
+
@@ -0,0 +1,13 @@
1
+ nativerelay/collector.py,sha256=jPDHfzCnDK7hZlrRUijUBuwY1pKrgqEpH-j3arHQAWs,382
2
+ nativerelay/cli.py,sha256=nJ4DErru3tYf6nAbvzGUAkb6Nfj2aDBCcRBkTYlPp9E,3211
3
+ nativerelay/model.py,sha256=B2877ueYtHhffLldFd_7J-BdoSyY-uP5ognPVdG442Q,5773
4
+ nativerelay/stream.py,sha256=uHixaWvliAufV0kwbSz7r1rh4eUOnzwYlXJhquSsSR8,4514
5
+ nativerelay/build.py,sha256=i3HGOBGNBrUEXamSFgMwvPW-P-S6y1xdZygkoD-yjXQ,2243
6
+ nativerelay/__init__.py,sha256=ybvXpAmxt80upZF6drELWZRem-8iXa9YIZC4C9_mMw0,296
7
+ collectors/__init__.py,sha256=QXgA7aS-PUeEorvGCuQe96rSHIE730bEpPPzN9r0XJc,34
8
+ collectors/linux/collector.py,sha256=43_nm9KPeTa-I8gd8rwE0t6gosDFeltIXHnns2UcO8c,16789
9
+ collectors/linux/__init__.py,sha256=IuTIryxNWK1r16SJlZNvwIzcMhQoqsishl3-cqZVt-Y,107
10
+ nativerelay-0.1.0.dist-info/METADATA,sha256=PCBP-mQfNdL80H0e6fNzXeGduc9mcIv9UXtV2H60JrQ,132
11
+ nativerelay-0.1.0.dist-info/WHEEL,sha256=z2Hx0MKflNlZIAkP5GUTPqUXXNrsUKmC72d9FVdGwY0,88
12
+ nativerelay-0.1.0.dist-info/entry_points.txt,sha256=FDtVrUt_aeMn4jVO4p67jYka9krFId8KR21ooTfEn9s,53
13
+ nativerelay-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: nativerelay.build
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nativerelay = nativerelay.cli:main