25 Commits

Author SHA1 Message Date
Sudo-Ivan
eb27326763 Bump package version to 1.0.0. 2025-07-14 17:31:45 -05:00
Sudo-Ivan
f40d5a51ae Refactor main to improve readability and maintainability. 2025-07-14 17:27:17 -05:00
Sudo-Ivan
4aa83a2dfb Add badges to README.md. 2025-07-14 17:22:26 -05:00
deepsource-io[bot]
a8b09611a1 ci: add .deepsource.toml 2025-07-14 22:01:12 +00:00
Sudo-Ivan
d0dd9e88c4 Dynamically use docker buildx build if available, otherwise fallback to docker build. 2025-07-14 16:59:02 -05:00
Sudo-Ivan
09d84c2533 Allow passing build arguments to Docker build commands in Makefile. 2025-07-14 16:57:21 -05:00
Sudo-Ivan
06ab592cb9 Refactor Dockerfiles to improve Poetry virtual environment management and execution. 2025-07-14 16:57:02 -05:00
Sudo-Ivan
843c3a1a56 Update rns package version to 1.0.0 in requirements.txt. 2025-07-14 16:38:14 -05:00
Sudo-Ivan
37ac95753c Update package versions including rns and development tools. 2025-07-14 16:38:08 -05:00
Sudo-Ivan
a493c57ad2 Update package version and RNS dependency range. 2025-07-14 16:38:01 -05:00
Sudo-Ivan
698bfb2e81 Refactor Dockerfiles to use Poetry for dependency management. 2025-07-14 16:36:50 -05:00
Sudo-Ivan
eaf2e544c4 Add a GitHub Actions workflow to build and test Docker images for multiple Python versions. 2025-07-14 16:36:30 -05:00
Sudo-Ivan
89f88e24ea Add build and push for rootless Docker image in CI workflow. 2025-07-14 16:36:24 -05:00
Sudo-Ivan
a47b78c13d update 2025-07-14 16:31:44 -05:00
Sudo-Ivan
4831f5261d add 2025-07-14 16:31:38 -05:00
Sudo-Ivan
46f90e461f add tests 2025-07-14 16:31:34 -05:00
Sudo-Ivan
a1bbe8bc8a Add ARM64 docker support 2025-07-14 16:31:01 -05:00
Sudo-Ivan
7092883834 remove 2025-07-05 23:57:32 -05:00
Sudo-Ivan
74f5174254 update dependencies and add safety 2025-07-05 23:55:03 -05:00
Sudo-Ivan
f9699c060a add 2025-07-05 23:54:47 -05:00
Sudo-Ivan
a3ccd49439 update 2025-05-28 18:16:16 -05:00
Sudo-Ivan
ece0473beb update python requirement 2025-05-28 16:53:55 -05:00
Sudo-Ivan
89065f6e0a fix 2025-05-28 16:48:34 -05:00
Sudo-Ivan
e873d8e754 update 2025-05-28 16:45:46 -05:00
Sudo-Ivan
5561205b3e update 2025-05-28 16:39:15 -05:00
19 changed files with 1807 additions and 189 deletions

7
.deepsource.toml Normal file
View File

@@ -0,0 +1,7 @@
version = 1
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"

27
.github/workflows/docker-test.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Docker Build Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Build Docker Image
run: docker build . --file Dockerfile --build-arg PYTHON_VERSION=${{ matrix.python-version }} --tag lxmfy-test:${{ matrix.python-version }}

View File

