From 7b27e461d991baa44f2ec75beb44eab1c816272d Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 28 Sep 2026 19:42:26 +0800 Subject: [PATCH] Add NAS Compose deployment with outbound-only named tunnel --- tools/geocaching/deploy/.env.example | 4 + tools/geocaching/deploy/.gitignore | 2 + tools/geocaching/deploy/COMPOSE.md | 91 ++++++++++++++++ tools/geocaching/deploy/Dockerfile | 13 +++ .../geocaching/deploy/Dockerfile.dockerignore | 9 ++ tools/geocaching/deploy/README.md | 2 + tools/geocaching/deploy/compose.yaml | 101 ++++++++++++++++++ 7 files changed, 222 insertions(+) create mode 100644 tools/geocaching/deploy/.env.example create mode 100644 tools/geocaching/deploy/.gitignore create mode 100644 tools/geocaching/deploy/COMPOSE.md create mode 100644 tools/geocaching/deploy/Dockerfile create mode 100644 tools/geocaching/deploy/Dockerfile.dockerignore create mode 100644 tools/geocaching/deploy/compose.yaml diff --git a/tools/geocaching/deploy/.env.example b/tools/geocaching/deploy/.env.example new file mode 100644 index 00000000..18ed3df1 --- /dev/null +++ b/tools/geocaching/deploy/.env.example @@ -0,0 +1,4 @@ +# Copy to .env. Keep the real token local; never commit it or paste it into chat. +TUNNEL_TOKEN= +DIRECTORY_NAME=Trail Mate public directory +SITE_ORIGIN=https://vicliu624.github.io diff --git a/tools/geocaching/deploy/.gitignore b/tools/geocaching/deploy/.gitignore new file mode 100644 index 00000000..bd8a08d5 --- /dev/null +++ b/tools/geocaching/deploy/.gitignore @@ -0,0 +1,2 @@ +.env +*.token diff --git a/tools/geocaching/deploy/COMPOSE.md b/tools/geocaching/deploy/COMPOSE.md new file mode 100644 index 00000000..c52564f4 --- /dev/null +++ b/tools/geocaching/deploy/COMPOSE.md @@ -0,0 +1,91 @@ +# NAS deployment without inbound ports + +This Compose project runs a persistent geocaching directory, a raw Reticulum WebSocket bridge, and a named Cloudflare Tunnel. It publishes no NAS ports. There is no host networking, Docker socket mount, certificate server or static cache export. + +The directory owns a private container network namespace. Bridge and tunnel share it, so `127.0.0.1:44242` and `127.0.0.1:8787` are internal to these containers, not the NAS host. Cloudflare terminates public TLS. Browser queries and responses pass through the tunnel in real time. + +## Prerequisites + +- Linux Docker Engine with Docker Compose v2.17 or later (for dependency restart propagation). Use the complete project; deploying the services separately loses their shared-network relationship. +- An amd64 or arm64 NAS able to pull the Python and Cloudflare images and install the pinned Python dependencies. +- A Cloudflare account and a domain that can be configured for a published application hostname. A DDNS hostname you cannot manage in Cloudflare is not automatically sufficient. +- Outbound DNS, HTTPS for image/dependency installation, TCP 7844 to Cloudflare, and TCP 4242 to the configured Reticulum peers. No inbound 80, 443 or other router forwarding is required. + +The tunnel uses HTTP/2 over outbound TCP 7844, so UDP is not required. This is the connector transport; browser-facing WebSocket traffic still passes through it. Availability and latency must be checked from the NAS and actual visitors. + +## 1. Get the files + +For a new checkout on your NAS: + +```sh +git clone --branch codex/geocaching --single-branch https://github.com/vicliu624/trail-mate.git +cd trail-mate/tools/geocaching/deploy +cp .env.example .env +``` + +For an existing checkout, update `codex/geocaching` and enter the same deployment directory. The build context is the repository root; copying only `compose.yaml` is insufficient. The Dockerfile-specific ignore file sends only the service files and requirement files to the builder, excluding local keys, databases and firmware output. + +## 2. Create a named tunnel + +In Cloudflare's tunnel dashboard, create a remotely managed Cloudflared tunnel. Obtain its tunnel token and put it in `.env` as `TUNNEL_TOKEN=...`. This is the tunnel token, not an API key. Keep it local. + +Add a published application route: + +| Setting | Value | +|---|---| +| Public hostname | A hostname you control, such as `geocaching.your-domain.example` | +| Path | Leave empty | +| Service type | **HTTP** | +| Service URL | **127.0.0.1:8787** | + +Use HTTP for the internal service, not Cloudflare's arbitrary TCP application mode. The bridge already speaks WebSocket over HTTP. The resulting browser endpoint is `wss://geocaching.your-domain.example/`. Do not put an interactive Access login in front of this public endpoint: the map worker cannot complete that login flow. + +Leave `SITE_ORIGIN=https://vicliu624.github.io` for the current website. It is the origin, not the full `/trail-mate/geocaching/` URL. `DIRECTORY_NAME` must fit in 40 UTF-8 bytes. The existing `reticulum/config` provides three editable upstream TCP entries and one loopback-only listener. + +## 3. Build and start + +```sh +docker compose config --quiet +docker compose build directory +docker compose up -d --no-build +docker compose ps +docker compose logs --tail=60 directory bridge tunnel +``` + +The first build installs the service dependencies; it does not compile Trail Mate firmware. Both Python services use the same local image. Container logs rotate at 5 MB, retaining at most three files per service. + +Expected milestones: + +- Directory emits JSON with `event: "ready"`, `delivery`, and **`discovery`** addresses. +- Bridge emits `event: "bridge_ready"`. +- Cloudflared reports a registered tunnel connection, and the dashboard shows the connector online. + +The directory health check establishes a local TCP connection. The bridge check performs a local WebSocket handshake with the allowed Origin. These checks establish local readiness only; they do not prove public directory discovery, author verification or successful spatial queries. Docker restarts exited services, but does not restart a running process merely because its health status changes. + +Send the operator the public **WSS URL** and the **discovery** value from the directory's ready log. Do not send the tunnel token, private identity file or database. The operator then updates `site/geocaching/network.json` and, where needed, device discovery seeds. That initial configuration deployment is separate from publishing cache data. Later cache publications and updates require no website deployment. + +## Persistence and upgrades + +The named volumes `trail-mate-geocaching_directory-data` and `trail-mate-geocaching_reticulum-data` preserve the directory identity, database, LXMF state and Reticulum state. A new empty deployment creates a new directory identity and an empty catalogue. It does not automatically restore previously published records whose only source is offline. + +The database, requests, snapshots and records are not shared with a browser build. To retain an existing directory identity and content, migrate its complete stopped service state deliberately before starting this deployment; do not overwrite a running volume. + +Upgrade all three services together because bridge and tunnel share the directory container's network namespace: + +```sh +docker compose down +git pull --ff-only +docker compose build directory +docker compose pull tunnel +docker compose up -d --no-build +``` + +`down` retains named volumes. **Do not add `--volumes` or `-v`** unless intentionally deleting all saved directory data and identity. Use NAS volume backup tooling while services are stopped for a consistent backup; identity material is private. The Python service user is UID/GID 10001. Replacing named volumes with NAS bind mounts requires granting that user write access. + +## Verification and limits + +Compose configuration is validated before release. A live Docker build/run and end-to-end public query still need validation on the NAS; the development machine's Linux Docker engine was unavailable when this deployment was prepared. + +After startup, verify a live query, exact signed detail and GPX download. Then publish a new revision and query again without rebuilding or deploying the website. Restart the NAS containers and verify the directory identity and data survive. The bridge admits 32 concurrent clients; this is a connection limit, not a tested NAS capacity guarantee. + +Reference: [Cloudflare setup](https://developers.cloudflare.com/tunnel/get-started/), [outbound firewall requirements](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/tunnel-with-firewall/), [tunnel run parameters](https://developers.cloudflare.com/tunnel/reference/run-parameters/). diff --git a/tools/geocaching/deploy/Dockerfile b/tools/geocaching/deploy/Dockerfile new file mode 100644 index 00000000..dcc90eda --- /dev/null +++ b/tools/geocaching/deploy/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HOME=/var/lib/trail-mate +WORKDIR /app +COPY tools/geocaching/interop-requirements.txt tools/geocaching/bridge-requirements.txt /app/ +RUN pip install --no-cache-dir -r interop-requirements.txt -r bridge-requirements.txt \ + && groupadd --gid 10001 trailmate \ + && useradd --uid 10001 --gid 10001 --no-create-home --home-dir /var/lib/trail-mate trailmate \ + && mkdir -p /var/lib/trail-mate/directory /var/lib/trail-mate/reticulum \ + && chown -R 10001:10001 /var/lib/trail-mate +COPY tools/geocaching/directory_service.py tools/geocaching/directory_store.py tools/geocaching/directory_sync.py tools/geocaching/packet_bridge.py /app/ +USER 10001:10001 +CMD ["python", "directory_service.py", "--help"] diff --git a/tools/geocaching/deploy/Dockerfile.dockerignore b/tools/geocaching/deploy/Dockerfile.dockerignore new file mode 100644 index 00000000..43b02069 --- /dev/null +++ b/tools/geocaching/deploy/Dockerfile.dockerignore @@ -0,0 +1,9 @@ +** +!tools/ +!tools/geocaching/ +!tools/geocaching/interop-requirements.txt +!tools/geocaching/bridge-requirements.txt +!tools/geocaching/directory_service.py +!tools/geocaching/directory_store.py +!tools/geocaching/directory_sync.py +!tools/geocaching/packet_bridge.py diff --git a/tools/geocaching/deploy/README.md b/tools/geocaching/deploy/README.md index 9fbf184b..02ba5277 100644 --- a/tools/geocaching/deploy/README.md +++ b/tools/geocaching/deploy/README.md @@ -1,5 +1,7 @@ # 多入口部署 +NAS 无需开放入站端口的 Docker Compose 部署见 [COMPOSE.md](COMPOSE.md),配置文件为 [compose.yaml](compose.yaml)。 + 网页自动连接我们维护的 WSS 服务。服务器上的一个 Reticulum 实例同时连接多个 公共 TCP 入口,并承载持久寻宝目录。普通访客没有网络设置界面。 diff --git a/tools/geocaching/deploy/compose.yaml b/tools/geocaching/deploy/compose.yaml new file mode 100644 index 00000000..ec756a88 --- /dev/null +++ b/tools/geocaching/deploy/compose.yaml @@ -0,0 +1,101 @@ +name: trail-mate-geocaching + +x-runtime: &runtime + image: trail-mate-geocaching:local + build: + context: ../../.. + dockerfile: tools/geocaching/deploy/Dockerfile + restart: unless-stopped + init: true + read_only: true + tmpfs: + - /tmp:size=32m,mode=1777 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" + +services: + directory: + <<: *runtime + command: + - python + - directory_service.py + - --state + - /var/lib/trail-mate/directory + - --rns-config + - /var/lib/trail-mate/reticulum + - --name + - ${DIRECTORY_NAME:-Trail Mate public directory} + volumes: + - directory-data:/var/lib/trail-mate/directory + - reticulum-data:/var/lib/trail-mate/reticulum + - ./reticulum/config:/var/lib/trail-mate/reticulum/config:ro + healthcheck: + test: [CMD, python, -c, "import socket; socket.create_connection(('127.0.0.1',44242),3).close()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + stop_grace_period: 40s + + bridge: + <<: *runtime + network_mode: service:directory + depends_on: + directory: + condition: service_healthy + restart: true + command: + - python + - packet_bridge.py + - --listen + - 127.0.0.1 + - --port + - "8787" + - --tcp-host + - 127.0.0.1 + - --tcp-port + - "44242" + - --origin + - ${SITE_ORIGIN:-https://vicliu624.github.io} + - --max-clients + - "32" + healthcheck: + test: [CMD, python, -c, "import os; from websockets.sync.client import connect; c=connect('ws://127.0.0.1:8787',origin=os.environ['SITE_ORIGIN'],open_timeout=3); c.close()"] + interval: 60s + timeout: 5s + retries: 3 + start_period: 10s + environment: + SITE_ORIGIN: ${SITE_ORIGIN:-https://vicliu624.github.io} + + tunnel: + image: cloudflare/cloudflared:2026.9.3 + restart: unless-stopped + network_mode: service:directory + depends_on: + directory: + condition: service_healthy + restart: true + bridge: + condition: service_healthy + restart: true + command: [tunnel, --no-autoupdate, --protocol, http2, run] + environment: + TUNNEL_TOKEN: ${TUNNEL_TOKEN:?Set the named Cloudflare tunnel token in .env} + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" + +volumes: + directory-data: + reticulum-data: