rnmon 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.
rnmon/Databases.py ADDED
@@ -0,0 +1,37 @@
1
+ import time
2
+ import requests
3
+ from random import randrange
4
+ from collections import deque
5
+
6
+ import RNS
7
+ from . import MP
8
+
9
+ class InfluxWriter:
10
+ def __init__(self, address: str, batch_size: int = 1000, flush_interval: int = 5, **kwargs):
11
+ self.maxlen = batch_size
12
+ self.flush_interval = flush_interval
13
+ self.flush_jitter = kwargs.setdefault('flush_jitter', 0)
14
+ self.address = address
15
+ self.http_headers = kwargs.setdefault('http_headers', None)
16
+ self.run()
17
+
18
+ def run(self):
19
+ last_push = time.time()
20
+ jitter = 0
21
+ RNS.log("[RNMon] Started InfluxWriter", RNS.LOG_INFO)
22
+ while not MP.terminate.is_set():
23
+ if len(MP.metric_queue) >= self.maxlen or (time.time() - last_push) > (self.flush_interval + jitter):
24
+ data = []
25
+ try:
26
+ for _ in range(self.maxlen):
27
+ data.append(MP.metric_queue.pop())
28
+ except IndexError:
29
+ pass
30
+ if data:
31
+ RNS.log(f"[RNMon] Pushing metrics - Count: {len(data)} Time: {int(time.time() - last_push)}s", RNS.LOG_DEBUG)
32
+ r = requests.post(self.address, headers=self.http_headers, data="\n".join(data))
33
+ last_push = time.time()
34
+ jitter = randrange(-self.flush_jitter, self.flush_jitter+1)
35
+ time.sleep(0.2)
36
+
37
+ RNS.log(f"[RNMon] Stopped InfluxWriter", RNS.LOG_INFO)
rnmon/Logging.py ADDED
@@ -0,0 +1,6 @@
1
+ import logging
2
+
3
+
4
+ log = logging.getLogger(__name__)
5
+
6
+ log.setLevel(logging.INFO)
rnmon/MP.py ADDED
@@ -0,0 +1,5 @@
1
+ import multiprocessing
2
+ from collections import deque
3
+
4
+ metric_queue = deque(maxlen=10000)
5
+ terminate = multiprocessing.Event()
rnmon/RNSUtils.py ADDED
@@ -0,0 +1,77 @@
1
+ import os
2
+ import time
3
+
4
+ import RNS
5
+ from . import MP
6
+
7
+ path_request_timeout = 30
8
+ link_est_timeout = 30
9
+
10
+ def establish_link(dest_identity: str, rpc_identity: os.PathLike, dest_type) -> RNS.Link:
11
+ mgmt_identity = read_rpc_identity(rpc_identity)
12
+ dest_hash = RNS.Destination.hash_from_name_and_identity( \
13
+ '.'.join(dest_type.ASPECTS), bytes.fromhex(dest_identity))
14
+ ensure_path(dest_hash)
15
+ RNS.log(f"[RNMon] Setting up Destination: {RNS.prettyhexrep(dest_hash)}", RNS.LOG_INFO)
16
+ remote_dest = RNS.Destination(
17
+ RNS.Identity.recall(dest_hash),
18
+ RNS.Destination.OUT,
19
+ RNS.Destination.SINGLE,
20
+ *dest_type.ASPECTS
21
+ )
22
+ RNS.log(f"[RNMon] Establishing a new link with {RNS.prettyhexrep(dest_hash)}", RNS.LOG_DEBUG)
23
+ link = RNS.Link(remote_dest)
24
+ link.set_link_established_callback(on_link_established(link, mgmt_identity))
25
+ link.set_link_closed_callback(on_link_closed)
26
+
27
+ start = time.time()
28
+ while link.status != RNS.Link.ACTIVE:
29
+ # Sometimes with multiple clients in a shared instance, it is possible that
30
+ # the establishment timeout is reset to PATHFINDER_M due to some edge cases
31
+ if time.time() - start > link_est_timeout:
32
+ RNS.log("[RNMon] Timed out waiting for link establishment", RNS.LOG_ERROR)
33
+ raise RuntimeError
34
+ RNS.log(f"[RNMon] Link to {dest_identity} Status: {link.status}", RNS.LOG_DEBUG)
35
+ time.sleep(1)
36
+ link.identify(mgmt_identity)
37
+ return link
38
+
39
+ def on_link_established(link: RNS.Link, rpc_identity: RNS.Identity) -> None:
40
+ RNS.log("[RNMon] Link established with server", RNS.LOG_DEBUG)
41
+ RNS.log(f"[RNMon] KEEPALIVE interval: {link.KEEPALIVE}s, Stale time: {link.stale_time}s", RNS.LOG_DEBUG)
42
+
43
+ def on_link_closed(link: RNS.Link) -> None:
44
+ reason = link.teardown_reason
45
+ if reason == RNS.Link.TIMEOUT:
46
+ RNS.log("[RNMon] The link timed out", RNS.LOG_WARNING)
47
+ elif reason == RNS.Link.DESTINATION_CLOSED:
48
+ RNS.log("[RNMon] The link was closed by the server", RNS.LOG_WARNING)
49
+ elif reason == RNS.Link.INITIATOR_CLOSED:
50
+ RNS.log("[RNMon] Closing link", RNS.LOG_ERROR)
51
+
52
+ def ensure_path(dest_hash: bytes) -> None:
53
+ if not RNS.Transport.has_path(dest_hash):
54
+ RNS.log(f"[RNMon] No path to destination known. Requesting path and waiting for announce to arrive...")
55
+ RNS.Transport.request_path(dest_hash)
56
+ start = time.time()
57
+ while not RNS.Transport.has_path(dest_hash):
58
+ time.sleep(0.2)
59
+ # Abort if the path request is taking too long, for example if the next transport_node
60
+ # is not in a mode that does path discovery for us, it might take a while to discover the path
61
+ if time.time() - start > path_request_timeout:
62
+ RNS.log("[RNMon] Timed out waiting for path announcement", RNS.LOG_ERROR)
63
+ raise RuntimeError
64
+
65
+
66
+ def read_rpc_identity(rpc_identity: os.PathLike) -> RNS.Identity:
67
+ mgmt_identity = RNS.Identity.from_file(os.path.expanduser(rpc_identity))
68
+ if not mgmt_identity:
69
+ raise FileNotFoundError(f"Failed to load identity from {rpc_identity}, check path and permissions.")
70
+ RNS.log(f"[RNMon] Loaded identity from '{rpc_identity}'", RNS.LOG_INFO)
71
+ return mgmt_identity
72
+
73
+
74
+ def validate_hexhash(hexhash: str) -> None:
75
+ dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH//8)*2
76
+ if len(hexhash) != dest_len:
77
+ raise TypeError(f"Destination length is invalid, must be {dest_len} hexadecimal characters ({dest_len//2} bytes)")
rnmon/Remotes.py ADDED
@@ -0,0 +1,138 @@
1
+ import os
2
+ import time
3
+ from random import randrange
4
+
5
+ import RNS
6
+ from . import MP, RNSUtils
7
+
8
+ class RNSTransportNode:
9
+ ASPECTS = ("rnstransport", "remote", "management")
10
+
11
+ NODE_METRICS = {
12
+ "rxb": "rns_transport_node_rx_bytes_total",
13
+ "txb": "rns_transport_node_tx_bytes_total",
14
+ "transport_uptime": "rns_transport_node_uptime_s",
15
+ "link_count": "rns_transport_node_link_count", # returned as second array element, unlabelled...
16
+ }
17
+ NODE_LABELS = {
18
+ "transport_id": "transport_id",
19
+ }
20
+ IFACE_METRICS = {
21
+ "clients": "rns_iface_client_count",
22
+ "bitrate": "rns_iface_bitrate",
23
+ "status": "rns_iface_up",
24
+ "mode": "rns_iface_mode",
25
+ "rxb": "rns_iface_rx_bytes_total",
26
+ "txb": "rns_iface_tx_bytes_total",
27
+ "held_announces": "rns_iface_announces_held_count",
28
+ "announce_queue": "rns_iface_announces_queue_count",
29
+ "incoming_announce_frequency": "rns_iface_announces_rx_rate",
30
+ "outgoing_announce_frequency": "rns_iface_announces_tx_rate",
31
+ }
32
+ IFACE_LABELS = {
33
+ "type": "type",
34
+ }
35
+
36
+ LPROTO_LABEL_TTABLE = str.maketrans({
37
+ " ": "\\ ",
38
+ ",": "\\,"
39
+ })
40
+
41
+ def __init__(self, interval: int, dest_identity: str, rpc_identity: os.PathLike, name: str, **kwargs) -> None:
42
+ self.link = RNSUtils.establish_link(dest_identity, rpc_identity, self)
43
+ self.interval = interval
44
+ self.collection_jitter = kwargs.setdefault('collection_jitter', 0)
45
+
46
+ # Used for metric labeling
47
+ self.node_name = name
48
+ self.dest_identity = dest_identity
49
+
50
+ self.request_timeout = self.link.rtt * self.link.traffic_timeout_factor + RNS.Resource.RESPONSE_MAX_GRACE_TIME*1.125
51
+ if self.request_timeout >= interval:
52
+ self.request_timeout = interval
53
+ RNS.log(f"[RNMon] Set Request timeout for '{self.node_name}': {self.request_timeout}s", RNS.LOG_EXTREME)
54
+
55
+
56
+
57
+ self.run()
58
+
59
+ def run(self) -> bool:
60
+ RNS.log(f"[RNMon] Starting RNSTransportNode scraper for '{self.node_name}'", RNS.LOG_INFO)
61
+ last_request_time = time.time()
62
+ jitter = 0
63
+ while not MP.terminate.is_set():
64
+ if self.link.status != RNS.Link.ACTIVE:
65
+ RNS.log(f"[RNMon] Link no longer active, stopping scraper for '{self.node_name}", RNS.LOG_DEBUG)
66
+ break
67
+ try:
68
+ # No point in spamming requests if the last one hasnt timed out yet, save local and network resources
69
+ if not self.link.pending_requests and ((time.time() - last_request_time) > (self.interval + jitter)):
70
+ req = self.link.request(
71
+ "/status",
72
+ data = [True],
73
+ response_callback = self._on_response,
74
+ failed_callback = self._on_request_fail,
75
+ timeout = self.request_timeout
76
+ )
77
+ last_request_time = time.time()
78
+ jitter = randrange(-self.collection_jitter, self.collection_jitter+1)
79
+ RNS.log(f"[RNMon] Sending request {RNS.prettyhexrep(req.request_id)} to '{self.node_name}'", RNS.LOG_EXTREME)
80
+ except Exception as e:
81
+ RNS.log(f"[RNMon] Error while sending request to '{self.node_name}': {str(e)}")
82
+
83
+ time.sleep(0.2)
84
+
85
+ RNS.log(f"[RNMon] Stopping RNSTransportNode scraper for '{self.node_name}'", RNS.LOG_INFO)
86
+ self.link.teardown()
87
+ return False
88
+
89
+ def _on_response(self, response) -> None:
90
+ self._parse_metrics(response.response)
91
+
92
+ def _on_request_fail(self, response) -> None:
93
+ RNS.log(f"[RNMon] The request {RNS.prettyhexrep(response.request_id)} to '{self.node_name}' failed.", RNS.LOG_DEBUG)
94
+
95
+ def _parse_metrics(self, data: list) -> None:
96
+ iface_labels = {}
97
+ iface_metrics = {}
98
+ node_labels = {}
99
+ node_metrics = {}
100
+ t = time.time_ns()
101
+
102
+ # link_count isnt labeled >.>
103
+ node_metrics[RNSTransportNode.NODE_METRICS['link_count']] = data[1]
104
+
105
+ for mk, mv in data[0].items():
106
+ if mk == 'interfaces':
107
+ for iface in mv:
108
+ if iface['short_name'].startswith('Client on'):
109
+ continue
110
+
111
+ for k, v in iface.items():
112
+ if k in RNSTransportNode.IFACE_METRICS:
113
+ iface_metrics[RNSTransportNode.IFACE_METRICS[k]] = v
114
+ if k in RNSTransportNode.IFACE_LABELS:
115
+ iface_labels[RNSTransportNode.IFACE_LABELS[k]] = v
116
+
117
+ iface_labels['name'] = iface['short_name']
118
+ iface_labels['identity'] = self.dest_identity
119
+ iface_labels['node_name'] = self.node_name
120
+
121
+ # convert to influx line format
122
+ labels = ",".join(f"{k}={v.translate(RNSTransportNode.LPROTO_LABEL_TTABLE)}" for k, v in iface_labels.items())
123
+ for k, v in iface_metrics.items():
124
+ metric = f"{k},{labels} value={v} {t}"
125
+ MP.metric_queue.append(metric)
126
+
127
+ else:
128
+ if mk in RNSTransportNode.NODE_METRICS:
129
+ node_metrics[RNSTransportNode.NODE_METRICS[mk]] = mv
130
+
131
+ node_labels['identity'] = self.dest_identity
132
+ node_labels['node_name'] = self.node_name
133
+
134
+ #convert to influx line format
135
+ labels = ",".join(f"{k}={v.translate(RNSTransportNode.LPROTO_LABEL_TTABLE)}" for k, v in node_labels.items())
136
+ for k, v in node_metrics.items():
137
+ metric = f"{k},{labels} value={v} {t}"
138
+ MP.metric_queue.append(metric)
rnmon/__init__.py ADDED
File without changes
rnmon/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from . import rnmon
2
+
3
+ rnmon.main()
rnmon/rnmon.py ADDED
@@ -0,0 +1,84 @@
1
+ import sys
2
+ import time
3
+ import signal
4
+ import argparse
5
+ import concurrent.futures
6
+
7
+ from yaml import safe_load
8
+
9
+ import RNS
10
+ # RNS.Link.KEEPALIVE=10
11
+ # RNS.Link.STALE_TIME=2*RNS.Link.KEEPALIVE
12
+ # TODO: PR to have these be configurable as arguments to RNS.Link.__init__
13
+
14
+ from .Databases import InfluxWriter
15
+ from .Remotes import RNSTransportNode
16
+ from . import MP, RNSUtils
17
+
18
+
19
+
20
+ def main():
21
+ parser = argparse.ArgumentParser(description="Simple request/response example")
22
+ parser.add_argument('-v', '--verbose', action='count', default=0)
23
+ parser.add_argument("--rns-config", type=str, default=None, \
24
+ help="path to Reticulum config directory")
25
+ parser.add_argument("config", nargs='?', type=argparse.FileType('r'), default="scraping.yaml", \
26
+ help="path to target list file")
27
+ args = parser.parse_args()
28
+
29
+ config = safe_load(args.config)
30
+
31
+ JOB_TYPES = {
32
+ "transport_node": RNSTransportNode,
33
+ "influx": InfluxWriter
34
+ }
35
+
36
+ # Init Reticulum instance
37
+ RNS.Reticulum(configdir=args.rns_config, verbosity=args.verbose)
38
+
39
+ def sig_handler(signum, frame):
40
+ MP.terminate.set()
41
+ signal.signal(signal.SIGINT, sig_handler)
42
+ signal.signal(signal.SIGTERM, sig_handler)
43
+
44
+ jobs = []
45
+ # Setup InfluxWriter push job
46
+ jobs.append({"type": "influx"} | config['influxdb'])
47
+ # Setup Scraping Jobs
48
+ for target in config['targets']:
49
+ RNSUtils.validate_hexhash(target['dest_identity'])
50
+ jobs.append({"verbosity": args.verbose} | target)
51
+
52
+ futures = {}
53
+ with concurrent.futures.ThreadPoolExecutor(len(jobs)) as executor:
54
+
55
+ for job in jobs:
56
+ futures[executor.submit(JOB_TYPES[job['type']], **job )] = job
57
+
58
+ while len(futures) > 0:
59
+ new_jobs = {}
60
+ done, not_done = concurrent.futures.wait(futures, return_when=concurrent.futures.FIRST_COMPLETED)
61
+ if MP.terminate.is_set():
62
+ break
63
+ for future in done:
64
+ job = futures[future]
65
+ if future.exception():
66
+ RNS.log(f"[RNMon] Job exited with exception: \"{future.exception()}\"", RNS.LOG_WARNING)
67
+ RNS.log(f"[RNMon] Job Exception Restart: {JOB_TYPES[job['type']]}", RNS.LOG_WARNING)
68
+ new_jobs[executor.submit(JOB_TYPES[job['type']], **job)] = job
69
+ for future in not_done:
70
+ job = futures[future]
71
+ new_jobs[future] = job
72
+ futures = new_jobs
73
+ time.sleep(1)
74
+
75
+
76
+ # RNS.exit() calls os._exit(), It does not clean things up properly
77
+ # and triggers semaphore_tracker:UserWarning since multiprocessing.resource_tracker
78
+ # is running, as intended, until it is terminated on main thread exit
79
+ # RNS.exit()
80
+ RNS.Reticulum.exit_handler()
81
+ sys.exit(0)
82
+
83
+ if __name__ == '__main__':
84
+ main()
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: rnmon
3
+ Version: 0.1.0
4
+ Summary: RNS Monitoring Agent
5
+ Author: lbatalha
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: pyyaml>=6.0.2
8
+ Requires-Dist: requests>=2.32.3
9
+ Requires-Dist: rns>=0.9.6
10
+ Description-Content-Type: text/markdown
11
+
12
+ # RNMon - Reticulum Application Monitoring Daemon
13
+
14
+ RNMon is a simple monitoring daemon designed to monitor the status of multiple RNS applications and push the metrics over http using the influx line protocol.
15
+
16
+ ## Installing
17
+
18
+ ## Configuration
19
+
20
+ Configure the daemon via `scraping.yaml`, the example config has comments explaining the options.
21
+
22
+ The configuration for reticulum is auto-discovered, but you can specify the location of the configuration directory using the `--rns-config` argument.
23
+
24
+ ## Operational principles
25
+
26
+ The metric pusher and all targets are executed in their own thread. The main thread starts a new RNS instance, and closes it on exit.
27
+
28
+ A link is established for each scrape target to reduce network overhead. If a link is broken for any reason, the thread is terminated and restarted - this avoids having to deal with the built-in RNS link retry mechanisms, their associated timeouts and any edge cases caused by using shared RNS intances. This might be changed in the future if RNS fixes the issues particular to this use case.
@@ -0,0 +1,12 @@
1
+ rnmon/Databases.py,sha256=yUnGYjQDGlYdCYTaIKz0JQob7rN3FcqeoxpdRlYulUc,1463
2
+ rnmon/Logging.py,sha256=eEtf_vyo--rgqXS8cePtIIPEU1jUWp6JbbFbpiNwqWM,79
3
+ rnmon/MP.py,sha256=nub7xJQUYkG3aO6T42mZSHGJO5mII42R0eMxp9srU_c,125
4
+ rnmon/RNSUtils.py,sha256=c9JEMP2yi9FmyDGf96I7O7kh-7Uf4biMSODdKX74i58,3552
5
+ rnmon/Remotes.py,sha256=xzAfvTLeoiADkiY90VE0NCJEEhU-5J9cCto60UZc4Ps,5798
6
+ rnmon/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ rnmon/__main__.py,sha256=xTyj40x3M7fIPfZbQ7xS4VvA8pRn6hw4dcMplsibdWY,34
8
+ rnmon/rnmon.py,sha256=K7F2T06fStbm3UIg7CPsVdqurC5tnnxQlWzPisjDQ00,2866
9
+ rnmon-0.1.0.dist-info/METADATA,sha256=MdsW3fIFCPPlubLflzGIMUj9FclFt66AaS77CbXkl2k,1297
10
+ rnmon-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ rnmon-0.1.0.dist-info/entry_points.txt,sha256=pKY8NEHRhxa8E9Q3H9TlkphkDFoaPwzrHkT4v8mPGsU,43
12
+ rnmon-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rnmon = rnmon.rnmon:main