@@ -22,6 +22,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -49,20 +54,33 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for Docker (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-rootless
tags: |
type=raw,value=latest-rootless,enable={{is_default_branch}}
type=ref,event=branch,prefix=,suffix=-rootless,enable={{is_default_branch}}
type=semver,pattern={{version}},suffix=-rootless
type=semver,pattern={{major}}.{{minor}},suffix=-rootless
type=sha,format=short,suffix=-rootless
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.rootless
file: ./Dockerfile.rootless
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}-rootless
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,4 +1,5 @@
FROM python:3.13-alpine
ARG PYTHON_VERSION=3.13
FROM python:${PYTHON_VERSION}-alpine
LABEL org.opencontainers.image.source="https://github.com/Sudo-Ivan/rns-page-node"
LABEL org.opencontainers.image.description="A simple way to serve pages and files over the Reticulum network."
@@ -7,11 +8,17 @@ LABEL org.opencontainers.image.authors="Sudo-Ivan"
WORKDIR /app
COPY requirements.txt ./
COPY setup.py ./
RUN apk add --no-cache gcc python3-dev musl-dev linux-headers
RUN pip install poetry
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
COPY pyproject.toml poetry.lock* ./
COPY README.md ./
COPY rns_page_node ./rns_page_node
RUN pip install --upgrade pip setuptools wheel && pip install -r requirements.txt .
RUN poetry install --no-interaction --no-ansi
ENTRYPOINT ["rns-page-node"]
ENV PATH="/app/.venv/bin:$PATH"
ENTRYPOINT ["poetry", "run", "rns-page-node"]

View File

@@ -1,17 +1,17 @@
FROM python:3.13-alpine AS builder
RUN apk update
RUN apk add build-base libffi-dev cargo pkgconfig
RUN apk add --no-cache build-base libffi-dev cargo pkgconfig gcc python3-dev musl-dev linux-headers
WORKDIR /src
COPY setup.py ./
RUN pip install poetry
COPY pyproject.toml ./
COPY README.md ./
COPY rns_page_node ./rns_page_node
RUN pip install --upgrade pip setuptools wheel
RUN pip wheel . --no-deps --wheel-dir /src/dist
RUN poetry build --format wheel
FROM scratch AS dist

View File

@@ -1,4 +1,5 @@
FROM python:3.13-alpine
ARG PYTHON_VERSION=3.13
FROM python:${PYTHON_VERSION}-alpine
LABEL org.opencontainers.image.source="https://github.com/Sudo-Ivan/rns-page-node"
LABEL org.opencontainers.image.description="A simple way to serve pages and files over the Reticulum network."
@@ -9,17 +10,19 @@ RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
WORKDIR /app
COPY requirements.txt setup.py README.md ./
RUN apk add --no-cache gcc python3-dev musl-dev linux-headers
RUN pip install poetry
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
COPY pyproject.toml poetry.lock* ./
COPY README.md ./
COPY rns_page_node ./rns_page_node
RUN pip install --upgrade pip setuptools wheel && pip install -r requirements.txt .
RUN poetry install --no-interaction --no-ansi
ENV PATH="/app/.venv/bin:$PATH"
USER app
ENTRYPOINT ["rns-page-node"]
ENTRYPOINT ["poetry", "run", "rns-page-node"]

View File

@@ -1,6 +1,9 @@
# Makefile for rns-page-node
.PHONY: all build sdist wheel clean install lint format docker-wheels docker-build docker-run docker-build-rootless docker-run-rootless help
# Detect if docker buildx is available
DOCKER_BUILD := $(shell docker buildx version >/dev/null 2>&1 && echo "docker buildx build" || echo "docker build")
.PHONY: all build sdist wheel clean install lint format docker-wheels docker-build docker-run docker-build-rootless docker-run-rootless help test docker-test
all: build
@@ -26,13 +29,13 @@ format:
ruff check --fix .
docker-wheels:
docker build --target builder -f Dockerfile.build -t rns-page-node-builder .
$(DOCKER_BUILD) --target builder -f Dockerfile.build -t rns-page-node-builder .
docker create --name builder-container rns-page-node-builder true
docker cp builder-container:/src/dist ./dist
docker rm builder-container
docker-build:
docker build -f Dockerfile -t rns-page-node:latest .
$(DOCKER_BUILD) $(BUILD_ARGS) -f Dockerfile -t rns-page-node:latest .
docker-run:
docker run --rm -it \
@@ -47,7 +50,7 @@ docker-run:
--announce-interval 360
docker-build-rootless:
docker build -f Dockerfile.rootless -t rns-page-node-rootless:latest .
$(DOCKER_BUILD) $(BUILD_ARGS) -f Dockerfile.rootless -t rns-page-node-rootless:latest .
docker-run-rootless:
docker run --rm -it \
@@ -61,6 +64,13 @@ docker-run-rootless:
--identity-dir /app/node-config \
--announce-interval 360
test:
bash tests/run_tests.sh
docker-test:
$(DOCKER_BUILD) -f tests/Dockerfile.tests -t rns-page-node-tests .
docker run --rm rns-page-node-tests
help:
@echo "Makefile commands:"
@echo " all - alias for build"
@@ -76,3 +86,5 @@ help:
@echo " docker-run - run runtime Docker image"
@echo " docker-build-rootless - build rootless runtime Docker image"
@echo " docker-run-rootless - run rootless runtime Docker image"
@echo " test - run local integration tests"
@echo " docker-test - build and run integration tests in Docker"

