Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a2efdb136a
|
|||
|
001613b4fa
|
|||
|
74564d0ef2
|
|||
|
81142ad194
|
|||
|
fee1d2e2d6
|
|||
|
7c93fdb71d
|
|||
| 9e435eeebc | |||
| 5dfcc1f2ce | |||
| 2def60b457 | |||
| f708ad4ee1 | |||
| f7568d81aa | |||
| 251f9bacef | |||
| 07892dbfee | |||
| 54e6849968 | |||
| ea27c380cb | |||
|
|
a338be85e1 | ||
|
|
e31cb3418b | ||
|
|
798725dca6 | ||
|
|
6f393497f0 | ||
|
|
14b5aabf2b | ||
| fb36907447 | |||
| 62fde2617b | |||
| 9f5ea23eb7 | |||
| 19fad61706 |
4
.github/workflows/docker-test.yml
vendored
4
.github/workflows/docker-test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
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 }}
|
||||
run: docker build . --file docker/Dockerfile --build-arg PYTHON_VERSION=${{ matrix.python-version }} --tag lxmfy-test:${{ matrix.python-version }}
|
||||
|
||||
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
@@ -54,6 +54,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -77,7 +78,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.rootless
|
||||
file: ./docker/Dockerfile.rootless
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta_rootless.outputs.tags }}
|
||||
|
||||
44
.github/workflows/tests.yml
vendored
Normal file
44
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd tests
|
||||
chmod +x run_tests.sh
|
||||
timeout 120 ./run_tests.sh
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-logs-python-${{ matrix.python-version }}
|
||||
path: tests/node.log
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -3,3 +3,14 @@ node-config/
|
||||
files/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
microvm/
|
||||
9
Makefile
9
Makefile
@@ -2,6 +2,7 @@
|
||||
|
||||
# Detect if docker buildx is available
|
||||
DOCKER_BUILD := $(shell docker buildx version >/dev/null 2>&1 && echo "docker buildx build" || echo "docker build")
|
||||
DOCKER_BUILD_LOAD := $(shell docker buildx version >/dev/null 2>&1 && echo "docker buildx build --load" || 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
|
||||
|
||||
@@ -29,13 +30,13 @@ format:
|
||||
ruff check --fix .
|
||||
|
||||
docker-wheels:
|
||||
$(DOCKER_BUILD) --target builder -f Dockerfile.build -t rns-page-node-builder .
|
||||
$(DOCKER_BUILD) --target builder -f docker/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) $(BUILD_ARGS) -f Dockerfile -t rns-page-node:latest .
|
||||
$(DOCKER_BUILD_LOAD) $(BUILD_ARGS) -f docker/Dockerfile -t rns-page-node:latest .
|
||||
|
||||
docker-run:
|
||||
docker run --rm -it \
|
||||
@@ -50,7 +51,7 @@ docker-run:
|
||||
--announce-interval 360
|
||||
|
||||
docker-build-rootless:
|
||||
$(DOCKER_BUILD) $(BUILD_ARGS) -f Dockerfile.rootless -t rns-page-node-rootless:latest .
|
||||
$(DOCKER_BUILD_LOAD) $(BUILD_ARGS) -f docker/Dockerfile.rootless -t rns-page-node-rootless:latest .
|
||||
|
||||
docker-run-rootless:
|
||||
docker run --rm -it \
|
||||
@@ -68,7 +69,7 @@ test:
|
||||
bash tests/run_tests.sh
|
||||
|
||||
docker-test:
|
||||
$(DOCKER_BUILD) -f tests/Dockerfile.tests -t rns-page-node-tests .
|
||||
$(DOCKER_BUILD_LOAD) -f docker/Dockerfile.tests -t rns-page-node-tests .
|
||||
docker run --rm rns-page-node-tests
|
||||
|
||||
help:
|
||||
|
||||
32
README.md
32
README.md
@@ -1,18 +1,48 @@
|
||||
# RNS Page Node
|
||||
|
||||
[Π ΡΡΡΠΊΠ°Ρ](README.ru.md)
|
||||
|
||||
[](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker.yml)
|
||||
[](https://github.com/Sudo-Ivan/rns-page-node/actions/workflows/docker-test.yml)
|
||||
[](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.
|
||||
|
||||
## Features
|
||||
|
||||
- Static and Dynamic pages.
|
||||
- Serve files
|
||||
- Simple
|
||||
|
||||
## To-Do
|
||||
|
||||
- Parameter parsing for forums, chat etc...
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Pip
|
||||
# May require --break-system-packages
|
||||
|
||||
pip install rns-page-node
|
||||
|
||||
# Pipx
|
||||
|
||||
pipx install rns-page-node
|
||||
|
||||
# uv
|
||||
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install rns-page-node
|
||||
|
||||
# Git
|
||||
|
||||
pipx install git+https://github.com/Sudo-Ivan/rns-page-node.git
|
||||
```
|
||||
|
||||
```bash
|
||||
# will use current directory for pages and files
|
||||
rns-page-node
|
||||
```
|
||||
|
||||
@@ -58,7 +88,7 @@ make docker-wheels
|
||||
|
||||
## Pages
|
||||
|
||||
Supports Micron `.mu` and dynamic pages with `#!` in the micron files.
|
||||
Supports dynamic pages but not request data parsing yet.
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
94
README.ru.md
Normal file
94
README.ru.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# RNS Page Node
|
||||
|
||||
ΠΡΠΎΡΡΠΎΠΉ ΡΠΏΠΎΡΠΎΠ± Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ² ΡΠ΅ΡΠ΅Π· ΡΠ΅ΡΡ [Reticulum](https://reticulum.network/). ΠΡΡΠΌΠ°Ρ Π·Π°ΠΌΠ΅Π½Π° Π΄Π»Ρ ΡΠ·Π»ΠΎΠ² [NomadNet](https://github.com/markqvist/NomadNet), ΠΊΠΎΡΠΎΡΡΠ΅ Π² ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΌ ΡΠ»ΡΠΆΠ°Ρ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ².
|
||||
|
||||
## ΠΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΠ΅
|
||||
|
||||
```bash
|
||||
# Pip
|
||||
# ΠΠΎΠΆΠ΅Ρ ΠΏΠΎΡΡΠ΅Π±ΠΎΠ²Π°ΡΡΡΡ --break-system-packages
|
||||
|
||||
pip install rns-page-node
|
||||
|
||||
# Pipx
|
||||
|
||||
pipx install rns-page-node
|
||||
|
||||
# uv
|
||||
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install rns-page-node
|
||||
|
||||
# Git
|
||||
|
||||
pipx install git+https://github.com/Sudo-Ivan/rns-page-node.git
|
||||
```
|
||||
|
||||
```bash
|
||||
# Π±ΡΠ΄Π΅Ρ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ ΡΠ΅ΠΊΡΡΠΈΠΉ ΠΊΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ²
|
||||
rns-page-node
|
||||
```
|
||||
|
||||
## ΠΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΠ΅
|
||||
|
||||
```bash
|
||||
rns-page-node --node-name "Page Node" --pages-dir ./pages --files-dir ./files --identity-dir ./node-config --announce-interval 360
|
||||
```
|
||||
|
||||
### Docker/Podman
|
||||
|
||||
```bash
|
||||
docker run -it --rm -v ./pages:/app/pages -v ./files:/app/files -v ./node-config:/app/node-config -v ./config:/root/.reticulum ghcr.io/sudo-ivan/rns-page-node:latest
|
||||
```
|
||||
|
||||
### Docker/Podman Π±Π΅Π· root
|
||||
|
||||
```bash
|
||||
mkdir -p ./pages ./files ./node-config ./config
|
||||
chown -R 1000:1000 ./pages ./files ./node-config ./config
|
||||
podman run -it --rm -v ./pages:/app/pages -v ./files:/app/files -v ./node-config:/app/node-config -v ./config:/app/config ghcr.io/sudo-ivan/rns-page-node:latest-rootless
|
||||
```
|
||||
|
||||
ΠΠΎΠ½ΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΡΠΎΠΌΠΎΠ² Π½Π΅ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎ, Π²Ρ ΡΠ°ΠΊΠΆΠ΅ ΠΌΠΎΠΆΠ΅ΡΠ΅ ΡΠΊΠΎΠΏΠΈΡΠΎΠ²Π°ΡΡ ΡΡΡΠ°Π½ΠΈΡΡ ΠΈ ΡΠ°ΠΉΠ»Ρ Π² ΠΊΠΎΠ½ΡΠ΅ΠΉΠ½Π΅Ρ Ρ ΠΏΠΎΠΌΠΎΡΡΡ `podman cp` ΠΈΠ»ΠΈ `docker cp`.
|
||||
|
||||
## Π‘Π±ΠΎΡΠΊΠ°
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Π‘Π±ΠΎΡΠΊΠ° wheels:
|
||||
|
||||
```bash
|
||||
make wheel
|
||||
```
|
||||
|
||||
### Π‘Π±ΠΎΡΠΊΠ° Wheels Π² Docker
|
||||
|
||||
```bash
|
||||
make docker-wheels
|
||||
```
|
||||
|
||||
## Π‘ΡΡΠ°Π½ΠΈΡΡ
|
||||
|
||||
ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°ΡΡΡΡ Π΄ΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΠ΅ ΡΡΡΠ°Π½ΠΈΡΡ, Π½ΠΎ ΠΏΠ°ΡΡΠΈΠ½Π³ Π΄Π°Π½Π½ΡΡ
Π·Π°ΠΏΡΠΎΡΠ° ΠΏΠΎΠΊΠ° Π½Π΅ ΡΠ΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½.
|
||||
|
||||
## ΠΠΏΡΠΈΠΈ
|
||||
|
||||
```
|
||||
-c, --config: ΠΡΡΡ ΠΊ ΡΠ°ΠΉΠ»Ρ ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ Reticulum.
|
||||
-n, --node-name: ΠΠΌΡ ΡΠ·Π»Π°.
|
||||
-p, --pages-dir: ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ.
|
||||
-f, --files-dir: ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΠ°ΠΉΠ»ΠΎΠ².
|
||||
-i, --identity-dir: ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΡ ΠΈΠ΄Π΅Π½ΡΠΈΡΠΈΠΊΠ°ΡΠΈΠΎΠ½Π½ΡΡ
Π΄Π°Π½Π½ΡΡ
ΡΠ·Π»Π°.
|
||||
-a, --announce-interval: ΠΠ½ΡΠ΅ΡΠ²Π°Π» Π°Π½ΠΎΠ½ΡΠΈΡΠΎΠ²Π°Π½ΠΈΡ ΠΏΡΠΈΡΡΡΡΡΠ²ΠΈΡ ΡΠ·Π»Π°.
|
||||
-r, --page-refresh-interval: ΠΠ½ΡΠ΅ΡΠ²Π°Π» ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΡΡΡΠ°Π½ΠΈΡ.
|
||||
-f, --file-refresh-interval: ΠΠ½ΡΠ΅ΡΠ²Π°Π» ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΡΠ°ΠΉΠ»ΠΎΠ².
|
||||
-l, --log-level: Π£ΡΠΎΠ²Π΅Π½Ρ Π»ΠΎΠ³ΠΈΡΠΎΠ²Π°Π½ΠΈΡ.
|
||||
```
|
||||
|
||||
## ΠΠΈΡΠ΅Π½Π·ΠΈΡ
|
||||
|
||||
ΠΡΠΎΡ ΠΏΡΠΎΠ΅ΠΊΡ Π²ΠΊΠ»ΡΡΠ°Π΅Ρ ΡΠ°ΡΡΠΈ ΠΊΠΎΠ΄ΠΎΠ²ΠΎΠΉ Π±Π°Π·Ρ [NomadNet](https://github.com/markqvist/NomadNet), ΠΊΠΎΡΠΎΡΠ°Ρ Π»ΠΈΡΠ΅Π½Π·ΠΈΡΠΎΠ²Π°Π½Π° ΠΏΠΎΠ΄ GNU General Public License v3.0 (GPL-3.0). ΠΠ°ΠΊ ΠΏΡΠΎΠΈΠ·Π²ΠΎΠ΄Π½Π°Ρ ΡΠ°Π±ΠΎΡΠ°, ΡΡΠΎΡ ΠΏΡΠΎΠ΅ΠΊΡ ΡΠ°ΠΊΠΆΠ΅ ΡΠ°ΡΠΏΡΠΎΡΡΡΠ°Π½ΡΠ΅ΡΡΡ Π½Π° ΡΡΠ»ΠΎΠ²ΠΈΡΡ
GPL-3.0. ΠΠΎΠ»Π½ΡΠΉ ΡΠ΅ΠΊΡΡ Π»ΠΈΡΠ΅Π½Π·ΠΈΠΈ ΡΠΌΠΎΡΡΠΈΡΠ΅ Π² ΡΠ°ΠΉΠ»Π΅ [LICENSE](LICENSE).
|
||||
|
||||
1370
poetry.lock
generated
1370
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
[project]
|
||||
name = "rns-page-node"
|
||||
version = "1.1.0"
|
||||
version = "1.2.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.10"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"rns (>=1.0.0,<1.5.0)"
|
||||
"rns (>=1.0.1,<1.5.0)"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -20,6 +20,4 @@ requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.12.12"
|
||||
safety = "^3.6.1"
|
||||
|
||||
ruff = "^0.13.3"
|
||||
|
||||
@@ -1 +1 @@
|
||||
rns=1.0.0
|
||||
rns=1.0.1
|
||||
@@ -1,2 +1,6 @@
|
||||
# rns_page_node package
|
||||
"""RNS Page Node package.
|
||||
|
||||
A minimal Reticulum page node that serves .mu pages and files over RNS.
|
||||
"""
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Minimal Reticulum Page Node
|
||||
"""Minimal Reticulum Page Node.
|
||||
|
||||
Serves .mu pages and files over RNS.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import RNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
@@ -25,6 +25,8 @@ You are not authorised to carry out the request.
|
||||
|
||||
|
||||
class PageNode:
|
||||
"""A Reticulum page node that serves .mu pages and files over RNS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity,
|
||||
@@ -35,15 +37,30 @@ class PageNode:
|
||||
page_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._lock = threading.Lock()
|
||||
self.logger = logging.getLogger(f"{__name__}.PageNode")
|
||||
self.identity = identity
|
||||
self.name = name
|
||||
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
|
||||
@@ -58,18 +75,22 @@ class PageNode:
|
||||
self.destination.set_link_established_callback(self.on_connect)
|
||||
|
||||
self._announce_thread = threading.Thread(
|
||||
target=self._announce_loop, daemon=True,
|
||||
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()
|
||||
|
||||
def register_pages(self):
|
||||
"""Scan pages directory and register request handlers for all .mu files."""
|
||||
with self._lock:
|
||||
self.servedpages = []
|
||||
self._scan_pages(self.pagespath)
|
||||
|
||||
if not os.path.isfile(os.path.join(self.pagespath, "index.mu")):
|
||||
pagespath = Path(self.pagespath)
|
||||
|
||||
if not (pagespath / "index.mu").is_file():
|
||||
self.destination.register_request_handler(
|
||||
"/page/index.mu",
|
||||
response_generator=self.serve_default_index,
|
||||
@@ -77,7 +98,9 @@ class PageNode:
|
||||
)
|
||||
|
||||
for full_path in self.servedpages:
|
||||
rel = full_path[len(self.pagespath) :]
|
||||
rel = full_path[len(str(pagespath)) :]
|
||||
if not rel.startswith("/"):
|
||||
rel = "/" + rel
|
||||
request_path = f"/page{rel}"
|
||||
self.destination.register_request_handler(
|
||||
request_path,
|
||||
@@ -86,12 +109,17 @@ class PageNode:
|
||||
)
|
||||
|
||||
def register_files(self):
|
||||
"""Scan files directory and register request handlers for all files."""
|
||||
with self._lock:
|
||||
self.servedfiles = []
|
||||
self._scan_files(self.filespath)
|
||||
|
||||
filespath = Path(self.filespath)
|
||||
|
||||
for full_path in self.servedfiles:
|
||||
rel = full_path[len(self.filespath) :]
|
||||
rel = full_path[len(str(filespath)) :]
|
||||
if not rel.startswith("/"):
|
||||
rel = "/" + rel
|
||||
request_path = f"/file{rel}"
|
||||
self.destination.register_request_handler(
|
||||
request_path,
|
||||
@@ -101,63 +129,138 @@ class PageNode:
|
||||
)
|
||||
|
||||
def _scan_pages(self, base):
|
||||
for entry in os.listdir(base):
|
||||
if entry.startswith("."):
|
||||
base_path = Path(base)
|
||||
for entry in base_path.iterdir():
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(base, entry)
|
||||
if os.path.isdir(path):
|
||||
self._scan_pages(path)
|
||||
elif os.path.isfile(path) and not entry.endswith(".allowed"):
|
||||
self.servedpages.append(path)
|
||||
if entry.is_dir():
|
||||
self._scan_pages(str(entry))
|
||||
elif entry.is_file() and not entry.name.endswith(".allowed"):
|
||||
self.servedpages.append(str(entry))
|
||||
|
||||
def _scan_files(self, base):
|
||||
for entry in os.listdir(base):
|
||||
if entry.startswith("."):
|
||||
base_path = Path(base)
|
||||
for entry in base_path.iterdir():
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(base, entry)
|
||||
if os.path.isdir(path):
|
||||
self._scan_files(path)
|
||||
elif os.path.isfile(path):
|
||||
self.servedfiles.append(path)
|
||||
if entry.is_dir():
|
||||
self._scan_files(str(entry))
|
||||
elif entry.is_file():
|
||||
self.servedfiles.append(str(entry))
|
||||
|
||||
@staticmethod
|
||||
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")
|
||||
|
||||
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,
|
||||
):
|
||||
file_path = path.replace("/page", self.pagespath, 1)
|
||||
"""Serve a .mu page file, executing it as a script if it has a shebang."""
|
||||
pagespath = Path(self.pagespath).resolve()
|
||||
relative_path = path[6:] if path.startswith("/page/") else path[5:]
|
||||
file_path = (pagespath / relative_path).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(pagespath)):
|
||||
return DEFAULT_NOTALLOWED.encode("utf-8")
|
||||
try:
|
||||
with open(file_path, "rb") as _f:
|
||||
with file_path.open("rb") as _f:
|
||||
first_line = _f.readline()
|
||||
is_script = first_line.startswith(b"#!")
|
||||
except Exception:
|
||||
is_script = False
|
||||
if is_script and os.access(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.
|
||||
if is_script and os.access(str(file_path), os.X_OK):
|
||||
try:
|
||||
result = subprocess.run([file_path], stdout=subprocess.PIPE, check=True) # noqa: S603
|
||||
env = os.environ.copy()
|
||||
if remote_identity:
|
||||
env["remote_identity"] = RNS.hexrep(
|
||||
remote_identity.hash,
|
||||
delimit=False,
|
||||
)
|
||||
if data:
|
||||
try:
|
||||
RNS.log(f"Processing request data: {data} (type: {type(data)})", RNS.LOG_DEBUG)
|
||||
if isinstance(data, dict):
|
||||
RNS.log(f"Data is dictionary with {len(data)} items", RNS.LOG_DEBUG)
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str):
|
||||
if key.startswith(("field_", "var_")):
|
||||
env[key] = value
|
||||
RNS.log(f"Set env[{key}] = {value}", RNS.LOG_DEBUG)
|
||||
elif key == "action":
|
||||
env["var_action"] = value
|
||||
RNS.log(f"Set env[var_action] = {value}", RNS.LOG_DEBUG)
|
||||
else:
|
||||
env[f"field_{key}"] = value
|
||||
RNS.log(f"Set env[field_{key}] = {value}", RNS.LOG_DEBUG)
|
||||
elif isinstance(data, bytes):
|
||||
data_str = data.decode("utf-8")
|
||||
RNS.log(f"Data is bytes, decoded to: {data_str}", RNS.LOG_DEBUG)
|
||||
if data_str:
|
||||
if "|" in data_str and "&" not in data_str:
|
||||
pairs = data_str.split("|")
|
||||
else:
|
||||
pairs = data_str.split("&")
|
||||
for pair in pairs:
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
if key.startswith(("field_", "var_")):
|
||||
env[key] = value
|
||||
elif key == "action":
|
||||
env["var_action"] = value
|
||||
else:
|
||||
env[f"field_{key}"] = value
|
||||
except Exception as e:
|
||||
RNS.log(f"Error parsing request data: {e}", RNS.LOG_ERROR)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
return result.stdout
|
||||
except Exception:
|
||||
self.logger.exception("Error executing script page")
|
||||
with open(file_path, "rb") as f:
|
||||
except Exception as e:
|
||||
RNS.log(f"Error executing script page: {e}", RNS.LOG_ERROR)
|
||||
with file_path.open("rb") as f:
|
||||
return f.read()
|
||||
|
||||
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,
|
||||
):
|
||||
file_path = path.replace("/file", self.filespath, 1)
|
||||
"""Serve a file from the files directory."""
|
||||
filespath = Path(self.filespath).resolve()
|
||||
relative_path = path[6:] if path.startswith("/file/") else path[5:]
|
||||
file_path = (filespath / relative_path).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(filespath)):
|
||||
return DEFAULT_NOTALLOWED.encode("utf-8")
|
||||
|
||||
return [
|
||||
open(file_path, "rb"),
|
||||
{"name": os.path.basename(file_path).encode("utf-8")},
|
||||
file_path.open("rb"),
|
||||
{"name": file_path.name.encode("utf-8")},
|
||||
]
|
||||
|
||||
def on_connect(self, link):
|
||||
pass
|
||||
"""Handle new link connections."""
|
||||
|
||||
def _announce_loop(self):
|
||||
try:
|
||||
@@ -169,8 +272,8 @@ class PageNode:
|
||||
self.destination.announce()
|
||||
self.last_announce = time.time()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
self.logger.exception("Error in announce loop")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in announce loop: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def _refresh_loop(self):
|
||||
try:
|
||||
@@ -189,45 +292,55 @@ class PageNode:
|
||||
self.register_files()
|
||||
self.last_file_refresh = now
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
self.logger.exception("Error in refresh loop")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in refresh loop: {e}", RNS.LOG_ERROR)
|
||||
|
||||
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()
|
||||
try:
|
||||
self._announce_thread.join(timeout=5)
|
||||
self._refresh_thread.join(timeout=5)
|
||||
except Exception:
|
||||
self.logger.exception("Error waiting for threads to shut down")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error waiting for threads to shut down: {e}", RNS.LOG_ERROR)
|
||||
try:
|
||||
if hasattr(self.destination, "close"):
|
||||
self.destination.close()
|
||||
except Exception:
|
||||
self.logger.exception("Error closing RNS destination")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error closing RNS destination: {e}", RNS.LOG_ERROR)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the RNS page node application."""
|
||||
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
|
||||
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(
|
||||
"-p",
|
||||
"--pages-dir",
|
||||
dest="pages_dir",
|
||||
help="Pages directory",
|
||||
default=os.path.join(os.getcwd(), "pages"),
|
||||
default=str(Path.cwd() / "pages"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--files-dir",
|
||||
dest="files_dir",
|
||||
help="Files directory",
|
||||
default=os.path.join(os.getcwd(), "files"),
|
||||
default=str(Path.cwd() / "files"),
|
||||
)
|
||||
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(
|
||||
"-a",
|
||||
@@ -242,7 +355,7 @@ def main():
|
||||
"--identity-dir",
|
||||
dest="identity_dir",
|
||||
help="Directory to store node identity",
|
||||
default=os.path.join(os.getcwd(), "node-config"),
|
||||
default=str(Path.cwd() / "node-config"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--page-refresh-interval",
|
||||
@@ -276,22 +389,18 @@ def main():
|
||||
identity_dir = args.identity_dir
|
||||
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",
|
||||
)
|
||||
|
||||
RNS.Reticulum(configpath)
|
||||
os.makedirs(identity_dir, exist_ok=True)
|
||||
identity_file = os.path.join(identity_dir, "identity")
|
||||
if os.path.isfile(identity_file):
|
||||
identity = RNS.Identity.from_file(identity_file)
|
||||
Path(identity_dir).mkdir(parents=True, exist_ok=True)
|
||||
identity_file = Path(identity_dir) / "identity"
|
||||
if identity_file.is_file():
|
||||
identity = RNS.Identity.from_file(str(identity_file))
|
||||
else:
|
||||
identity = RNS.Identity()
|
||||
identity.to_file(identity_file)
|
||||
identity.to_file(str(identity_file))
|
||||
|
||||
os.makedirs(pages_dir, exist_ok=True)
|
||||
os.makedirs(files_dir, exist_ok=True)
|
||||
Path(pages_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(files_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
node = PageNode(
|
||||
identity,
|
||||
@@ -302,15 +411,14 @@ def main():
|
||||
page_refresh_interval,
|
||||
file_refresh_interval,
|
||||
)
|
||||
logger.info("Page node running. Press Ctrl-C to exit.")
|
||||
logger.info("Node address: %s", RNS.prettyhexrep(node.destination.hash))
|
||||
|
||||
RNS.log("Page node running. Press Ctrl-C to exit.", RNS.LOG_INFO)
|
||||
RNS.log(f"Node address: {RNS.prettyhexrep(node.destination.hash)}", RNS.LOG_INFO)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received, shutting down...")
|
||||
RNS.log("Keyboard interrupt received, shutting down...", RNS.LOG_INFO)
|
||||
node.shutdown()
|
||||
|
||||
|
||||
|
||||
6
setup.py
6
setup.py
@@ -5,7 +5,7 @@ with open("README.md", encoding="utf-8") as fh:
|
||||
|
||||
setup(
|
||||
name="rns-page-node",
|
||||
version="1.0.0",
|
||||
version="1.2.0",
|
||||
author="Sudo-Ivan",
|
||||
author_email="",
|
||||
description="A simple way to serve pages and files over the Reticulum network.",
|
||||
@@ -14,9 +14,9 @@ setup(
|
||||
url="https://github.com/Sudo-Ivan/rns-page-node",
|
||||
packages=find_packages(),
|
||||
license="GPL-3.0",
|
||||
python_requires=">=3.10",
|
||||
python_requires=">=3.9",
|
||||
install_requires=[
|
||||
"rns>=1.0.0,<1.5.0",
|
||||
"rns>=1.0.1,<1.5.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
|
||||
28
tests/run_tests.sh
Normal file β Executable file
28
tests/run_tests.sh
Normal file β Executable file
@@ -9,11 +9,33 @@ rm -rf config node-config pages files node.log
|
||||
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.
|
||||
cat > pages/index.mu << 'EOF'
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
|
||||
print("`F0f0`_`Test Page`_")
|
||||
print("This is a test page with environment variable support.")
|
||||
print()
|
||||
|
||||
print("`F0f0`_`Environment Variables`_")
|
||||
params = []
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(('field_', 'var_')):
|
||||
params.append(f"- `Faaa`{key}`f: `F0f0`{value}`f")
|
||||
|
||||
if params:
|
||||
print("\n".join(params))
|
||||
else:
|
||||
print("- No parameters received")
|
||||
|
||||
print()
|
||||
print("`F0f0`_`Remote Identity`_")
|
||||
remote_id = os.environ.get('remote_identity', '33aff86b736acd47dca07e84630fd192') # Mock for testing
|
||||
print(f"`Faaa`{remote_id}`f")
|
||||
EOF
|
||||
|
||||
chmod +x pages/index.mu
|
||||
|
||||
cat > files/text.txt << EOF
|
||||
This is a test file.
|
||||
EOF
|
||||
|
||||
@@ -36,6 +36,14 @@ global_link = RNS.Link(destination)
|
||||
responses = {}
|
||||
done_event = threading.Event()
|
||||
|
||||
# Test data for environment variables
|
||||
test_data_dict = {
|
||||
'var_field_test': 'dictionary_value',
|
||||
'var_field_message': 'hello_world',
|
||||
'var_action': 'test_action'
|
||||
}
|
||||
test_data_bytes = b'field_bytes_test=bytes_value|field_bytes_message=test_bytes|action=bytes_action'
|
||||
|
||||
|
||||
# Callback for page response
|
||||
def on_page(response):
|
||||
@@ -44,10 +52,37 @@ def on_page(response):
|
||||
text = data.decode("utf-8")
|
||||
else:
|
||||
text = str(data)
|
||||
print("Received page:")
|
||||
print("Received page (no data):")
|
||||
print(text)
|
||||
responses["page"] = text
|
||||
if "file" in responses:
|
||||
check_responses()
|
||||
|
||||
# Callback for page response with dictionary data
|
||||
def on_page_dict(response):
|
||||
data = response.response
|
||||
if isinstance(data, bytes):
|
||||
text = data.decode("utf-8")
|
||||
else:
|
||||
text = str(data)
|
||||
print("Received page (dict data):")
|
||||
print(text)
|
||||
responses["page_dict"] = text
|
||||
check_responses()
|
||||
|
||||
# Callback for page response with bytes data
|
||||
def on_page_bytes(response):
|
||||
data = response.response
|
||||
if isinstance(data, bytes):
|
||||
text = data.decode("utf-8")
|
||||
else:
|
||||
text = str(data)
|
||||
print("Received page (bytes data):")
|
||||
print(text)
|
||||
responses["page_bytes"] = text
|
||||
check_responses()
|
||||
|
||||
def check_responses():
|
||||
if "page" in responses and "page_dict" in responses and "page_bytes" in responses and "file" in responses:
|
||||
done_event.set()
|
||||
|
||||
|
||||
@@ -78,13 +113,18 @@ def on_file(response):
|
||||
else:
|
||||
print("Received file (unhandled format):", data)
|
||||
responses["file"] = str(data)
|
||||
if "page" in responses:
|
||||
done_event.set()
|
||||
check_responses()
|
||||
|
||||
|
||||
# Request the page and file once the link is established
|
||||
# Request the pages and file once the link is established
|
||||
def on_link_established(link):
|
||||
# Test page without data
|
||||
link.request("/page/index.mu", None, response_callback=on_page)
|
||||
# Test page with dictionary data (simulates MeshChat)
|
||||
link.request("/page/index.mu", test_data_dict, response_callback=on_page_dict)
|
||||
# Test page with bytes data (URL-encoded style)
|
||||
link.request("/page/index.mu", test_data_bytes, response_callback=on_page_bytes)
|
||||
# Test file serving
|
||||
link.request("/file/text.txt", None, response_callback=on_file)
|
||||
|
||||
|
||||
@@ -97,8 +137,63 @@ 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!")
|
||||
# Validate test results
|
||||
def validate_test_results():
|
||||
"""Validate that all responses contain expected content"""
|
||||
|
||||
# Check basic page response (no data)
|
||||
if "page" not in responses:
|
||||
print("ERROR: No basic page response received", file=sys.stderr)
|
||||
return False
|
||||
|
||||
page_content = responses["page"]
|
||||
if "No parameters received" not in page_content:
|
||||
print("ERROR: Basic page should show 'No parameters received'", file=sys.stderr)
|
||||
return False
|
||||
if "33aff86b736acd47dca07e84630fd192" not in page_content:
|
||||
print("ERROR: Basic page should show mock remote identity", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Check page with dictionary data
|
||||
if "page_dict" not in responses:
|
||||
print("ERROR: No dictionary data page response received", file=sys.stderr)
|
||||
return False
|
||||
|
||||
dict_content = responses["page_dict"]
|
||||
if "var_field_test" not in dict_content or "dictionary_value" not in dict_content:
|
||||
print("ERROR: Dictionary data page should contain processed environment variables", file=sys.stderr)
|
||||
return False
|
||||
if "33aff86b736acd47dca07e84630fd192" not in dict_content:
|
||||
print("ERROR: Dictionary data page should show mock remote identity", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Check page with bytes data
|
||||
if "page_bytes" not in responses:
|
||||
print("ERROR: No bytes data page response received", file=sys.stderr)
|
||||
return False
|
||||
|
||||
bytes_content = responses["page_bytes"]
|
||||
if "field_bytes_test" not in bytes_content or "bytes_value" not in bytes_content:
|
||||
print("ERROR: Bytes data page should contain processed environment variables", file=sys.stderr)
|
||||
return False
|
||||
if "33aff86b736acd47dca07e84630fd192" not in bytes_content:
|
||||
print("ERROR: Bytes data page should show mock remote identity", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Check file response
|
||||
if "file" not in responses:
|
||||
print("ERROR: No file response received", file=sys.stderr)
|
||||
return False
|
||||
|
||||
file_content = responses["file"]
|
||||
if "This is a test file" not in file_content:
|
||||
print("ERROR: File content doesn't match expected content", file=sys.stderr)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if validate_test_results():
|
||||
print("All tests passed! Environment variable processing works correctly.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Tests failed.", file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user