Improved PageNode class with documentation, error handling, and path management.
Update file and page serving methods to utilize pathlib for modern python path handling
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
"""Minimal Reticulum Page Node
|
"""Minimal Reticulum Page Node.
|
||||||
|
|
||||||
Serves .mu pages and files over RNS.
|
Serves .mu pages and files over RNS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_INDEX = """>Default Home Page
|
DEFAULT_INDEX = """>Default Home Page
|
||||||
|
|
||||||
This node is serving pages using rns-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 rns-page-node, but index.mu was not found.
|
||||||
|
Please add an index.mu file to customize the home page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_NOTALLOWED = """>Request Not Allowed
|
DEFAULT_NOTALLOWED = """>Request Not Allowed
|
||||||
@@ -25,6 +25,8 @@ You are not authorised to carry out the request.
|
|||||||
|
|
||||||
|
|
||||||
class PageNode:
|
class PageNode:
|
||||||
|
"""A Reticulum page node that serves .mu pages and files over RNS."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
identity,
|
identity,
|
||||||
@@ -35,15 +37,30 @@ class PageNode:
|
|||||||
page_refresh_interval=0,
|
page_refresh_interval=0,
|
||||||
file_refresh_interval=0,
|
file_refresh_interval=0,
|
||||||
):
|
):
|
||||||
|
"""Initialize the PageNode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identity: RNS Identity for the node
|
||||||
|
pagespath: Path to directory containing .mu pages
|
||||||
|
filespath: Path to directory containing files to serve
|
||||||
|
announce_interval: Seconds between announcements (default: 360)
|
||||||
|
name: Display name for the node (optional)
|
||||||
|
page_refresh_interval: Seconds between page rescans (0 = disabled)
|
||||||
|
file_refresh_interval: Seconds between file rescans (0 = disabled)
|
||||||
|
|
||||||
|
"""
|
||||||
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.identity = identity
|
self.identity = identity
|
||||||
self.name = name
|
self.name = name
|
||||||
self.pagespath = pagespath
|
self.pagespath = pagespath
|
||||||
self.filespath = filespath
|
self.filespath = filespath
|
||||||
self.destination = RNS.Destination(
|
self.destination = RNS.Destination(
|
||||||
identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node",
|
identity,
|
||||||
|
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
|
||||||
@@ -58,20 +75,22 @@ 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(
|
self._announce_thread = threading.Thread(
|
||||||
target=self._announce_loop, daemon=True,
|
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()
|
||||||
|
|
||||||
def register_pages(self):
|
def register_pages(self):
|
||||||
|
"""Scan pages directory and register request handlers for all .mu files."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.servedpages = []
|
self.servedpages = []
|
||||||
self._scan_pages(self.pagespath)
|
self._scan_pages(self.pagespath)
|
||||||
|
|
||||||
pagespath = os.path.join(self.pagespath, "")
|
pagespath = Path(self.pagespath)
|
||||||
|
|
||||||
if not os.path.isfile(os.path.join(self.pagespath, "index.mu")):
|
if not (pagespath / "index.mu").is_file():
|
||||||
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,
|
||||||
@@ -79,7 +98,7 @@ class PageNode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for full_path in self.servedpages:
|
for full_path in self.servedpages:
|
||||||
rel = full_path[len(pagespath) :]
|
rel = full_path[len(str(pagespath)) :]
|
||||||
if not rel.startswith("/"):
|
if not rel.startswith("/"):
|
||||||
rel = "/" + rel
|
rel = "/" + rel
|
||||||
request_path = f"/page{rel}"
|
request_path = f"/page{rel}"
|
||||||
@@ -90,14 +109,15 @@ class PageNode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def register_files(self):
|
def register_files(self):
|
||||||
|
"""Scan files directory and register request handlers for all files."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.servedfiles = []
|
self.servedfiles = []
|
||||||
self._scan_files(self.filespath)
|
self._scan_files(self.filespath)
|
||||||
|
|
||||||
filespath = os.path.join(self.filespath, "")
|
filespath = Path(self.filespath)
|
||||||
|
|
||||||
for full_path in self.servedfiles:
|
for full_path in self.servedfiles:
|
||||||
rel = full_path[len(filespath) :]
|
rel = full_path[len(str(filespath)) :]
|
||||||
if not rel.startswith("/"):
|
if not rel.startswith("/"):
|
||||||
rel = "/" + rel
|
rel = "/" + rel
|
||||||
request_path = f"/file{rel}"
|
request_path = f"/file{rel}"
|
||||||
@@ -109,89 +129,115 @@ class PageNode:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _scan_pages(self, base):
|
def _scan_pages(self, base):
|
||||||
for entry in os.listdir(base):
|
base_path = Path(base)
|
||||||
if entry.startswith("."):
|
for entry in base_path.iterdir():
|
||||||
|
if entry.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
path = os.path.join(base, entry)
|
if entry.is_dir():
|
||||||
if os.path.isdir(path):
|
self._scan_pages(str(entry))
|
||||||
self._scan_pages(path)
|
elif entry.is_file() and not entry.name.endswith(".allowed"):
|
||||||
elif os.path.isfile(path) and not entry.endswith(".allowed"):
|
self.servedpages.append(str(entry))
|
||||||
self.servedpages.append(path)
|
|
||||||
|
|
||||||
def _scan_files(self, base):
|
def _scan_files(self, base):
|
||||||
for entry in os.listdir(base):
|
base_path = Path(base)
|
||||||
if entry.startswith("."):
|
for entry in base_path.iterdir():
|
||||||
|
if entry.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
path = os.path.join(base, entry)
|
if entry.is_dir():
|
||||||
if os.path.isdir(path):
|
self._scan_files(str(entry))
|
||||||
self._scan_files(path)
|
elif entry.is_file():
|
||||||
elif os.path.isfile(path):
|
self.servedfiles.append(str(entry))
|
||||||
self.servedfiles.append(path)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serve_default_index(
|
def serve_default_index(
|
||||||
path, data, request_id, link_id, remote_identity, requested_at,
|
_path,
|
||||||
|
_data,
|
||||||
|
_request_id,
|
||||||
|
_link_id,
|
||||||
|
_remote_identity,
|
||||||
|
_requested_at,
|
||||||
):
|
):
|
||||||
|
"""Serve the default index page when no index.mu file exists."""
|
||||||
return DEFAULT_INDEX.encode("utf-8")
|
return DEFAULT_INDEX.encode("utf-8")
|
||||||
|
|
||||||
def serve_page(
|
def serve_page(
|
||||||
self, path, data, request_id, link_id, remote_identity, requested_at,
|
self,
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
_request_id,
|
||||||
|
_link_id,
|
||||||
|
remote_identity,
|
||||||
|
_requested_at,
|
||||||
):
|
):
|
||||||
pagespath = os.path.join(self.pagespath, "")
|
"""Serve a .mu page file, executing it as a script if it has a shebang."""
|
||||||
file_path = pagespath + path[5:]
|
pagespath = Path(self.pagespath)
|
||||||
|
relative_path = path[6:] if path.startswith("/page/") else path[5:]
|
||||||
|
file_path = pagespath / relative_path
|
||||||
try:
|
try:
|
||||||
with open(file_path, "rb") as _f:
|
with file_path.open("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(str(file_path), os.X_OK):
|
||||||
# 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:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
if remote_identity:
|
if remote_identity:
|
||||||
env["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False)
|
env["remote_identity"] = RNS.hexrep(
|
||||||
|
remote_identity.hash,
|
||||||
|
delimit=False,
|
||||||
|
)
|
||||||
if data and isinstance(data, bytes):
|
if data and isinstance(data, bytes):
|
||||||
try:
|
try:
|
||||||
data_str = data.decode('utf-8')
|
data_str = data.decode("utf-8")
|
||||||
if data_str:
|
if data_str:
|
||||||
if '|' in data_str and '&' not in data_str:
|
if "|" in data_str and "&" not in data_str:
|
||||||
pairs = data_str.split('|')
|
pairs = data_str.split("|")
|
||||||
else:
|
else:
|
||||||
pairs = data_str.split('&')
|
pairs = data_str.split("&")
|
||||||
for pair in pairs:
|
for pair in pairs:
|
||||||
if '=' in pair:
|
if "=" in pair:
|
||||||
key, value = pair.split('=', 1)
|
key, value = pair.split("=", 1)
|
||||||
if key.startswith('field_'):
|
if key.startswith(("field_", "var_")):
|
||||||
env[key] = value
|
env[key] = value
|
||||||
elif key.startswith('var_'):
|
elif key == "action":
|
||||||
env[key] = value
|
env["var_action"] = value
|
||||||
elif key == 'action':
|
|
||||||
env['var_action'] = value
|
|
||||||
else:
|
else:
|
||||||
env[f'field_{key}'] = value
|
env[f"field_{key}"] = value
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self.logger.exception("Error parsing request data")
|
RNS.log(f"Error parsing request data: {e}", RNS.LOG_ERROR)
|
||||||
result = subprocess.run([file_path], stdout=subprocess.PIPE, check=True, env=env) # noqa: S603
|
result = subprocess.run( # noqa: S603
|
||||||
|
[str(file_path)],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
return result.stdout
|
return result.stdout
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self.logger.exception("Error executing script page")
|
RNS.log(f"Error executing script page: {e}", RNS.LOG_ERROR)
|
||||||
with open(file_path, "rb") as f:
|
with file_path.open("rb") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
def serve_file(
|
def serve_file(
|
||||||
self, path, data, request_id, link_id, remote_identity, requested_at,
|
self,
|
||||||
|
path,
|
||||||
|
_data,
|
||||||
|
_request_id,
|
||||||
|
_link_id,
|
||||||
|
_remote_identity,
|
||||||
|
_requested_at,
|
||||||
):
|
):
|
||||||
filespath = os.path.join(self.filespath, "")
|
"""Serve a file from the files directory."""
|
||||||
file_path = filespath + path[6:]
|
filespath = Path(self.filespath)
|
||||||
|
relative_path = path[6:] if path.startswith("/file/") else path[5:]
|
||||||
|
file_path = filespath / relative_path
|
||||||
return [
|
return [
|
||||||
open(file_path, "rb"),
|
file_path.open("rb"),
|
||||||
{"name": os.path.basename(file_path).encode("utf-8")},
|
{"name": file_path.name.encode("utf-8")},
|
||||||
]
|
]
|
||||||
|
|
||||||
def on_connect(self, link):
|
def on_connect(self, link):
|
||||||
pass
|
"""Handle new link connections."""
|
||||||
|
|
||||||
def _announce_loop(self):
|
def _announce_loop(self):
|
||||||
try:
|
try:
|
||||||
@@ -203,8 +249,8 @@ class PageNode:
|
|||||||
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 as e:
|
||||||
self.logger.exception("Error in announce loop")
|
RNS.log(f"Error in announce loop: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
def _refresh_loop(self):
|
def _refresh_loop(self):
|
||||||
try:
|
try:
|
||||||
@@ -223,45 +269,55 @@ class PageNode:
|
|||||||
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 as e:
|
||||||
self.logger.exception("Error in refresh loop")
|
RNS.log(f"Error in refresh loop: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.logger.info("Shutting down PageNode...")
|
"""Gracefully shutdown the PageNode and cleanup resources."""
|
||||||
|
RNS.log("Shutting down PageNode...", RNS.LOG_INFO)
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
try:
|
try:
|
||||||
self._announce_thread.join(timeout=5)
|
self._announce_thread.join(timeout=5)
|
||||||
self._refresh_thread.join(timeout=5)
|
self._refresh_thread.join(timeout=5)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self.logger.exception("Error waiting for threads to shut down")
|
RNS.log(f"Error waiting for threads to shut down: {e}", RNS.LOG_ERROR)
|
||||||
try:
|
try:
|
||||||
if hasattr(self.destination, "close"):
|
if hasattr(self.destination, "close"):
|
||||||
self.destination.close()
|
self.destination.close()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self.logger.exception("Error closing RNS destination")
|
RNS.log(f"Error closing RNS destination: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Run the RNS page node application."""
|
||||||
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
|
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c", "--config", dest="configpath", help="Reticulum config path", default=None,
|
"-c",
|
||||||
|
"--config",
|
||||||
|
dest="configpath",
|
||||||
|
help="Reticulum config path",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-p",
|
"-p",
|
||||||
"--pages-dir",
|
"--pages-dir",
|
||||||
dest="pages_dir",
|
dest="pages_dir",
|
||||||
help="Pages directory",
|
help="Pages directory",
|
||||||
default=os.path.join(os.getcwd(), "pages"),
|
default=str(Path.cwd() / "pages"),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-f",
|
"-f",
|
||||||
"--files-dir",
|
"--files-dir",
|
||||||
dest="files_dir",
|
dest="files_dir",
|
||||||
help="Files directory",
|
help="Files directory",
|
||||||
default=os.path.join(os.getcwd(), "files"),
|
default=str(Path.cwd() / "files"),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-n", "--node-name", dest="node_name", help="Node display name", default=None,
|
"-n",
|
||||||
|
"--node-name",
|
||||||
|
dest="node_name",
|
||||||
|
help="Node display name",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-a",
|
"-a",
|
||||||
@@ -276,7 +332,7 @@ def main():
|
|||||||
"--identity-dir",
|
"--identity-dir",
|
||||||
dest="identity_dir",
|
dest="identity_dir",
|
||||||
help="Directory to store node identity",
|
help="Directory to store node identity",
|
||||||
default=os.path.join(os.getcwd(), "node-config"),
|
default=str(Path.cwd() / "node-config"),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--page-refresh-interval",
|
"--page-refresh-interval",
|
||||||
@@ -310,22 +366,18 @@ def main():
|
|||||||
identity_dir = args.identity_dir
|
identity_dir = args.identity_dir
|
||||||
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)
|
|
||||||
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)
|
Path(identity_dir).mkdir(parents=True, exist_ok=True)
|
||||||
identity_file = os.path.join(identity_dir, "identity")
|
identity_file = Path(identity_dir) / "identity"
|
||||||
if os.path.isfile(identity_file):
|
if identity_file.is_file():
|
||||||
identity = RNS.Identity.from_file(identity_file)
|
identity = RNS.Identity.from_file(str(identity_file))
|
||||||
else:
|
else:
|
||||||
identity = RNS.Identity()
|
identity = RNS.Identity()
|
||||||
identity.to_file(identity_file)
|
identity.to_file(str(identity_file))
|
||||||
|
|
||||||
os.makedirs(pages_dir, exist_ok=True)
|
Path(pages_dir).mkdir(parents=True, exist_ok=True)
|
||||||
os.makedirs(files_dir, exist_ok=True)
|
Path(files_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
node = PageNode(
|
node = PageNode(
|
||||||
identity,
|
identity,
|
||||||
@@ -336,15 +388,14 @@ def main():
|
|||||||
page_refresh_interval,
|
page_refresh_interval,
|
||||||
file_refresh_interval,
|
file_refresh_interval,
|
||||||
)
|
)
|
||||||
logger.info("Page node running. Press Ctrl-C to exit.")
|
RNS.log("Page node running. Press Ctrl-C to exit.", RNS.LOG_INFO)
|
||||||
logger.info("Node address: %s", RNS.prettyhexrep(node.destination.hash))
|
RNS.log(f"Node address: {RNS.prettyhexrep(node.destination.hash)}", RNS.LOG_INFO)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Keyboard interrupt received, shutting down...")
|
RNS.log("Keyboard interrupt received, shutting down...", RNS.LOG_INFO)
|
||||||
node.shutdown()
|
node.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user