View File

@@ -1,15 +1,15 @@
# RNS Page Node
[![Build and Publish Docker Image](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker.yml/badge.svg)](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker.yml)
[![Docker Build Test](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker-test.yml/badge.svg)](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker-test.yml)
[![DeepSource](https://app.deepsource.com/gh/Sudo-Ivan/rns-page-node.svg/?label=active+issues&show_trend=true&token=kajzd0SjJXSzkuN3z3kG9gQw)](https://app.deepsource.com/gh/Sudo-Ivan/rns-page-node/)
A simple way to serve pages and files over the [Reticulum network](https://reticulum.network/). Drop-in replacement for [NomadNet](https://github.com/markqvist/NomadNet) nodes that primarily serve pages and files.
## Usage
```bash
pip install git+https://github.com/Sudo-Ivan/rns-page-node.git
# or
pipx install git+https://github.com/Sudo-Ivan/rns-page-node.git
pip install rns-page-node
```
```bash
@@ -56,9 +56,9 @@ make wheel
make docker-wheels
```
## Page formats
## Pages
- Micron `.mu`
Supports Micron `.mu` and dynamic pages with `#!` in the micron files.
## Options
@@ -69,12 +69,11 @@ make docker-wheels
-f, --files-dir: The directory to serve files from.
-i, --identity-dir: The directory to persist the node's identity.
-a, --announce-interval: The interval to announce the node's presence.
-r, --page-refresh-interval: The interval to refresh pages.
-f, --file-refresh-interval: The interval to refresh files.
-l, --log-level: The logging level.
```
## To-Do
- [ ] Pypi
## License
This project incorporates portions of the [NomadNet](https://github.com/markqvist/NomadNet) codebase, which is licensed under the GNU General Public License v3.0 (GPL-3.0). As a derivative work, this project is also distributed under the terms of the GPL-3.0. See the [LICENSE](LICENSE) file for full license.

1354
poetry.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
[project]
name = "rns-page-node"
version = "0.1.2"
license = {file = "LICENSE"}
version = "1.0.0"
license = "GPL-3.0-only"
description = "A simple way to serve pages and files over the Reticulum network."
authors = [
{name = "Sudo-Ivan"}
]
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"rns (>=0.9.6,<0.10.0)"
"rns (>=1.0.0,<1.5.0)"
]
[project.scripts]
@@ -20,5 +20,6 @@ requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies]
ruff = "^0.11.11"
ruff = "^0.12.3"
safety = "^3.6.0"

View File

@@ -1 +1 @@
rns==0.9.6
rns=1.0.0

View File

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

View File

@@ -1,31 +1,41 @@
#!/usr/bin/env python3
"""
Minimal Reticulum Page Node
"""Minimal Reticulum Page Node
Serves .mu pages and files over RNS.
"""
import os
import time
import threading
import subprocess
import RNS
import argparse
import logging
import os
import subprocess
import threading
import time
import RNS
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.
'''
"""
DEFAULT_NOTALLOWED = '''>Request Not Allowed
DEFAULT_NOTALLOWED = """>Request Not Allowed
You are not authorised to carry out the request.
'''
"""
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._lock = threading.Lock()
self.logger = logging.getLogger(f"{__name__}.PageNode")
@@ -34,11 +44,7 @@ class PageNode:
self.pagespath = pagespath
self.filespath = filespath
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.last_announce = 0
@@ -52,7 +58,9 @@ class PageNode:
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._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
self._refresh_thread.start()
@@ -66,7 +74,7 @@ class PageNode:
self.destination.register_request_handler(
"/page/index.mu",
response_generator=self.serve_default_index,
allow=RNS.Destination.ALLOW_ALL
allow=RNS.Destination.ALLOW_ALL,
)
for full_path in self.servedpages:
@@ -75,7 +83,7 @@ class PageNode:
self.destination.register_request_handler(
request_path,
response_generator=self.serve_page,
allow=RNS.Destination.ALLOW_ALL
allow=RNS.Destination.ALLOW_ALL,
)
def register_files(self):
@@ -90,12 +98,12 @@ class PageNode:
request_path,
response_generator=self.serve_file,
allow=RNS.Destination.ALLOW_ALL,
auto_compress=32_000_000
auto_compress=32_000_000,
)
def _scan_pages(self, base):
for entry in os.listdir(base):
if entry.startswith('.'):
if entry.startswith("."):
continue
path = os.path.join(base, entry)
if os.path.isdir(path):
@@ -105,7 +113,7 @@ class PageNode:
def _scan_files(self, base):
for entry in os.listdir(base):
if entry.startswith('.'):
if entry.startswith("."):
continue
path = os.path.join(base, entry)
if os.path.isdir(path):
@@ -113,40 +121,51 @@ class PageNode:
elif os.path.isfile(path):
self.servedfiles.append(path)
def serve_default_index(self, path, data, request_id, link_id, remote_identity, requested_at):
return DEFAULT_INDEX.encode('utf-8')
@staticmethod
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)
try:
with open(file_path, 'rb') as _f:
with open(file_path, "rb") as _f:
first_line = _f.readline()
is_script = first_line.startswith(b'#!')
is_script = first_line.startswith(b"#!")
except Exception:
is_script = False
if is_script and os.access(file_path, os.X_OK):
# Note: You can remove the following try-except block and just serve the page content statically
# 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:
result = subprocess.run([file_path], stdout=subprocess.PIPE)
result = subprocess.run([file_path], stdout=subprocess.PIPE, check=True) # noqa: S603
return result.stdout
except Exception:
pass
with open(file_path, 'rb') as f:
self.logger.exception("Error executing script page")
with open(file_path, "rb") as f:
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)
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):
pass
def _announce_loop(self):
while not self._stop_event.is_set():
try:
while not self._stop_event.is_set():
if time.time() - self.last_announce > self.announce_interval:
if self.name:
self.destination.announce(app_data=self.name.encode('utf-8'))
self.destination.announce(app_data=self.name.encode("utf-8"))
else:
self.destination.announce()
self.last_announce = time.time()
@@ -155,13 +174,19 @@ class PageNode:
self.logger.exception("Error in announce loop")
def _refresh_loop(self):
while not self._stop_event.is_set():
try:
while not self._stop_event.is_set():
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.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.last_file_refresh = now
time.sleep(1)
@@ -177,7 +202,7 @@ class PageNode:
except Exception:
self.logger.exception("Error waiting for threads to shut down")
try:
if hasattr(self.destination, 'close'):
if hasattr(self.destination, "close"):
self.destination.close()
except Exception:
self.logger.exception("Error closing RNS destination")
@@ -185,15 +210,63 @@ class PageNode:
def main():
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
parser.add_argument('-c', '--config', dest='configpath', help='Reticulum config path', default=None)
parser.add_argument('-p', '--pages-dir', dest='pages_dir', help='Pages directory', 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')
parser.add_argument(
"-c", "--config", dest="configpath", help="Reticulum config path", default=None
)
parser.add_argument(
"-p",
"--pages-dir",
dest="pages_dir",
help="Pages directory",
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()
configpath = args.configpath
@@ -205,11 +278,13 @@ def main():
page_refresh_interval = args.page_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')
logging.basicConfig(
level=numeric_level, format="%(asctime)s %(name)s [%(levelname)s] %(message)s"
)
RNS.Reticulum(configpath)
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):
identity = RNS.Identity.from_file(identity_file)
else:
@@ -219,7 +294,15 @@ def main():
os.makedirs(pages_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.")
try:
@@ -229,5 +312,6 @@ def main():
logger.info("Keyboard interrupt received, shutting down...")
node.shutdown()
if __name__ == '__main__':
if __name__ == "__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()
setup(
name='rns-page-node',
version='0.1.2',
author='Sudo-Ivan',
author_email='',
description='A simple way to serve pages and files over the Reticulum network.',
name="rns-page-node",
version="1.0.0",
author="Sudo-Ivan",
author_email="",
description="A simple way to serve pages and files over the Reticulum network.",
long_description=long_description,
long_description_content_type='text/markdown',
url='https://github.com/Sudo-Ivan/rns-page-node',
long_description_content_type="text/markdown",
url="https://github.com/Sudo-Ivan/rns-page-node",
packages=find_packages(),
python_requires='>=3.9',
license="GPL-3.0",
python_requires=">=3.10",
install_requires=[
'rns>=0.9.6,<0.10.0',
"rns>=1.0.0,<1.5.0",
],
entry_points={
'console_scripts': [
'rns-page-node=rns_page_node.main:main',
"console_scripts": [
"rns-page-node=rns_page_node.main:main",
],
},
license='GPL-3.0',
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Operating System :: OS Independent',
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
],
)

4
tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
pages/
node-config/
node.log
config/

14
tests/Dockerfile.tests Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.10-slim
RUN apt-get update && apt-get install -y build-essential libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir .
WORKDIR /app/tests
RUN chmod +x run_tests.sh
CMD ["bash", "run_tests.sh"]

44
tests/run_tests.sh Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "${BASH_SOURCE[0]}")"
# Remove previous test artifacts
rm -rf config node-config pages files node.log
# Create directories for config, node identity, pages, and files
mkdir -p config node-config pages files
# Create a sample page and a test file
cat > pages/index.mu << EOF
>Test Page
This is a test page.
EOF
cat > files/text.txt << EOF
This is a test file.
EOF
# Start the page node in the background
python3 ../rns_page_node/main.py -c config -i node-config -p pages -f files > node.log 2>&1 &
NODE_PID=$!
# Wait for node to generate its identity file
echo "Waiting for node identity..."
for i in {1..40}; do
if [ -f node-config/identity ]; then
echo "Identity file found"
break
fi
sleep 0.25
done
if [ ! -f node-config/identity ]; then
echo "Error: node identity file not found" >&2
kill $NODE_PID
exit 1
fi
# Run the client test
python3 test_client.py
# Clean up
kill $NODE_PID

105
tests/test_client.py Normal file
View File

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

65
tests/test_client2.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import os
import sys
import threading
import time
import RNS
dir_path = os.path.abspath(os.path.dirname(__file__))
config_dir = os.path.join(dir_path, "config")
RNS.Reticulum(config_dir)
DESTINATION_HEX = (
"49b2d959db8528347d0a38083aec1042" # Ivans Node that runs rns-page-node
)
dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2
if len(DESTINATION_HEX) != dest_len:
print(
f"Invalid destination length (got {len(DESTINATION_HEX)}, expected {dest_len})",
file=sys.stderr,
)
sys.exit(1)
destination_hash = bytes.fromhex(DESTINATION_HEX)
if not RNS.Transport.has_path(destination_hash):
print("Requesting path to server...")
RNS.Transport.request_path(destination_hash)
while not RNS.Transport.has_path(destination_hash):
time.sleep(0.1)
server_identity = RNS.Identity.recall(destination_hash)
print(f"Recalled server identity for {DESTINATION_HEX}")
destination = RNS.Destination(
server_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "nomadnetwork", "node"
)
link = RNS.Link(destination)
done_event = threading.Event()
def on_page(response):
data = response.response
if isinstance(data, bytes):
text = data.decode("utf-8")
else:
text = str(data)
print("Fetched page content:")
print(text)
done_event.set()
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())
if not done_event.wait(timeout=30):
print("Timed out waiting for page", file=sys.stderr)
sys.exit(1)
print("Done fetching page.")
sys.exit(0)