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 - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -49,20 +54,33 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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 - name: Build and push rootless Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile.rootless file: ./Dockerfile.rootless
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}-rootless tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta_rootless.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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.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." 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 WORKDIR /app
COPY requirements.txt ./ RUN apk add --no-cache gcc python3-dev musl-dev linux-headers
COPY setup.py ./
RUN pip install poetry
ENV POETRY_VIRTUALENVS_IN_PROJECT=true
COPY pyproject.toml poetry.lock* ./
COPY README.md ./ COPY README.md ./
COPY rns_page_node ./rns_page_node 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,18 +1,18 @@
FROM python:3.13-alpine AS builder FROM python:3.13-alpine AS builder
RUN apk update 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 WORKDIR /src
COPY setup.py ./ RUN pip install poetry
COPY pyproject.toml ./
COPY README.md ./ COPY README.md ./
COPY rns_page_node ./rns_page_node COPY rns_page_node ./rns_page_node
RUN pip install --upgrade pip setuptools wheel RUN poetry build --format wheel
RUN pip wheel . --no-deps --wheel-dir /src/dist
FROM scratch AS dist FROM scratch AS dist
COPY --from=builder /src/dist . COPY --from=builder /src/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.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." 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 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 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 USER app
ENTRYPOINT ["rns-page-node"] ENTRYPOINT ["poetry", "run", "rns-page-node"]

View File

@@ -1,6 +1,9 @@
# Makefile for rns-page-node # 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 all: build
@@ -26,13 +29,13 @@ format:
ruff check --fix . ruff check --fix .
docker-wheels: 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 create --name builder-container rns-page-node-builder true
docker cp builder-container:/src/dist ./dist docker cp builder-container:/src/dist ./dist
docker rm builder-container docker rm builder-container
docker-build: 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:
docker run --rm -it \ docker run --rm -it \
@@ -47,7 +50,7 @@ docker-run:
--announce-interval 360 --announce-interval 360
docker-build-rootless: 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-rootless:
docker run --rm -it \ docker run --rm -it \
@@ -61,6 +64,13 @@ docker-run-rootless:
--identity-dir /app/node-config \ --identity-dir /app/node-config \
--announce-interval 360 --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: help:
@echo "Makefile commands:" @echo "Makefile commands:"
@echo " all - alias for build" @echo " all - alias for build"
@@ -75,4 +85,6 @@ help:
@echo " docker-build - build runtime Docker image" @echo " docker-build - build runtime Docker image"
@echo " docker-run - run runtime Docker image" @echo " docker-run - run runtime Docker image"
@echo " docker-build-rootless - build rootless runtime Docker image" @echo " docker-build-rootless - build rootless runtime Docker image"
@echo " docker-run-rootless - run 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 # 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. 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 ## Usage
```bash ```bash
pip install git+https://github.com/Sudo-Ivan/rns-page-node.git pip install rns-page-node
# or
pipx install git+https://github.com/Sudo-Ivan/rns-page-node.git
``` ```
```bash ```bash
@@ -56,9 +56,9 @@ make wheel
make docker-wheels make docker-wheels
``` ```
## Page formats ## Pages
- Micron `.mu` Supports Micron `.mu` and dynamic pages with `#!` in the micron files.
## Options ## Options
@@ -69,12 +69,11 @@ make docker-wheels
-f, --files-dir: The directory to serve files from. -f, --files-dir: The directory to serve files from.
-i, --identity-dir: The directory to persist the node's identity. -i, --identity-dir: The directory to persist the node's identity.
-a, --announce-interval: The interval to announce the node's presence. -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 ## 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. 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] [project]
name = "rns-page-node" name = "rns-page-node"
version = "0.1.2" version = "1.0.0"
license = {file = "LICENSE"} license = "GPL-3.0-only"
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."
authors = [ authors = [
{name = "Sudo-Ivan"} {name = "Sudo-Ivan"}
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.10"
dependencies = [ dependencies = [
"rns (>=0.9.6,<0.10.0)" "rns (>=1.0.0,<1.5.0)"
] ]
[project.scripts] [project.scripts]
@@ -20,5 +20,6 @@ requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies] [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 # 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 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: 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.1.2', version="1.0.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(),
python_requires='>=3.9', license="GPL-3.0",
python_requires=">=3.10",
install_requires=[ install_requires=[
'rns>=0.9.6,<0.10.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",
], ],
}, },
license='GPL-3.0',
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",
], ],
) )

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)