Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0621facc7d
|
|||
|
50cbfed5fa
|
|||
|
36d9a3350b
|
|||
|
515a9d9dbf
|
|||
|
3c27b4f9b8
|
|||
|
851c8c05d4
|
|||
|
8002a75e26
|
|||
|
06e6b55ecc
|
|||
|
48e47bd0bd
|
|||
|
9c074a0333
|
|||
|
f2314f862c
|
|||
|
6e57536650
|
|||
|
5fd7551874
|
|||
|
62d592c4d0
|
|||
|
8af2a9abbb
|
|||
|
64ca8bd4d2
|
|||
|
f1d025bd0e
|
|||
|
087ff563a2
|
|||
|
882dacf2bb
|
|||
|
a2efdb136a
|
|||
|
001613b4fa
|
|||
|
74564d0ef2
|
|||
|
81142ad194
|
|||
|
fee1d2e2d6
|
|||
|
7c93fdb71d
|
|||
| 9e435eeebc | |||
| 5dfcc1f2ce | |||
| 2def60b457 | |||
| f708ad4ee1 | |||
| f7568d81aa | |||
| 251f9bacef | |||
| 07892dbfee | |||
| 54e6849968 | |||
| ea27c380cb | |||
|
|
a338be85e1 | ||
|
|
e31cb3418b | ||
|
|
798725dca6 | ||
|
|
6f393497f0 | ||
|
|
14b5aabf2b | ||
| fb36907447 | |||
| 62fde2617b | |||
| 9f5ea23eb7 | |||
| 19fad61706 | |||
| c900cf38c9 | |||
| 014ebc25c6 | |||
|
|
d5e9308fb5 | ||
|
|
7d5e891261 | ||
|
|
c382ed790f | ||
| cb72e57da9 | |||
|
|
aaf5ad23e2 | ||
|
|
ce1b1dad7d | ||
|
|
67ebc7e556 | ||
|
|
b31fb748b8 |
8
.github/workflows/docker-test.yml
vendored
8
.github/workflows/docker-test.yml
vendored
@@ -15,13 +15,13 @@ 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@v4
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
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 }}
|
||||
|
||||
19
.github/workflows/docker.yml
vendored
19
.github/workflows/docker.yml
vendored
@@ -20,18 +20,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -51,9 +51,10 @@ jobs:
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
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 }}
|
||||
@@ -63,7 +64,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (rootless)
|
||||
id: meta_rootless
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-rootless
|
||||
tags: |
|
||||
@@ -74,10 +75,10 @@ jobs:
|
||||
type=sha,format=short,suffix=-rootless
|
||||
|
||||
- name: Build and push rootless Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
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 }}
|
||||
|
||||
14
.github/workflows/publish.yml
vendored
14
.github/workflows/publish.yml
vendored
@@ -23,11 +23,11 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5.3.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install pypa/build
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python3 -m build
|
||||
- name: Store the distribution packages
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
@@ -55,12 +55,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Publish distribution π¦ to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.12.3
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
|
||||
|
||||
github-release:
|
||||
name: Sign the Python π distribution π¦ and create GitHub Release
|
||||
@@ -73,12 +73,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Sign the dists with Sigstore
|
||||
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
||||
uses: sigstore/gh-action-sigstore-python@f7ad0af51a5648d09a20d00370f0a91c3bdf8f84 # v3.0.1
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
|
||||
17
.github/workflows/safety.yml
vendored
Normal file
17
.github/workflows/safety.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Safety
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # weekly
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@main
|
||||
- name: Run Safety CLI to check for vulnerabilities
|
||||
uses: pyupio/safety-action@7baf6605473beffc874c1313ddf2db085c0cacf2 # v1
|
||||
with:
|
||||
api-key: ${{ secrets.SAFETY_API_KEY }}
|
||||
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
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,3 +3,12 @@ node-config/
|
||||
files/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
15
Makefile
15
Makefile
@@ -2,19 +2,20 @@
|
||||
|
||||
# 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
|
||||
|
||||
all: build
|
||||
|
||||
build: clean
|
||||
python3 setup.py sdist bdist_wheel
|
||||
python3 -m build
|
||||
|
||||
sdist:
|
||||
python3 setup.py sdist
|
||||
python3 -m build --sdist
|
||||
|
||||
wheel:
|
||||
python3 setup.py bdist_wheel
|
||||
python3 -m build --wheel
|
||||
|
||||
clean:
|
||||
rm -rf build dist *.egg-info
|
||||
@@ -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:
|
||||
|
||||
85
README.md
85
README.md
@@ -1,31 +1,82 @@
|
||||
# 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
|
||||
|
||||
- Serves pages and files over RNS
|
||||
- Dynamic page support with environment variables
|
||||
- Form data and request parameter parsing
|
||||
|
||||
## 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
|
||||
|
||||
# Pipx via Git
|
||||
|
||||
pipx install git+https://github.com/Sudo-Ivan/rns-page-node.git
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# will use current directory for pages and files
|
||||
rns-page-node
|
||||
```
|
||||
|
||||
## Usage
|
||||
or with command-line options:
|
||||
|
||||
```bash
|
||||
rns-page-node --node-name "Page Node" --pages-dir ./pages --files-dir ./files --identity-dir ./node-config --announce-interval 360
|
||||
```
|
||||
|
||||
or with a config file:
|
||||
|
||||
```bash
|
||||
rns-page-node /path/to/config.conf
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
You can use a configuration file to persist settings. See `config.example` for an example.
|
||||
|
||||
Config file format is simple `key=value` pairs:
|
||||
|
||||
```
|
||||
# Comment lines start with #
|
||||
node-name=My Page Node
|
||||
pages-dir=./pages
|
||||
files-dir=./files
|
||||
identity-dir=./node-config
|
||||
announce-interval=360
|
||||
```
|
||||
|
||||
Priority order: Command-line arguments > Config file > Defaults
|
||||
|
||||
### Docker/Podman
|
||||
|
||||
```bash
|
||||
docker 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
|
||||
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 Rootless
|
||||
@@ -58,20 +109,30 @@ make docker-wheels
|
||||
|
||||
## Pages
|
||||
|
||||
Supports Micron `.mu` and dynamic pages with `#!` in the micron files.
|
||||
Supports dynamic executable pages with full request data parsing. Pages can receive:
|
||||
- Form fields via `field_*` environment variables
|
||||
- Link variables via `var_*` environment variables
|
||||
- Remote identity via `remote_identity` environment variable
|
||||
- Link ID via `link_id` environment variable
|
||||
|
||||
This enables forums, chats, and other interactive applications compatible with NomadNet clients.
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
-c, --config: The path to the Reticulum config file.
|
||||
-n, --node-name: The name of the node.
|
||||
-p, --pages-dir: The directory to serve pages from.
|
||||
-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.
|
||||
Positional arguments:
|
||||
node_config Path to rns-page-node config file
|
||||
|
||||
Optional arguments:
|
||||
-c, --config Path to the Reticulum config file
|
||||
-n, --node-name Name of the node
|
||||
-p, --pages-dir Directory to serve pages from
|
||||
-f, --files-dir Directory to serve files from
|
||||
-i, --identity-dir Directory to persist the node's identity
|
||||
-a, --announce-interval Interval to announce the node's presence (in minutes, default: 360 = 6 hours)
|
||||
--page-refresh-interval Interval to refresh pages (in seconds, 0 = disabled)
|
||||
--file-refresh-interval Interval to refresh files (in seconds, 0 = disabled)
|
||||
-l, --log-level Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
124
README.ru.md
Normal file
124
README.ru.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# RNS Page Node
|
||||
|
||||
[English](README.md)
|
||||
|
||||
ΠΡΠΎΡΡΠΎΠΉ ΡΠΏΠΎΡΠΎΠ± Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ² ΡΠ΅ΡΠ΅Π· ΡΠ΅ΡΡ [Reticulum](https://reticulum.network/). ΠΡΡΠΌΠ°Ρ Π·Π°ΠΌΠ΅Π½Π° Π΄Π»Ρ ΡΠ·Π»ΠΎΠ² [NomadNet](https://github.com/markqvist/NomadNet), ΠΊΠΎΡΠΎΡΡΠ΅ Π² ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΌ ΡΠ»ΡΠΆΠ°Ρ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ².
|
||||
|
||||
## ΠΡΠΎΠ±Π΅Π½Π½ΠΎΡΡΠΈ
|
||||
|
||||
- Π Π°Π·Π΄Π°ΡΠ° ΡΡΡΠ°Π½ΠΈΡ ΠΈ ΡΠ°ΠΉΠ»ΠΎΠ² ΡΠ΅ΡΠ΅Π· RNS
|
||||
- ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Π΄ΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΡ
ΡΡΡΠ°Π½ΠΈΡ Ρ ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΠΌΠΈ ΠΎΠΊΡΡΠΆΠ΅Π½ΠΈΡ
|
||||
- Π Π°Π·Π±ΠΎΡ Π΄Π°Π½Π½ΡΡ
ΡΠΎΡΠΌ ΠΈ ΠΏΠ°ΡΠ°ΠΌΠ΅ΡΡΠΎΠ² Π·Π°ΠΏΡΠΎΡΠΎΠ²
|
||||
|
||||
## Π£ΡΡΠ°Π½ΠΎΠ²ΠΊΠ°
|
||||
|
||||
```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
|
||||
|
||||
# Pipx ΡΠ΅ΡΠ΅Π· 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
|
||||
```
|
||||
|
||||
ΠΈΠ»ΠΈ Ρ ΡΠ°ΠΉΠ»ΠΎΠΌ ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ:
|
||||
```bash
|
||||
rns-page-node /ΠΏΡΡΡ/ΠΊ/config.conf
|
||||
```
|
||||
|
||||
### Π€Π°ΠΉΠ» ΠΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ
|
||||
|
||||
ΠΡ ΠΌΠΎΠΆΠ΅ΡΠ΅ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ ΡΠ°ΠΉΠ» ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ Π΄Π»Ρ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΡ Π½Π°ΡΡΡΠΎΠ΅ΠΊ. Π‘ΠΌ. `config.example` Π΄Π»Ρ ΠΏΡΠΈΠΌΠ΅ΡΠ°.
|
||||
|
||||
Π€ΠΎΡΠΌΠ°Ρ ΡΠ°ΠΉΠ»Π° ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ - ΠΏΡΠΎΡΡΡΠ΅ ΠΏΠ°ΡΡ `ΠΊΠ»ΡΡ=Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅`:
|
||||
|
||||
```
|
||||
# Π‘ΡΡΠΎΠΊΠΈ ΠΊΠΎΠΌΠΌΠ΅Π½ΡΠ°ΡΠΈΠ΅Π² Π½Π°ΡΠΈΠ½Π°ΡΡΡΡ Ρ #
|
||||
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
|
||||
```
|
||||
|
||||
## Π‘ΡΡΠ°Π½ΠΈΡΡ
|
||||
|
||||
ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Π΄ΠΈΠ½Π°ΠΌΠΈΡΠ΅ΡΠΊΠΈΡ
ΠΈΡΠΏΠΎΠ»Π½ΡΠ΅ΠΌΡΡ
ΡΡΡΠ°Π½ΠΈΡ Ρ ΠΏΠΎΠ»Π½ΡΠΌ ΡΠ°Π·Π±ΠΎΡΠΎΠΌ Π΄Π°Π½Π½ΡΡ
Π·Π°ΠΏΡΠΎΡΠΎΠ². Π‘ΡΡΠ°Π½ΠΈΡΡ ΠΌΠΎΠ³ΡΡ ΠΏΠΎΠ»ΡΡΠ°ΡΡ:
|
||||
- ΠΠΎΠ»Ρ ΡΠΎΡΠΌ ΡΠ΅ΡΠ΅Π· ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΠ΅ ΠΎΠΊΡΡΠΆΠ΅Π½ΠΈΡ `field_*`
|
||||
- ΠΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΠ΅ ΡΡΡΠ»ΠΎΠΊ ΡΠ΅ΡΠ΅Π· ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΠ΅ ΠΎΠΊΡΡΠΆΠ΅Π½ΠΈΡ `var_*`
|
||||
- Π£Π΄Π°Π»Π΅Π½Π½ΡΡ ΠΈΠ΄Π΅Π½ΡΠΈΡΠΈΠΊΠ°ΡΠΈΡ ΡΠ΅ΡΠ΅Π· ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΡ ΠΎΠΊΡΡΠΆΠ΅Π½ΠΈΡ `remote_identity`
|
||||
- ID ΡΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΡ ΡΠ΅ΡΠ΅Π· ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΡ ΠΎΠΊΡΡΠΆΠ΅Π½ΠΈΡ `link_id`
|
||||
|
||||
ΠΡΠΎ ΠΏΠΎΠ·Π²ΠΎΠ»ΡΠ΅Ρ ΡΠΎΠ·Π΄Π°Π²Π°ΡΡ ΡΠΎΡΡΠΌΡ, ΡΠ°ΡΡ ΠΈ Π΄ΡΡΠ³ΠΈΠ΅ ΠΈΠ½ΡΠ΅ΡΠ°ΠΊΡΠΈΠ²Π½ΡΠ΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ, ΡΠΎΠ²ΠΌΠ΅ΡΡΠΈΠΌΡΠ΅ Ρ ΠΊΠ»ΠΈΠ΅Π½ΡΠ°ΠΌΠΈ NomadNet.
|
||||
|
||||
## ΠΠ°ΡΠ°ΠΌΠ΅ΡΡΡ
|
||||
|
||||
```
|
||||
ΠΠΎΠ·ΠΈΡΠΈΠΎΠ½Π½ΡΠ΅ Π°ΡΠ³ΡΠΌΠ΅Π½ΡΡ:
|
||||
node_config ΠΡΡΡ ΠΊ ΡΠ°ΠΉΠ»Ρ ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ rns-page-node
|
||||
|
||||
ΠΠ΅ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΡΠ΅ Π°ΡΠ³ΡΠΌΠ΅Π½ΡΡ:
|
||||
-c, --config ΠΡΡΡ ΠΊ ΡΠ°ΠΉΠ»Ρ ΠΊΠΎΠ½ΡΠΈΠ³ΡΡΠ°ΡΠΈΠΈ Reticulum
|
||||
-n, --node-name ΠΠΌΡ ΡΠ·Π»Π°
|
||||
-p, --pages-dir ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΡΡΠ°Π½ΠΈΡ
|
||||
-f, --files-dir ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠ°Π·Π΄Π°ΡΠΈ ΡΠ°ΠΉΠ»ΠΎΠ²
|
||||
-i, --identity-dir ΠΠ°ΡΠ°Π»ΠΎΠ³ Π΄Π»Ρ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΡ ΠΈΠ΄Π΅Π½ΡΠΈΡΠΈΠΊΠ°ΡΠΈΠΎΠ½Π½ΡΡ
Π΄Π°Π½Π½ΡΡ
ΡΠ·Π»Π°
|
||||
-a, --announce-interval ΠΠ½ΡΠ΅ΡΠ²Π°Π» Π°Π½ΠΎΠ½ΡΠΈΡΠΎΠ²Π°Π½ΠΈΡ ΠΏΡΠΈΡΡΡΡΡΠ²ΠΈΡ ΡΠ·Π»Π° (Π² ΠΌΠΈΠ½ΡΡΠ°Ρ
, ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ: 360 = 6 ΡΠ°ΡΠΎΠ²)
|
||||
--page-refresh-interval ΠΠ½ΡΠ΅ΡΠ²Π°Π» ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΡΡΡΠ°Π½ΠΈΡ (Π² ΡΠ΅ΠΊΡΠ½Π΄Π°Ρ
, 0 = ΠΎΡΠΊΠ»ΡΡΠ΅Π½ΠΎ)
|
||||
--file-refresh-interval ΠΠ½ΡΠ΅ΡΠ²Π°Π» ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΡΠ°ΠΉΠ»ΠΎΠ² (Π² ΡΠ΅ΠΊΡΠ½Π΄Π°Ρ
, 0 = ΠΎΡΠΊΠ»ΡΡΠ΅Π½ΠΎ)
|
||||
-l, --log-level Π£ΡΠΎΠ²Π΅Π½Ρ Π»ΠΎΠ³ΠΈΡΠΎΠ²Π°Π½ΠΈΡ (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
```
|
||||
|
||||
## ΠΠΈΡΠ΅Π½Π·ΠΈΡ
|
||||
|
||||
ΠΡΠΎΡ ΠΏΡΠΎΠ΅ΠΊΡ Π²ΠΊΠ»ΡΡΠ°Π΅Ρ ΡΠ°ΡΡΠΈ ΠΊΠΎΠ΄ΠΎΠ²ΠΎΠΉ Π±Π°Π·Ρ [NomadNet](https://github.com/markqvist/NomadNet), ΠΊΠΎΡΠΎΡΠ°Ρ Π»ΠΈΡΠ΅Π½Π·ΠΈΡΠΎΠ²Π°Π½Π° ΠΏΠΎΠ΄ GNU General Public License v3.0 (GPL-3.0). ΠΠ°ΠΊ ΠΏΡΠΎΠΈΠ·Π²ΠΎΠ΄Π½Π°Ρ ΡΠ°Π±ΠΎΡΠ°, ΡΡΠΎΡ ΠΏΡΠΎΠ΅ΠΊΡ ΡΠ°ΠΊΠΆΠ΅ ΡΠ°ΡΠΏΡΠΎΡΡΡΠ°Π½ΡΠ΅ΡΡΡ Π½Π° ΡΡΠ»ΠΎΠ²ΠΈΡΡ
GPL-3.0. ΠΠΎΠ»Π½ΡΠΉ ΡΠ΅ΠΊΡΡ Π»ΠΈΡΠ΅Π½Π·ΠΈΠΈ ΡΠΌΠΎΡΡΠΈΡΠ΅ Π² ΡΠ°ΠΉΠ»Π΅ [LICENSE](LICENSE).
|
||||
31
config.example
Normal file
31
config.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# rns-page-node configuration file
|
||||
# Lines starting with # are comments
|
||||
# Format: key=value
|
||||
|
||||
# Reticulum config directory path
|
||||
# reticulum-config=/path/to/reticulum/config
|
||||
|
||||
# Node display name
|
||||
node-name=My Page Node
|
||||
|
||||
# Pages directory
|
||||
pages-dir=./pages
|
||||
|
||||
# Files directory
|
||||
files-dir=./files
|
||||
|
||||
# Node identity directory
|
||||
identity-dir=./node-config
|
||||
|
||||
# Announce interval in minutes (default: 360 = 6 hours)
|
||||
announce-interval=360
|
||||
|
||||
# Page refresh interval in seconds (0 = disabled)
|
||||
page-refresh-interval=300
|
||||
|
||||
# File refresh interval in seconds (0 = disabled)
|
||||
file-refresh-interval=300
|
||||
|
||||
# Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
log-level=INFO
|
||||
|
||||
1583
poetry.lock
generated
1583
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,24 @@
|
||||
[project]
|
||||
name = "rns-page-node"
|
||||
version = "1.0.0"
|
||||
version = "1.3.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.4,<1.5.0)"
|
||||
]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Sudo-Ivan/rns-page-node"
|
||||
Repository = "https://github.com/Sudo-Ivan/rns-page-node"
|
||||
|
||||
[project.scripts]
|
||||
rns-page-node = "rns_page_node.main:main"
|
||||
@@ -20,6 +28,4 @@ requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.12.3"
|
||||
safety = "^3.6.0"
|
||||
|
||||
ruff = "^0.13.3"
|
||||
|
||||
@@ -1 +1 @@
|
||||
rns=1.0.0
|
||||
rns=1.0.4
|
||||
@@ -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,22 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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 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,7 +24,49 @@ You are not authorised to carry out the request.
|
||||
"""
|
||||
|
||||
|
||||
def load_config(config_file):
|
||||
"""Load configuration from a plain text config file.
|
||||
|
||||
Config format is simple key=value pairs, one per line.
|
||||
Lines starting with # are comments and are ignored.
|
||||
Empty lines are ignored.
|
||||
|
||||
Args:
|
||||
config_file: Path to the config file
|
||||
|
||||
Returns:
|
||||
Dictionary of configuration values
|
||||
|
||||
"""
|
||||
config = {}
|
||||
try:
|
||||
with open(config_file, encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
RNS.log(
|
||||
f"Invalid config line {line_num} in {config_file}: {line}",
|
||||
RNS.LOG_WARNING,
|
||||
)
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
config[key] = value
|
||||
RNS.log(f"Loaded configuration from {config_file}", RNS.LOG_INFO)
|
||||
except FileNotFoundError:
|
||||
RNS.log(f"Config file not found: {config_file}", RNS.LOG_ERROR)
|
||||
except Exception as e:
|
||||
RNS.log(f"Error reading config file {config_file}: {e}", RNS.LOG_ERROR)
|
||||
return config
|
||||
|
||||
|
||||
class PageNode:
|
||||
"""A Reticulum page node that serves .mu pages and files over RNS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity,
|
||||
@@ -36,15 +77,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: Minutes between announcements (default: 360) == 6 hours
|
||||
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
|
||||
@@ -59,18 +115,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,
|
||||
@@ -78,7 +138,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,
|
||||
@@ -87,12 +149,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,
|
||||
@@ -102,76 +169,126 @@ 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_map = {}
|
||||
if "PATH" in os.environ:
|
||||
env_map["PATH"] = os.environ["PATH"]
|
||||
if _link_id is not None:
|
||||
env_map["link_id"] = RNS.hexrep(_link_id, delimit=False)
|
||||
if remote_identity is not None:
|
||||
env_map["remote_identity"] = RNS.hexrep(
|
||||
remote_identity.hash,
|
||||
delimit=False,
|
||||
)
|
||||
if data is not None and isinstance(data, dict):
|
||||
for e in data:
|
||||
if isinstance(e, str) and (
|
||||
e.startswith("field_") or e.startswith("var_")
|
||||
):
|
||||
env_map[e] = data[e]
|
||||
result = subprocess.run( # noqa: S603
|
||||
[str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
env=env_map,
|
||||
)
|
||||
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:
|
||||
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 * 60:
|
||||
if self.name:
|
||||
self.destination.announce(app_data=self.name.encode("utf-8"))
|
||||
else:
|
||||
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:
|
||||
@@ -190,52 +307,68 @@ 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
|
||||
"node_config",
|
||||
nargs="?",
|
||||
help="Path to rns-page-node config file",
|
||||
default=None,
|
||||
)
|
||||
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"),
|
||||
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",
|
||||
"--announce-interval",
|
||||
dest="announce_interval",
|
||||
type=int,
|
||||
help="Announce interval in seconds",
|
||||
help="Announce interval in minutes",
|
||||
default=360,
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -243,7 +376,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",
|
||||
@@ -269,30 +402,58 @@ def main():
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
configpath = args.configpath
|
||||
pages_dir = args.pages_dir
|
||||
files_dir = args.files_dir
|
||||
node_name = args.node_name
|
||||
announce_interval = args.announce_interval
|
||||
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"
|
||||
config = {}
|
||||
if args.node_config:
|
||||
config = load_config(args.node_config)
|
||||
|
||||
def get_config_value(arg_value, arg_default, config_key, value_type=str):
|
||||
"""Get value from CLI args, config file, or default.
|
||||
|
||||
Priority: CLI arg > config file > default
|
||||
"""
|
||||
if arg_value != arg_default:
|
||||
return arg_value
|
||||
if config_key in config:
|
||||
try:
|
||||
if value_type == int:
|
||||
return int(config[config_key])
|
||||
return config[config_key]
|
||||
except ValueError:
|
||||
RNS.log(
|
||||
f"Invalid {value_type.__name__} value for {config_key}: {config[config_key]}",
|
||||
RNS.LOG_WARNING,
|
||||
)
|
||||
return arg_default
|
||||
|
||||
configpath = get_config_value(args.configpath, None, "reticulum-config")
|
||||
pages_dir = get_config_value(args.pages_dir, str(Path.cwd() / "pages"), "pages-dir")
|
||||
files_dir = get_config_value(args.files_dir, str(Path.cwd() / "files"), "files-dir")
|
||||
node_name = get_config_value(args.node_name, None, "node-name")
|
||||
announce_interval = get_config_value(
|
||||
args.announce_interval, 360, "announce-interval", int,
|
||||
)
|
||||
identity_dir = get_config_value(
|
||||
args.identity_dir, str(Path.cwd() / "node-config"), "identity-dir",
|
||||
)
|
||||
page_refresh_interval = get_config_value(
|
||||
args.page_refresh_interval, 0, "page-refresh-interval", int,
|
||||
)
|
||||
file_refresh_interval = get_config_value(
|
||||
args.file_refresh_interval, 0, "file-refresh-interval", int,
|
||||
)
|
||||
log_level = get_config_value(args.log_level, "INFO", "log-level")
|
||||
|
||||
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,
|
||||
@@ -303,13 +464,14 @@ def main():
|
||||
page_refresh_interval,
|
||||
file_refresh_interval,
|
||||
)
|
||||
logger.info("Page node running. Press Ctrl-C to exit.")
|
||||
RNS.log("Page node running. Press Ctrl-C to exit.", RNS.LOG_INFO)
|
||||
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()
|
||||
|
||||
|
||||
|
||||
31
setup.py
31
setup.py
@@ -1,31 +0,0 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
with open("README.md", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
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",
|
||||
packages=find_packages(),
|
||||
license="GPL-3.0",
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"rns>=1.0.0,<1.5.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"rns-page-node=rns_page_node.main:main",
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
)
|
||||
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
|
||||
|
||||
@@ -20,7 +20,11 @@ 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"
|
||||
server_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
"nomadnetwork",
|
||||
"node",
|
||||
)
|
||||
|
||||
# Ensure we know a path to the destination
|
||||
@@ -36,6 +40,18 @@ 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_dict2 = {
|
||||
"field_username": "testuser",
|
||||
"field_message": "hello_from_form",
|
||||
"var_action": "submit",
|
||||
}
|
||||
|
||||
|
||||
# Callback for page response
|
||||
def on_page(response):
|
||||
@@ -44,10 +60,45 @@ 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 second dict data
|
||||
def on_page_dict2(response):
|
||||
data = response.response
|
||||
if isinstance(data, bytes):
|
||||
text = data.decode("utf-8")
|
||||
else:
|
||||
text = str(data)
|
||||
print("Received page (dict2 data):")
|
||||
print(text)
|
||||
responses["page_dict2"] = text
|
||||
check_responses()
|
||||
|
||||
|
||||
def check_responses():
|
||||
if (
|
||||
"page" in responses
|
||||
and "page_dict" in responses
|
||||
and "page_dict2" in responses
|
||||
and "file" in responses
|
||||
):
|
||||
done_event.set()
|
||||
|
||||
|
||||
@@ -78,27 +129,100 @@ 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 var_ prefixed data)
|
||||
link.request("/page/index.mu", test_data_dict, response_callback=on_page_dict)
|
||||
# Test page with form field data (simulates field_ prefixed data)
|
||||
link.request("/page/index.mu", test_data_dict2, response_callback=on_page_dict2)
|
||||
# Test file serving
|
||||
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())
|
||||
global_link.set_link_closed_callback(lambda link: 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!")
|
||||
|
||||
# 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 second dictionary data (form fields)
|
||||
if "page_dict2" not in responses:
|
||||
print("ERROR: No dict2 data page response received", file=sys.stderr)
|
||||
return False
|
||||
|
||||
dict2_content = responses["page_dict2"]
|
||||
if "field_username" not in dict2_content or "testuser" not in dict2_content:
|
||||
print(
|
||||
"ERROR: Dict2 data page should contain processed environment variables",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if "33aff86b736acd47dca07e84630fd192" not in dict2_content:
|
||||
print(
|
||||
"ERROR: Dict2 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)
|
||||
|
||||
@@ -34,7 +34,11 @@ 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"
|
||||
server_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
"nomadnetwork",
|
||||
"node",
|
||||
)
|
||||
link = RNS.Link(destination)
|
||||
|
||||
@@ -53,9 +57,9 @@ def on_page(response):
|
||||
|
||||
|
||||
link.set_link_established_callback(
|
||||
lambda l: l.request("/page/index.mu", None, response_callback=on_page)
|
||||
lambda link: link.request("/page/index.mu", None, response_callback=on_page),
|
||||
)
|
||||
link.set_link_closed_callback(lambda l: done_event.set())
|
||||
link.set_link_closed_callback(lambda link: done_event.set())
|
||||
|
||||
if not done_event.wait(timeout=30):
|
||||
print("Timed out waiting for page", file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user