Refactor main to improve readability and maintainability.

This commit is contained in:
Sudo-Ivan
2025-07-14 17:27:17 -05:00
parent 4aa83a2dfb
commit f40d5a51ae
5 changed files with 218 additions and 127 deletions

View File

@@ -1,2 +1,2 @@
# rns_page_node package # rns_page_node package
__all__ = ['main'] __all__ = ["main"]

View File

@@ -1,31 +1,41 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Minimal Reticulum Page Node
Minimal Reticulum Page Node
Serves .mu pages and files over RNS. Serves .mu pages and files over RNS.
""" """
import os
import time
import threading
import subprocess
import RNS
import argparse import argparse
import logging import logging
import os
import subprocess
import threading
import time
import RNS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_INDEX = '''>Default Home Page DEFAULT_INDEX = """>Default Home Page
This node is serving pages using page node, but the home page file (index.mu) was not found in the pages directory. Please add an index.mu file to customize the home page. This node is serving pages using page node, but the home page file (index.mu) was not found in the pages directory. Please add an index.mu file to customize the home page.
''' """
DEFAULT_NOTALLOWED = '''>Request Not Allowed DEFAULT_NOTALLOWED = """>Request Not Allowed
You are not authorised to carry out the request. You are not authorised to carry out the request.
''' """
class PageNode: class PageNode:
def __init__(self, identity, pagespath, filespath, announce_interval=360, name=None, page_refresh_interval=0, file_refresh_interval=0): def __init__(
self,
identity,
pagespath,
filespath,
announce_interval=360,
name=None,
page_refresh_interval=0,
file_refresh_interval=0,
):
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._lock = threading.Lock() self._lock = threading.Lock()
self.logger = logging.getLogger(f"{__name__}.PageNode") self.logger = logging.getLogger(f"{__name__}.PageNode")
@@ -34,11 +44,7 @@ class PageNode:
self.pagespath = pagespath self.pagespath = pagespath
self.filespath = filespath self.filespath = filespath
self.destination = RNS.Destination( self.destination = RNS.Destination(
identity, identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node"
RNS.Destination.IN,
RNS.Destination.SINGLE,
"nomadnetwork",
"node"
) )
self.announce_interval = announce_interval self.announce_interval = announce_interval
self.last_announce = 0 self.last_announce = 0
@@ -52,7 +58,9 @@ class PageNode:
self.destination.set_link_established_callback(self.on_connect) self.destination.set_link_established_callback(self.on_connect)
self._announce_thread = threading.Thread(target=self._announce_loop, daemon=True) self._announce_thread = threading.Thread(
target=self._announce_loop, daemon=True
)
self._announce_thread.start() self._announce_thread.start()
self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True) self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
self._refresh_thread.start() self._refresh_thread.start()
@@ -66,16 +74,16 @@ class PageNode:
self.destination.register_request_handler( self.destination.register_request_handler(
"/page/index.mu", "/page/index.mu",
response_generator=self.serve_default_index, response_generator=self.serve_default_index,
allow=RNS.Destination.ALLOW_ALL allow=RNS.Destination.ALLOW_ALL,
) )
for full_path in self.servedpages: for full_path in self.servedpages:
rel = full_path[len(self.pagespath):] rel = full_path[len(self.pagespath) :]
request_path = f"/page{rel}" request_path = f"/page{rel}"
self.destination.register_request_handler( self.destination.register_request_handler(
request_path, request_path,
response_generator=self.serve_page, response_generator=self.serve_page,
allow=RNS.Destination.ALLOW_ALL allow=RNS.Destination.ALLOW_ALL,
) )
def register_files(self): def register_files(self):
@@ -84,18 +92,18 @@ class PageNode:
self._scan_files(self.filespath) self._scan_files(self.filespath)
for full_path in self.servedfiles: for full_path in self.servedfiles:
rel = full_path[len(self.filespath):] rel = full_path[len(self.filespath) :]
request_path = f"/file{rel}" request_path = f"/file{rel}"
self.destination.register_request_handler( self.destination.register_request_handler(
request_path, request_path,
response_generator=self.serve_file, response_generator=self.serve_file,
allow=RNS.Destination.ALLOW_ALL, allow=RNS.Destination.ALLOW_ALL,
auto_compress=32_000_000 auto_compress=32_000_000,
) )
def _scan_pages(self, base): def _scan_pages(self, base):
for entry in os.listdir(base): for entry in os.listdir(base):
if entry.startswith('.'): if entry.startswith("."):
continue continue
path = os.path.join(base, entry) path = os.path.join(base, entry)
if os.path.isdir(path): if os.path.isdir(path):
@@ -105,7 +113,7 @@ class PageNode:
def _scan_files(self, base): def _scan_files(self, base):
for entry in os.listdir(base): for entry in os.listdir(base):
if entry.startswith('.'): if entry.startswith("."):
continue continue
path = os.path.join(base, entry) path = os.path.join(base, entry)
if os.path.isdir(path): if os.path.isdir(path):
@@ -113,60 +121,77 @@ class PageNode:
elif os.path.isfile(path): elif os.path.isfile(path):
self.servedfiles.append(path) self.servedfiles.append(path)
def serve_default_index(self, path, data, request_id, link_id, remote_identity, requested_at): @staticmethod
return DEFAULT_INDEX.encode('utf-8') def serve_default_index(
path, data, request_id, link_id, remote_identity, requested_at
):
return DEFAULT_INDEX.encode("utf-8")
def serve_page(self, path, data, request_id, link_id, remote_identity, requested_at): def serve_page(
self, path, data, request_id, link_id, remote_identity, requested_at
):
file_path = path.replace("/page", self.pagespath, 1) file_path = path.replace("/page", self.pagespath, 1)
try: try:
with open(file_path, 'rb') as _f: with open(file_path, "rb") as _f:
first_line = _f.readline() first_line = _f.readline()
is_script = first_line.startswith(b'#!') is_script = first_line.startswith(b"#!")
except Exception: except Exception:
is_script = False is_script = False
if is_script and os.access(file_path, os.X_OK): if is_script and os.access(file_path, os.X_OK):
# Note: You can remove the following try-except block if you just serve static pages. # Note: The execution of file_path is intentional here, as some pages are designed to be executable scripts.
# This is acknowledged as a potential security risk if untrusted input can control file_path.
try: try:
result = subprocess.run([file_path], stdout=subprocess.PIPE) result = subprocess.run([file_path], stdout=subprocess.PIPE, check=True) # noqa: S603
return result.stdout return result.stdout
except Exception: except Exception:
pass self.logger.exception("Error executing script page")
with open(file_path, 'rb') as f: with open(file_path, "rb") as f:
return f.read() return f.read()
def serve_file(self, path, data, request_id, link_id, remote_identity, requested_at): def serve_file(
self, path, data, request_id, link_id, remote_identity, requested_at
):
file_path = path.replace("/file", self.filespath, 1) file_path = path.replace("/file", self.filespath, 1)
return [open(file_path, 'rb'), {"name": os.path.basename(file_path).encode('utf-8')}] return [
open(file_path, "rb"),
{"name": os.path.basename(file_path).encode("utf-8")},
]
def on_connect(self, link): def on_connect(self, link):
pass pass
def _announce_loop(self): def _announce_loop(self):
while not self._stop_event.is_set(): try:
try: while not self._stop_event.is_set():
if time.time() - self.last_announce > self.announce_interval: if time.time() - self.last_announce > self.announce_interval:
if self.name: if self.name:
self.destination.announce(app_data=self.name.encode('utf-8')) self.destination.announce(app_data=self.name.encode("utf-8"))
else: else:
self.destination.announce() self.destination.announce()
self.last_announce = time.time() self.last_announce = time.time()
time.sleep(1) time.sleep(1)
except Exception: except Exception:
self.logger.exception("Error in announce loop") self.logger.exception("Error in announce loop")
def _refresh_loop(self): def _refresh_loop(self):
while not self._stop_event.is_set(): try:
try: while not self._stop_event.is_set():
now = time.time() now = time.time()
if self.page_refresh_interval > 0 and now - self.last_page_refresh > self.page_refresh_interval: if (
self.page_refresh_interval > 0
and now - self.last_page_refresh > self.page_refresh_interval
):
self.register_pages() self.register_pages()
self.last_page_refresh = now self.last_page_refresh = now
if self.file_refresh_interval > 0 and now - self.last_file_refresh > self.file_refresh_interval: if (
self.file_refresh_interval > 0
and now - self.last_file_refresh > self.file_refresh_interval
):
self.register_files() self.register_files()
self.last_file_refresh = now self.last_file_refresh = now
time.sleep(1) time.sleep(1)
except Exception: except Exception:
self.logger.exception("Error in refresh loop") self.logger.exception("Error in refresh loop")
def shutdown(self): def shutdown(self):
self.logger.info("Shutting down PageNode...") self.logger.info("Shutting down PageNode...")
@@ -177,7 +202,7 @@ class PageNode:
except Exception: except Exception:
self.logger.exception("Error waiting for threads to shut down") self.logger.exception("Error waiting for threads to shut down")
try: try:
if hasattr(self.destination, 'close'): if hasattr(self.destination, "close"):
self.destination.close() self.destination.close()
except Exception: except Exception:
self.logger.exception("Error closing RNS destination") self.logger.exception("Error closing RNS destination")
@@ -185,15 +210,63 @@ class PageNode:
def main(): def main():
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node") parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
parser.add_argument('-c', '--config', dest='configpath', help='Reticulum config path', default=None) parser.add_argument(
parser.add_argument('-p', '--pages-dir', dest='pages_dir', help='Pages directory', default=os.path.join(os.getcwd(), 'pages')) "-c", "--config", dest="configpath", help="Reticulum config path", default=None
parser.add_argument('-f', '--files-dir', dest='files_dir', help='Files directory', default=os.path.join(os.getcwd(), 'files')) )
parser.add_argument('-n', '--node-name', dest='node_name', help='Node display name', default=None) parser.add_argument(
parser.add_argument('-a', '--announce-interval', dest='announce_interval', type=int, help='Announce interval in seconds', default=360) "-p",
parser.add_argument('-i', '--identity-dir', dest='identity_dir', help='Directory to store node identity', default=os.path.join(os.getcwd(), 'node-config')) "--pages-dir",
parser.add_argument('--page-refresh-interval', dest='page_refresh_interval', type=int, default=0, help='Page refresh interval in seconds, 0 disables auto-refresh') dest="pages_dir",
parser.add_argument('--file-refresh-interval', dest='file_refresh_interval', type=int, default=0, help='File refresh interval in seconds, 0 disables auto-refresh') help="Pages directory",
parser.add_argument('-l', '--log-level', dest='log_level', choices=['DEBUG','INFO','WARNING','ERROR','CRITICAL'], default='INFO', help='Logging level') default=os.path.join(os.getcwd(), "pages"),
)
parser.add_argument(
"-f",
"--files-dir",
dest="files_dir",
help="Files directory",
default=os.path.join(os.getcwd(), "files"),
)
parser.add_argument(
"-n", "--node-name", dest="node_name", help="Node display name", default=None
)
parser.add_argument(
"-a",
"--announce-interval",
dest="announce_interval",
type=int,
help="Announce interval in seconds",
default=360,
)
parser.add_argument(
"-i",
"--identity-dir",
dest="identity_dir",
help="Directory to store node identity",
default=os.path.join(os.getcwd(), "node-config"),
)
parser.add_argument(
"--page-refresh-interval",
dest="page_refresh_interval",
type=int,
default=0,
help="Page refresh interval in seconds, 0 disables auto-refresh",
)
parser.add_argument(
"--file-refresh-interval",
dest="file_refresh_interval",
type=int,
default=0,
help="File refresh interval in seconds, 0 disables auto-refresh",
)
parser.add_argument(
"-l",
"--log-level",
dest="log_level",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
default="INFO",
help="Logging level",
)
args = parser.parse_args() args = parser.parse_args()
configpath = args.configpath configpath = args.configpath
@@ -205,11 +278,13 @@ def main():
page_refresh_interval = args.page_refresh_interval page_refresh_interval = args.page_refresh_interval
file_refresh_interval = args.file_refresh_interval file_refresh_interval = args.file_refresh_interval
numeric_level = getattr(logging, args.log_level.upper(), logging.INFO) numeric_level = getattr(logging, args.log_level.upper(), logging.INFO)
logging.basicConfig(level=numeric_level, format='%(asctime)s %(name)s [%(levelname)s] %(message)s') logging.basicConfig(
level=numeric_level, format="%(asctime)s %(name)s [%(levelname)s] %(message)s"
)
RNS.Reticulum(configpath) RNS.Reticulum(configpath)
os.makedirs(identity_dir, exist_ok=True) os.makedirs(identity_dir, exist_ok=True)
identity_file = os.path.join(identity_dir, 'identity') identity_file = os.path.join(identity_dir, "identity")
if os.path.isfile(identity_file): if os.path.isfile(identity_file):
identity = RNS.Identity.from_file(identity_file) identity = RNS.Identity.from_file(identity_file)
else: else:
@@ -219,7 +294,15 @@ def main():
os.makedirs(pages_dir, exist_ok=True) os.makedirs(pages_dir, exist_ok=True)
os.makedirs(files_dir, exist_ok=True) os.makedirs(files_dir, exist_ok=True)
node = PageNode(identity, pages_dir, files_dir, announce_interval, node_name, page_refresh_interval, file_refresh_interval) node = PageNode(
identity,
pages_dir,
files_dir,
announce_interval,
node_name,
page_refresh_interval,
file_refresh_interval,
)
logger.info("Page node running. Press Ctrl-C to exit.") logger.info("Page node running. Press Ctrl-C to exit.")
try: try:
@@ -229,5 +312,6 @@ def main():
logger.info("Keyboard interrupt received, shutting down...") logger.info("Keyboard interrupt received, shutting down...")
node.shutdown() node.shutdown()
if __name__ == '__main__':
if __name__ == "__main__":
main() main()

View File

@@ -1,31 +1,31 @@
from setuptools import setup, find_packages from setuptools import find_packages, setup
with open('README.md', 'r', encoding='utf-8') as fh: with open("README.md", encoding="utf-8") as fh:
long_description = fh.read() long_description = fh.read()
setup( setup(
name='rns-page-node', name="rns-page-node",
version='0.2.0', version="0.2.0",
author='Sudo-Ivan', author="Sudo-Ivan",
author_email='', author_email="",
description='A simple way to serve pages and files over the Reticulum network.', description="A simple way to serve pages and files over the Reticulum network.",
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type="text/markdown",
url='https://github.com/Sudo-Ivan/rns-page-node', url="https://github.com/Sudo-Ivan/rns-page-node",
packages=find_packages(), packages=find_packages(),
license="GPL-3.0", license="GPL-3.0",
python_requires='>=3.10', python_requires=">=3.10",
install_requires=[ install_requires=[
'rns>=1.0.0,<1.5.0', "rns>=1.0.0,<1.5.0",
], ],
entry_points={ entry_points={
'console_scripts': [ "console_scripts": [
'rns-page-node=rns_page_node.main:main', "rns-page-node=rns_page_node.main:main",
], ],
}, },
classifiers=[ classifiers=[
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3",
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
'Operating System :: OS Independent', "Operating System :: OS Independent",
], ],
) )

View File

@@ -1,29 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import sys import sys
import time
import threading import threading
import time
import RNS import RNS
# Determine base directory for tests # Determine base directory for tests
dir_path = os.path.abspath(os.path.dirname(__file__)) dir_path = os.path.abspath(os.path.dirname(__file__))
config_dir = os.path.join(dir_path, 'config') config_dir = os.path.join(dir_path, "config")
identity_dir = os.path.join(dir_path, 'node-config') identity_dir = os.path.join(dir_path, "node-config")
# Initialize Reticulum with shared config # Initialize Reticulum with shared config
RNS.Reticulum(config_dir) RNS.Reticulum(config_dir)
# Load server identity (created by the page node) # Load server identity (created by the page node)
identity_file = os.path.join(identity_dir, 'identity') identity_file = os.path.join(identity_dir, "identity")
server_identity = RNS.Identity.from_file(identity_file) server_identity = RNS.Identity.from_file(identity_file)
# Create a destination to the server node # Create a destination to the server node
destination = RNS.Destination( destination = RNS.Destination(
server_identity, server_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "nomadnetwork", "node"
RNS.Destination.OUT,
RNS.Destination.SINGLE,
'nomadnetwork',
'node'
) )
# Ensure we know a path to the destination # Ensure we know a path to the destination
@@ -39,53 +36,57 @@ global_link = RNS.Link(destination)
responses = {} responses = {}
done_event = threading.Event() done_event = threading.Event()
# Callback for page response # Callback for page response
def on_page(response): def on_page(response):
data = response.response data = response.response
if isinstance(data, bytes): if isinstance(data, bytes):
text = data.decode('utf-8') text = data.decode("utf-8")
else: else:
text = str(data) text = str(data)
print('Received page:') print("Received page:")
print(text) print(text)
responses['page'] = text responses["page"] = text
if 'file' in responses: if "file" in responses:
done_event.set() done_event.set()
# Callback for file response # Callback for file response
def on_file(response): def on_file(response):
data = response.response data = response.response
# Handle response as [fileobj, headers] # Handle response as [fileobj, headers]
if isinstance(data, list) and len(data) == 2 and hasattr(data[0], 'read'): if isinstance(data, list) and len(data) == 2 and hasattr(data[0], "read"):
fileobj, headers = data fileobj, headers = data
file_data = fileobj.read() file_data = fileobj.read()
filename = headers.get(b'name', b'').decode('utf-8') filename = headers.get(b"name", b"").decode("utf-8")
print(f'Received file ({filename}):') print(f"Received file ({filename}):")
print(file_data.decode('utf-8')) print(file_data.decode("utf-8"))
responses['file'] = file_data.decode('utf-8') responses["file"] = file_data.decode("utf-8")
# Handle response as a raw file object # Handle response as a raw file object
elif hasattr(data, 'read'): elif hasattr(data, "read"):
file_data = data.read() file_data = data.read()
filename = os.path.basename('text.txt') filename = os.path.basename("text.txt")
print(f'Received file ({filename}):') print(f"Received file ({filename}):")
print(file_data.decode('utf-8')) print(file_data.decode("utf-8"))
responses['file'] = file_data.decode('utf-8') responses["file"] = file_data.decode("utf-8")
# Handle response as raw bytes # Handle response as raw bytes
elif isinstance(data, bytes): elif isinstance(data, bytes):
text = data.decode('utf-8') text = data.decode("utf-8")
print('Received file:') print("Received file:")
print(text) print(text)
responses['file'] = text responses["file"] = text
else: else:
print('Received file (unhandled format):', data) print("Received file (unhandled format):", data)
responses['file'] = str(data) responses["file"] = str(data)
if 'page' in responses: if "page" in responses:
done_event.set() done_event.set()
# Request the page and file once the link is established # Request the page and file once the link is established
def on_link_established(link): def on_link_established(link):
link.request('/page/index.mu', None, response_callback=on_page) link.request("/page/index.mu", None, response_callback=on_page)
link.request('/file/text.txt', None, response_callback=on_file) link.request("/file/text.txt", None, response_callback=on_file)
# Register callbacks # Register callbacks
global_link.set_link_established_callback(on_link_established) global_link.set_link_established_callback(on_link_established)
@@ -93,12 +94,12 @@ global_link.set_link_closed_callback(lambda l: done_event.set())
# Wait for responses or timeout # Wait for responses or timeout
if not done_event.wait(timeout=30): if not done_event.wait(timeout=30):
print('Test timed out.', file=sys.stderr) print("Test timed out.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if responses.get('page') and responses.get('file'): if responses.get("page") and responses.get("file"):
print('Tests passed!') print("Tests passed!")
sys.exit(0) sys.exit(0)
else: else:
print('Tests failed.', file=sys.stderr) print("Tests failed.", file=sys.stderr)
sys.exit(1) sys.exit(1)

View File

@@ -1,20 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import sys import sys
import time
import threading import threading
import time
import RNS import RNS
dir_path = os.path.abspath(os.path.dirname(__file__)) dir_path = os.path.abspath(os.path.dirname(__file__))
config_dir = os.path.join(dir_path, 'config') config_dir = os.path.join(dir_path, "config")
RNS.Reticulum(config_dir) RNS.Reticulum(config_dir)
DESTINATION_HEX = '49b2d959db8528347d0a38083aec1042' # Ivans Node that runs rns-page-node DESTINATION_HEX = (
"49b2d959db8528347d0a38083aec1042" # Ivans Node that runs rns-page-node
)
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
if len(DESTINATION_HEX) != dest_len: if len(DESTINATION_HEX) != dest_len:
print(f"Invalid destination length (got {len(DESTINATION_HEX)}, expected {dest_len})", file=sys.stderr) print(
f"Invalid destination length (got {len(DESTINATION_HEX)}, expected {dest_len})",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
destination_hash = bytes.fromhex(DESTINATION_HEX) destination_hash = bytes.fromhex(DESTINATION_HEX)
@@ -28,32 +34,32 @@ server_identity = RNS.Identity.recall(destination_hash)
print(f"Recalled server identity for {DESTINATION_HEX}") print(f"Recalled server identity for {DESTINATION_HEX}")
destination = RNS.Destination( destination = RNS.Destination(
server_identity, server_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "nomadnetwork", "node"
RNS.Destination.OUT,
RNS.Destination.SINGLE,
'nomadnetwork',
'node'
) )
link = RNS.Link(destination) link = RNS.Link(destination)
done_event = threading.Event() done_event = threading.Event()
def on_page(response): def on_page(response):
data = response.response data = response.response
if isinstance(data, bytes): if isinstance(data, bytes):
text = data.decode('utf-8') text = data.decode("utf-8")
else: else:
text = str(data) text = str(data)
print('Fetched page content:') print("Fetched page content:")
print(text) print(text)
done_event.set() done_event.set()
link.set_link_established_callback(lambda l: l.request('/page/index.mu', None, response_callback=on_page))
link.set_link_established_callback(
lambda l: l.request("/page/index.mu", None, response_callback=on_page)
)
link.set_link_closed_callback(lambda l: done_event.set()) link.set_link_closed_callback(lambda l: done_event.set())
if not done_event.wait(timeout=30): if not done_event.wait(timeout=30):
print('Timed out waiting for page', file=sys.stderr) print("Timed out waiting for page", file=sys.stderr)
sys.exit(1) sys.exit(1)
print('Done fetching page.') print("Done fetching page.")
sys.exit(0) sys.exit(0)