mirror of
https://github.com/MeshCore-Beacon/beacon-docs.git
synced 2026-10-23 00:41:24 +00:00
docs: reorganise into a deploy and operate front door (#12)
* docs: move design and API contract into docs/ * docs: add releases and versioning page * docs: add configuration reference for server and web * docs: add reverse proxy page * docs: add upgrading page * docs: add operations page and move backup and profiling docs * docs: add getting packets in and security pages * docs: add troubleshooting page * docs(api): fold admin, activity, route and summary notes into the contract * docs(deploy): add type 2 split deployment, finish the beacon rename, ignore secrets * docs: rewrite README as the front door and share the contributor workflow * docs: remove em dashes from prose and config comments * docs: apply review fixes to type 2, upgrading, troubleshooting and first-run notes * docs: fix upgrade order, restore steps, type 2 setup and contract details from review * docs: keep the gitignore to deployment secrets and runtime state
This commit is contained in:
1 parent
b38be34aad
commit
f2728b059b
35 files changed
+2033
-381
No files matched your search
+14
@@ -0,0 +1,14 @@
|
||||
# Deployment secrets and runtime state. Each deployment folder's .env is created from
|
||||
# app_config/.env.example; the data/ subfolders below are bind mounts the containers write to.
|
||||
docker-deployment-*/.env
|
||||
docker-deployment-*/*/.env
|
||||
docker-deployment-*/data/postgres/
|
||||
docker-deployment-*/data/redis/
|
||||
docker-deployment-*/data/Caddy/data/
|
||||
docker-deployment-*/data/Caddy/config/
|
||||
docker-deployment-*/data/Caddy/logs/
|
||||
docker-deployment-*/*/data/postgres/
|
||||
docker-deployment-*/*/data/redis/
|
||||
docker-deployment-*/*/data/Caddy/data/
|
||||
docker-deployment-*/*/data/Caddy/config/
|
||||
docker-deployment-*/*/data/Caddy/logs/
|
||||
+5
-5
@@ -25,7 +25,7 @@ Examples of behaviour that contributes to a positive environment:
|
||||
|
||||
Examples of unacceptable behaviour:
|
||||
|
||||
- Hate speech or discriminatory language or jokes of any kind — including but
|
||||
- Hate speech or discriminatory language or jokes of any kind, including but
|
||||
not limited to those targeting race, ethnicity, nationality, gender identity
|
||||
or expression, sexual orientation, disability, religion, age, or socioeconomic
|
||||
background
|
||||
@@ -34,7 +34,7 @@ Examples of unacceptable behaviour:
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment of any kind
|
||||
- Deliberate misgendering or use of rejected names
|
||||
- Publishing others' private information — such as a physical or email address —
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Threats of violence or incitement of violence toward any individual or group
|
||||
- Dismissing or minimising reports of harassment or discrimination
|
||||
@@ -55,15 +55,15 @@ when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces — GitHub issues, pull
|
||||
requests, discussions, and the MeshCore Canada Discord server — and also when an
|
||||
This Code of Conduct applies within all project spaces (GitHub issues, pull
|
||||
requests, discussions, and the MeshCore Canada Discord server) and also when an
|
||||
individual is representing the project in public spaces.
|
||||
|
||||
## Reporting
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
|
||||
reported by contacting the maintainers directly on the MeshCore Canada Discord
|
||||
server at https://discord.gg/Gz3KvJx2hf — reach out to **dedskelly** privately.
|
||||
server at https://discord.gg/Gz3KvJx2hf; reach out to **dedskelly** privately.
|
||||
|
||||
All reports will be handled with discretion and confidentiality. Maintainers are
|
||||
obligated to respect the privacy and safety of the reporter.
|
||||
|
||||
+118
-80
@@ -1,126 +1,164 @@
|
||||
# Contributing to Beacon Docs
|
||||
# Contributing to Beacon
|
||||
|
||||
Thank you for your interest in contributing. This repository holds the
|
||||
project-wide documentation and the ready-to-run Docker deployments for Beacon.
|
||||
The application code lives elsewhere:
|
||||
Thank you for your interest in contributing. This page is the shared workflow for all three
|
||||
Beacon repositories:
|
||||
|
||||
- [beacon-server](https://github.com/MeshCore-Beacon/beacon-server) — the API backend
|
||||
- [beacon-web](https://github.com/MeshCore-Beacon/beacon-web) — the web frontend
|
||||
- [beacon-docs](https://github.com/MeshCore-Beacon/beacon-docs), this repo: deployment,
|
||||
configuration and operator documentation.
|
||||
- [beacon-server](https://github.com/MeshCore-Beacon/beacon-server): the API and ingest. Its own
|
||||
[CONTRIBUTING.md](https://github.com/MeshCore-Beacon/beacon-server/blob/main/CONTRIBUTING.md)
|
||||
covers Go style, tests, database and API changes.
|
||||
- [beacon-web](https://github.com/MeshCore-Beacon/beacon-web): the frontend. Its own
|
||||
[CONTRIBUTING.md](https://github.com/MeshCore-Beacon/beacon-web/blob/main/CONTRIBUTING.md)
|
||||
covers TypeScript style, tests and the feature layout.
|
||||
|
||||
Please read this guide before opening a PR.
|
||||
|
||||
---
|
||||
Please read this before opening a PR anywhere.
|
||||
|
||||
## Before you start
|
||||
|
||||
**Issues are disabled on this repository.** To discuss a change before doing the
|
||||
work — a docs restructure, a new deployment type, a correction you're unsure
|
||||
about — reach out on the [MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf)
|
||||
first. For small fixes (typos, broken links, clarifications) just open a PR.
|
||||
**Talk first for anything bigger than a small fix.** On beacon-server and beacon-web, open or
|
||||
comment on an issue before starting work, so maintainers can say if something is already in
|
||||
progress or out of scope. Issues are disabled on beacon-docs; for a docs restructure, a new
|
||||
deployment type or a correction you are unsure about, reach out on the
|
||||
[MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf). For typos, broken links and small
|
||||
clarifications, just open a PR.
|
||||
|
||||
**One thing per PR.** Each pull request should cover one logical change — a fixed
|
||||
section, a new deployment type, a corrected variable table. PRs that touch many
|
||||
unrelated docs at once are hard to review.
|
||||
**One thing per PR.** Each pull request covers one logical change: a bug fix, a new endpoint, a
|
||||
corrected variable table. PRs that touch many unrelated things are hard to review and hard to
|
||||
revert.
|
||||
|
||||
**No fully AI-generated contributions.** We welcome contributors who use AI tools
|
||||
to assist their work, but PRs should reflect the author's own understanding and
|
||||
judgement. PRs that appear to be unreviewed AI output may be closed without
|
||||
further comment.
|
||||
|
||||
---
|
||||
**No fully AI-generated contributions.** We welcome contributors who use AI tools to assist
|
||||
their work, but a PR should reflect the author's own understanding and judgement. PRs that
|
||||
appear to be unreviewed AI output may be closed without further comment.
|
||||
|
||||
## Branches
|
||||
|
||||
- `main` — the published documentation. PRs target `main`.
|
||||
beacon-server and beacon-web:
|
||||
|
||||
- `main` holds stable releases only and is protected. Never target it directly.
|
||||
- `dev` is active development. All PRs target `dev`.
|
||||
|
||||
beacon-docs has only `main`. PRs target `main`, and the docs there describe the current
|
||||
release.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Fork or create a branch from `main`
|
||||
2. Make your changes
|
||||
3. Run the checklist below
|
||||
4. Open a pull request against `main` with a clear description of what changed
|
||||
and why
|
||||
1. Fork, or create a branch from `dev` (code repos) or `main` (docs).
|
||||
2. Make your change.
|
||||
3. Run that repo's checklist (below for docs; in each code repo's CONTRIBUTING for code).
|
||||
4. Open a pull request with a clear description of what changed and why, referencing any
|
||||
related issue.
|
||||
|
||||
---
|
||||
## Running the full stack locally
|
||||
|
||||
## Checklist before opening a PR
|
||||
Most changes to either code repo are easier to check against the other one running next to it.
|
||||
|
||||
- **Docs match reality.** If you change a variable, path, command, or image
|
||||
name, confirm it matches the actual files in `docker-deployment-*/` and
|
||||
`app_config/`. The README's variable tables must stay in sync with
|
||||
`app_config/.env.example` and `app_config/config.yaml.example`.
|
||||
- **Links resolve.** Internal links point at files that exist; external links
|
||||
load.
|
||||
- **Compose still parses.** If you touched a `docker-compose.yml`, validate it:
|
||||
**beacon-server**, with a throwaway PostgreSQL in Docker:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MeshCore-Beacon/beacon-server.git && cd beacon-server
|
||||
cp env.example .env
|
||||
cp config.yaml.example config.yaml
|
||||
docker run -d --name beacon-postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=beacon -e POSTGRES_PASSWORD=beacon -e POSTGRES_DB=beacon postgres:16-alpine
|
||||
go run ./cmd/beacon
|
||||
```
|
||||
|
||||
Set `POSTGRES_DSN` and your broker credentials in `.env` (every variable is described in
|
||||
[Configuration](docs/configuration.md)). Migrations run on startup. The API is on
|
||||
`http://localhost:8080` and Swagger at `http://localhost:8080/swagger/index.html`.
|
||||
|
||||
**beacon-web**, in a second terminal:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MeshCore-Beacon/beacon-web.git && cd beacon-web
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In `.env.local`, uncomment and set:
|
||||
|
||||
```
|
||||
VITE_DEV_PROXY=http://localhost:8080
|
||||
VITE_API_BASE=/api/v1
|
||||
VITE_WS_URL=ws://localhost:5173/ws
|
||||
```
|
||||
|
||||
Vite then proxies `/api` and `/ws` to the server and presents the server's own origin on the
|
||||
WebSocket, so beacon-server's same-origin rule is satisfied without touching its config. If
|
||||
you would rather have the browser talk to `localhost:8080` directly, add
|
||||
`websocket: {allowed_origins: [http://localhost:5173]}` to the server's `config.yaml` instead.
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
## Checklist before opening a docs PR
|
||||
|
||||
- **Docs match reality.** If you change a variable, path, command or image name, check it
|
||||
against the actual files in `docker-deployment-*/` and `app_config/`, and against the code
|
||||
in beacon-server or beacon-web when the behaviour lives there.
|
||||
- **Links resolve.** Relative links point at files that exist; anchors match a heading.
|
||||
- **Compose still parses.** If you touched a `docker-compose.yml`:
|
||||
```bash
|
||||
docker compose -f docker-deployment-type1/docker-compose.yml config
|
||||
```
|
||||
- **No secrets.** Never commit a real `.env`, real passwords, channel keys, or
|
||||
MQTT credentials. Use placeholder values like `CHANGE_ME` in examples, and
|
||||
keep real secrets in the gitignored `.env`.
|
||||
|
||||
---
|
||||
- **No secrets.** Never commit a real `.env`, real passwords, channel keys or MQTT credentials.
|
||||
Use placeholders like `CHANGE_ME` in examples.
|
||||
- **No em dashes.** Use a comma, a period or a new sentence.
|
||||
|
||||
## Documentation style
|
||||
|
||||
- Write for an operator deploying Beacon for the first time — assume Docker
|
||||
knowledge, not knowledge of this project.
|
||||
- Prefer concrete, copy-pasteable commands over prose.
|
||||
- Use fenced code blocks with a language tag (` ```bash `, ` ```yaml `,
|
||||
` ```text `).
|
||||
- Keep tables aligned with the example config files; when a config field
|
||||
changes, update both the example file and the README in the same PR.
|
||||
- Use relative links between files in this repo so they work on GitHub and in
|
||||
local clones.
|
||||
|
||||
---
|
||||
- Write for an operator deploying Beacon for the first time. Assume Docker knowledge, not
|
||||
knowledge of this project.
|
||||
- Lead with the action. "Set `DOMAIN` to your public hostname", not "the `DOMAIN` variable
|
||||
should be configured".
|
||||
- Say why only where the why prevents a mistake.
|
||||
- Prefer concrete, copy-pasteable commands over prose, in fenced blocks with a language tag.
|
||||
- One page, one job. Headings are things a reader searches for, not categories.
|
||||
- Keep tables in step with the example config files; when a key changes, update
|
||||
`app_config/config.yaml.example` and the page that describes it in the same PR.
|
||||
- Relative links between files in this repo, so they work on GitHub and in local clones.
|
||||
|
||||
## Adding or changing a deployment
|
||||
|
||||
The deployment folders are meant to be copied to a server and run as-is. When
|
||||
editing one:
|
||||
The deployment folders are meant to be copied to a server and run as-is. When editing one:
|
||||
|
||||
- Keep the folder self-contained: `docker-compose.yml`, the `data/` tree, and a
|
||||
`.env` that is created from `app_config/.env.example`.
|
||||
- Every variable a user must set should appear in `app_config/.env.example` (for
|
||||
`.env`) or `app_config/config.yaml.example` (for app config), and be documented
|
||||
in the README's variable tables.
|
||||
- Keep service names, container names, and the Caddyfile upstreams consistent —
|
||||
the reverse proxy must resolve the names it proxies to.
|
||||
- If you add a new deployment type, give it its own `docker-deployment-typeN/`
|
||||
folder, add a Caddyfile template under `app_config/caddy/`, and add a section
|
||||
to the README walkthrough.
|
||||
|
||||
---
|
||||
- Keep the folder self-contained: `docker-compose.yml`, the `data/` tree, and a `.env` the
|
||||
user creates from `app_config/.env.example`.
|
||||
- Every variable a user must set appears in `app_config/.env.example` (for `.env`) or
|
||||
`app_config/config.yaml.example` (for app config), and in [Configuration](docs/configuration.md).
|
||||
- Keep service names and Caddyfile upstreams consistent; the proxy must resolve the names it
|
||||
proxies to.
|
||||
- A new deployment type gets its own `docker-deployment-typeN/` folder with a README, Caddyfile
|
||||
templates under `app_config/caddy/`, and a line in the main README.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Use the conventional commits format:
|
||||
Conventional commits, imperative, one line under 72 characters, with an optional scope:
|
||||
|
||||
```
|
||||
docs(readme): clarify VITE_MAP_CENTER fallback behaviour
|
||||
docs(deploy): add Type 2 split server/web compose files
|
||||
fix(config): correct postgres password mismatch note
|
||||
docs(deploy): add type 2 split server and web compose files
|
||||
feat(routes): add observation_count to known routes response
|
||||
fix(ingest): correct lat/lon divisor for advert payloads
|
||||
chore: update logos
|
||||
```
|
||||
|
||||
Common scopes: `readme`, `deploy`, `config`, `caddy`, `docs`.
|
||||
## Releases
|
||||
|
||||
---
|
||||
Versioning, image tags and the release flow are in
|
||||
[Releases and versioning](docs/releases.md).
|
||||
|
||||
## Repo structure
|
||||
|
||||
```
|
||||
docker-deployment-type1/ — single-server (all-in-one) Docker Compose deployment
|
||||
docker-deployment-type2/ — split server/web deployment (WIP)
|
||||
app_config/ — example .env, config.yaml, proxy configs (Caddy, nginx, Apache), fail2ban
|
||||
app_documentation/ — project-wide design & API docs
|
||||
logos/ — brand assets
|
||||
docker-deployment-type1/ all-in-one Docker Compose deployment
|
||||
docker-deployment-type2/ split server and web deployment
|
||||
app_config/ example .env and config.yaml, proxy configs (Caddy, nginx, Apache), fail2ban
|
||||
docs/ operator and design documentation
|
||||
logos/ brand assets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recognition
|
||||
|
||||
If you'd like to be listed as a contributor, add yourself to
|
||||
If you would like to be listed as a contributor, add yourself to
|
||||
[CONTRIBUTORS.md](CONTRIBUTORS.md) in your PR.
|
||||
+2
-2
@@ -4,8 +4,8 @@ Beacon is built by its contributors. Thank you to everyone who has helped.
|
||||
|
||||
## Core
|
||||
|
||||
- [ded](https://hackers.town/@ded) — co-founder, lead developer
|
||||
- [MrAlders0n](https://github.com/MrAlders0n/) — co-founder, lead developer
|
||||
- [ded](https://hackers.town/@ded), co-founder, lead developer
|
||||
- [MrAlders0n](https://github.com/MrAlders0n/), co-founder, lead developer
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
# beacon-docs
|
||||
# MeshCore Beacon
|
||||
|
||||
Documentation, architecture, and **ready-to-run Docker deployments** for MeshCore Beacon.
|
||||
Beacon watches a MeshCore mesh from above. It listens to the MQTT brokers that observers publish
|
||||
into, decodes every LoRa packet it hears, stores it, and shows the network live: packets,
|
||||
channels, nodes, observers, routes, traces and stats, on a map and in lists.
|
||||
|
||||
This repo is the single place to:
|
||||
This repo is where you deploy and operate it. The code lives in
|
||||
[beacon-server](https://github.com/MeshCore-Beacon/beacon-server) (the API and ingest) and
|
||||
[beacon-web](https://github.com/MeshCore-Beacon/beacon-web) (the frontend).
|
||||
|
||||
1. **Grab a deployment** — copy the Docker Compose folder for the topology you want, fill in your variables, and `docker compose up -d`.
|
||||
2. **Read the docs** — project-wide design and API documentation that describe how the whole system works.
|
||||
> **Coming from 1.x?** 2.0.0 needs a fresh database and a few config changes. Read
|
||||
> [Upgrading](docs/upgrading.md) before you pull new images.
|
||||
|
||||
> **Upgrading from 1.x?** Beacon 2.0.0 needs a fresh database and a few config changes. Read
|
||||
> [Upgrading to 2.0.0](#upgrading-to-200) before pulling new images.
|
||||
## Deploy the all-in-one stack
|
||||
|
||||
---
|
||||
One server runs everything: the API, Postgres, Redis, the web frontend and Caddy with automatic
|
||||
HTTPS. You need a domain pointed at the server, ports 80 and 443 open, Docker with the compose
|
||||
plugin, and a subscriber account on a MeshCore MQTT broker
|
||||
([where packets come from](docs/getting-packets-in.md)).
|
||||
|
||||
## Deploy Beacon
|
||||
If you would rather run the API and the web frontend on different hosts, see
|
||||
[Split deployment](#split-deployment).
|
||||
|
||||
Two deployment topologies are provided. Pick one, copy its folder to your server, set your variables, and bring it up.
|
||||
### 1. Get the files onto your server
|
||||
|
||||
| Type | Folder | What it is | Status |
|
||||
|---|---|---|---|
|
||||
| **Type 1 — All-in-One** | [`docker-deployment-type1/`](docker-deployment-type1/) | Full stack on a single server: API (`app`), Postgres (`db`), Redis (`redis`), web frontend (`web`), and Caddy reverse proxy with automatic TLS. | ✅ Ready |
|
||||
| **Type 2 — Split Server + Web** | [`docker-deployment-type2/`](docker-deployment-type2/) | API backend on one server, web frontend on a dedicated server. | 🚧 Not done yet (WIP) |
|
||||
|
||||
### Step-by-step (Type 1)
|
||||
|
||||
**1. Get the files onto your server**
|
||||
|
||||
Clone the repo (you only need the deployment folder, but cloning is the simplest way to grab it):
|
||||
Clone the repo; you only need the deployment folder, but cloning is the simplest way to get it.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MeshCore-Beacon/beacon-docs.git
|
||||
@@ -37,286 +35,163 @@ The folder is self-contained:
|
||||
```text
|
||||
docker-deployment-type1/
|
||||
├── docker-compose.yml # the stack (pins the network to 172.30.0.0/24)
|
||||
├── .env # your secrets (you create this — see step 2)
|
||||
├── .env # your secrets (you create this in step 2)
|
||||
└── data/ # persistent state, bind-mounted into the containers
|
||||
├── app/config.yaml # Beacon app config (regions, channels, retention, proxy trust)
|
||||
├── app/config.yaml # Beacon config: regions, channels, retention, proxy trust
|
||||
├── Caddy/CaddyFile/Caddyfile.proxy
|
||||
├── Caddy/conf.d/blocklist.caddy # manual IP / User-Agent block list
|
||||
├── Caddy/conf.d/blocklist.caddy # manual IP and User-Agent block list
|
||||
├── Caddy/logs/ # JSON access log (read by fail2ban, created on first run)
|
||||
├── postgres/ # ← Postgres database files live here (created on first run)
|
||||
└── redis/ # ← Redis data lives here (created on first run)
|
||||
├── postgres/ # Postgres database files (created on first run)
|
||||
└── redis/ # Redis data (created on first run)
|
||||
```
|
||||
|
||||
> **`data/postgres/` and `data/redis/` are empty in git on purpose.** They're bind-mount targets: the `db` and `redis` containers write their data into them, so your database and cache **survive `docker compose down` and restarts**. Don't delete them unless you intend to wipe all data — `rm -rf data/postgres` resets the database. They populate automatically the first time you run `docker compose up -d`.
|
||||
>
|
||||
> A `data/postgres/` left over from Beacon 1.x must be moved away first: 2.0.0 won't start on a 1.x database. See [Upgrading to 2.0.0](#upgrading-to-200).
|
||||
`data/postgres/` and `data/redis/` are bind mounts, so your database and cache survive
|
||||
`docker compose down` and restarts. Do not delete `data/postgres/` unless you mean to wipe all
|
||||
data. A `data/postgres/` left over from Beacon 1.x must be moved away first; 2.0.0 will not
|
||||
start on a 1.x database (see [Upgrading](docs/upgrading.md)).
|
||||
|
||||
**2. Create and fill in your `.env`**
|
||||
|
||||
Copy the template from [`app_config/.env.example`](app_config/.env.example) and edit it:
|
||||
### 2. Create and fill in your `.env`
|
||||
|
||||
```bash
|
||||
cp ../app_config/.env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Set every `CHANGE_*` value. The variables you must fill in:
|
||||
Set every `CHANGE_*` value. The ones you cannot skip:
|
||||
|
||||
| Variable | Service | What to set |
|
||||
|---|---|---|
|
||||
| `POSTGRES_DSN` | `app` | Database connection string. Change the password (`CHANGE_DB_PASS`) to a strong one. |
|
||||
| `REDIS_ADDR` | `app` | `redis:6379` — points the API at the compose Redis service. Leave it out and the server runs uncached, so every read hits Postgres. |
|
||||
| `BEACON_API_KEY` | `app` | *(Optional)* Bearer key for the `/api/v1/admin/*` endpoints, at least 16 characters (e.g. `openssl rand -hex 32`). Without one, admin routes return `503`; public reads and the live feed are unaffected. |
|
||||
| `LOG_LEVEL` / `LOG_FORMAT` | `app` | *(Optional)* `debug`/`info`/`warn`/`error` and `text`/`json`. Default `info` / `text`. |
|
||||
| `MQTT_BROKER_1_*` / `MQTT_BROKER_2_*` | `app` | URL, username, and password for your live MeshCore MQTT packet sources. |
|
||||
| `DOMAIN` | `caddy` | Your public domain (e.g. `beacon.example.com`). Caddy auto-provisions a Let's Encrypt cert for it. |
|
||||
| `VITE_API_BASE` | `web` | `https://<your-domain>/api/v1` — must be the **public** domain, never localhost. |
|
||||
| `VITE_WS_URL` | `web` | `wss://<your-domain>/ws` |
|
||||
| `VITE_MAP_CENTER` / `VITE_MAP_ZOOM` | `web` | *(Optional)* Fallback "All" map view. The app auto-fits the map to all IATA locations from `config.yaml`; these values are only used as a fallback when those IATAs have no location set. Omit for a world view. |
|
||||
| `VITE_DISABLED_TABS` | `web` | *(Optional)* Comma list of tabs to hide, e.g. `Map,Traces`. Options: `Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics`. |
|
||||
| `VITE_ENABLED_THEMES` | `web` | *(Optional)* Comma list of theme ids. When set, only these themes are offered; listing `meshmapper_dark` / `meshmapper_light` is the only way to enable those. |
|
||||
| `VITE_APP_NAME` | `web` | *(Optional)* Top-left wordmark text. Default `BEACON`. |
|
||||
| `VITE_SKIP_SPLASH` | `web` | *(Optional)* `true` skips the once-per-session load splash. |
|
||||
| `VITE_BANNER` | `web` | *(Optional)* Notice above the header on every page, e.g. for a dev instance. `[label](url)` and bare URLs become links. |
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `POSTGRES_DSN` | The database connection string. Change `CHANGE_DB_PASS` to a strong password. **The same password must go in `POSTGRES_PASSWORD` in `docker-compose.yml`.** |
|
||||
| `MQTT_BROKER_1_URL`, `_USERNAME`, `_PASSWORD` | Your MeshCore MQTT broker and the subscriber account on it. The template also lists a second broker; if you have only one, clear `MQTT_BROKER_2_URL` (an empty URL disables that worker). |
|
||||
| `DOMAIN` | Your public hostname, for example `beacon.example.com`. Caddy requests a Let's Encrypt certificate for it. |
|
||||
| `VITE_API_BASE` | `https://<your-domain>/api/v1`. The browser calls this, so it must be the public domain, never `localhost`. |
|
||||
| `VITE_WS_URL` | `wss://<your-domain>/ws` |
|
||||
|
||||
> ⚠️ **Password must match in two places.** The password inside `POSTGRES_DSN` (in `.env`) must equal `POSTGRES_PASSWORD` in `docker-compose.yml`. Update both before bringing the stack up.
|
||||
Everything else in the file is optional and explained in
|
||||
[Configuration](docs/configuration.md): the admin API key, log settings, Redis, and the
|
||||
web app's map view, tabs, themes, name and banner.
|
||||
|
||||
**3. Fill in the app config**
|
||||
|
||||
Edit [`data/app/config.yaml`](docker-deployment-type1/data/app/config.yaml) to define your network:
|
||||
### 3. Describe your network in `config.yaml`
|
||||
|
||||
```bash
|
||||
nano data/app/config.yaml
|
||||
```
|
||||
|
||||
- **`iatas`** — the airport-code anchor points for your coverage area (name + lat/lng).
|
||||
- **`regions`** — map regions that group IATAs together.
|
||||
- **`channel_keys`** — hashtag channels and/or explicit channel keys to decrypt.
|
||||
- **`scopes`** — transport scopes. Each manual scope needs a `region` that is one of the slugs under `regions`.
|
||||
- **`telemetry`**, **`packets`**, **`analytics`**, **`routes`** — retention windows (packets default to 7 days).
|
||||
- **`ingest`** — optional geographic ingest filter.
|
||||
- **`server.trusted_proxies`** — already set to the compose subnet so Beacon sees real client IPs through Caddy. Change it only if you change the subnet in `docker-compose.yml`.
|
||||
- `iatas`: the airport codes your observers report under, with a name and coordinates.
|
||||
- `regions`: groups of IATAs that become the region picker in the web app.
|
||||
- `channel_keys`: hashtag channels and explicit keys to decrypt. Without them, channel messages
|
||||
are stored as hashes only.
|
||||
- `scopes`: transport scopes, if your mesh uses them. Each one needs a `region`.
|
||||
- `packets`, `telemetry`, `analytics`, `routes`: how long things are kept. Packets default to
|
||||
7 days.
|
||||
- `ingest.allow_countries` is set to `[CA]`, so packets from observers outside Canada are
|
||||
dropped. Change it to your country, or delete the `ingest` block to accept everything.
|
||||
- `server.trusted_proxies` is already set to the compose subnet so Beacon sees real visitor
|
||||
addresses through Caddy. Change it only if you change the subnet in `docker-compose.yml`.
|
||||
|
||||
[`app_config/config.yaml.example`](app_config/config.yaml.example) documents every other key (rate limits, WebSocket limits, MeshMapper imports, CORS, cache, background intervals).
|
||||
Every other key, with its default, is documented in
|
||||
[`app_config/config.yaml.example`](app_config/config.yaml.example); the
|
||||
[Configuration](docs/configuration.md#configyaml) page is the map of what each block does.
|
||||
|
||||
**4. Point DNS at the server**
|
||||
### 4. Point DNS at the server
|
||||
|
||||
Create an `A`/`AAAA` record for your `DOMAIN` pointing at the server's public IP. Caddy needs ports **80** and **443** reachable to issue the TLS certificate.
|
||||
Create an `A` or `AAAA` record for `DOMAIN` pointing at the server's public IP. Caddy needs
|
||||
ports 80 and 443 reachable from the internet to get its certificate.
|
||||
|
||||
**5. Bring it up**
|
||||
### 5. Bring it up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Check it's healthy:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Visit `https://<your-domain>` and you're off to the races. 🚀
|
||||
Within a minute `app` should log a `connected` line for each broker. Packets themselves are
|
||||
not logged at the default level; open `https://<your-domain>` and watch the Packets tab, or
|
||||
check `https://<your-domain>/api/v1/observers` fills in. If something is off, [Troubleshooting](docs/troubleshooting.md)
|
||||
lists the usual suspects.
|
||||
|
||||
> After changing any `VITE_*` value later, apply it with:
|
||||
> ```bash
|
||||
> docker compose up -d web
|
||||
> ```
|
||||
> The web container writes these to `/config.js` on every start, so visitors get the new values on their next page load.
|
||||
To change a `VITE_*` value later, edit `.env` and run `docker compose up -d web`. The web
|
||||
container writes those values to `/config.js` on every start, so visitors get them on their
|
||||
next page load.
|
||||
|
||||
### Container images
|
||||
## Split deployment
|
||||
|
||||
API, database and ingest on one host, the web frontend on another:
|
||||
[docker-deployment-type2](docker-deployment-type2/README.md).
|
||||
|
||||
## Container images
|
||||
|
||||
The `app` and `web` services pull public images from GitHub Container Registry
|
||||
(`ghcr.io/meshcore-beacon/beacon-server` and `…/beacon-web`) — **no `docker login`
|
||||
is required**.
|
||||
(`ghcr.io/meshcore-beacon/beacon-server` and `ghcr.io/meshcore-beacon/beacon-web`). No
|
||||
`docker login` is needed.
|
||||
|
||||
Tags: `latest` follows stable releases on `main`, `dev` follows the development branch, and each
|
||||
release is also published as `X.Y.Z` and `X.Y`. The compose file uses `latest`; to control when
|
||||
upgrades happen, pin both images to a release line (`beacon-server:2.0` and `beacon-web:2.0`) or an
|
||||
exact release (`:2.0.0`). Server and web share major.minor versions, so web `2.0.x` pairs with server
|
||||
`2.0.x`; patch numbers move independently.
|
||||
The compose files use `latest`, which follows stable releases. To decide for yourself when to
|
||||
upgrade, pin both images to the same release line (`:2.0`) or an exact release (`:2.0.0`); server
|
||||
and web share `major.minor`. Tags, versioning and the release process are in
|
||||
[Releases and versioning](docs/releases.md). A `403 Forbidden` on pull means the package has
|
||||
gone private; see [Troubleshooting](docs/troubleshooting.md#docker-compose-up-fails-with-403-forbidden-pulling-an-image).
|
||||
|
||||
> **Troubleshooting — `403 Forbidden` on pull.** If `docker compose up` fails with a
|
||||
> `... manifests/<tag>: 403 Forbidden` error, the package has been set (or defaulted)
|
||||
> to **Private** on GHCR. A maintainer must set it back to Public — see
|
||||
> [Maintainers: publishing images](#maintainers-publishing-images).
|
||||
## Documentation
|
||||
|
||||
### Type 2 — Split Server + Web
|
||||
**Run it**
|
||||
|
||||
🚧 **Not done yet.** [`docker-deployment-type2/`](docker-deployment-type2/) is a placeholder; instructions and compose files will land here.
|
||||
- [Getting packets in](docs/getting-packets-in.md): brokers, the account Beacon needs, topics.
|
||||
- [Configuration](docs/configuration.md): every environment variable and a map of `config.yaml`.
|
||||
- [Reverse proxy](docs/reverse-proxy.md): Caddy, nginx, Apache, rate limits, split origins.
|
||||
- [Securing a deployment](docs/security.md): the admin key, the listener, origins, bans.
|
||||
- [Day-to-day operations](docs/operations.md): health, logs, retention, backup and restore.
|
||||
- [Upgrading](docs/upgrading.md): 2.0.0 from 1.x, and routine upgrades.
|
||||
- [Troubleshooting](docs/troubleshooting.md): symptom, cause, fix.
|
||||
- [Releases and versioning](docs/releases.md): image tags and how releases are cut.
|
||||
|
||||
### Reverse proxy
|
||||
**Understand it**
|
||||
|
||||
However you front Beacon, the proxy has the same three jobs:
|
||||
- [High level design](docs/high-level-design.md): how it works, the schema, the ingest pipeline.
|
||||
- [API contract](docs/api-contract.md): REST, WebSocket, admin endpoints, errors.
|
||||
- [Backup and export](docs/backup-export.md) and [CPU profiling](docs/profiling.md).
|
||||
|
||||
- Send `/api/*` and `/ws` to beacon-server (port `8080`) and everything else to beacon-web. `/ws` is a
|
||||
WebSocket, so the proxy must pass the upgrade through and keep idle sockets open for more than 90 s
|
||||
(the browser pings every 30 s; the server drops a socket after 90 s of silence).
|
||||
- Overwrite `X-Real-IP` with the connecting client's address and pass the original `Host` header.
|
||||
beacon-server ignores `X-Forwarded-For` and `True-Client-IP` entirely.
|
||||
- Be listed in beacon-server's `server.trusted_proxies` (CIDR: `/32` for one IPv4 host, `/128` for
|
||||
IPv6). Only those peers may set `X-Real-IP`. Get this wrong and every visitor shares the proxy's
|
||||
rate limit and WebSocket connection cap.
|
||||
**Change it**
|
||||
|
||||
`VITE_API_BASE` and `VITE_WS_URL` then point at the public paths, e.g.
|
||||
`https://beacon.example.com/api/v1` and `wss://beacon.example.com/ws`.
|
||||
- [Contributing](CONTRIBUTING.md): the workflow for all three repos and running the full stack
|
||||
locally.
|
||||
- beacon-server: [historical stats](https://github.com/MeshCore-Beacon/beacon-server/blob/main/docs/historical-stats.md),
|
||||
how the hourly rollups work.
|
||||
- beacon-web: [translations](https://github.com/MeshCore-Beacon/beacon-web/blob/main/docs/translations.md),
|
||||
adding a language.
|
||||
|
||||
**Caddy** (the type 1 default): [`Caddyfile.proxy`](docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy).
|
||||
The relevant part:
|
||||
|
||||
```caddy
|
||||
handle /api/* {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
handle /ws {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles the WebSocket upgrade and keeps the `Host` header by itself. It reaches the app over
|
||||
the compose network, so `data/app/config.yaml` trusts that subnet:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
trusted_proxies: [172.30.0.0/24]
|
||||
```
|
||||
|
||||
Behind Cloudflare or another CDN, `{remote_host}` is the CDN. The comment at the top of
|
||||
`Caddyfile.proxy` shows how to trust the CDN's ranges and forward `{client_ip}` instead.
|
||||
|
||||
**nginx** on the host: [`app_config/nginx/beacon.conf`](app_config/nginx/beacon.conf) is a complete
|
||||
server block. The essentials:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location = /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade; # map is in the example file
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081; # beacon-web published on loopback
|
||||
}
|
||||
```
|
||||
|
||||
nginx sends the upstream address as `Host` by default, which fails the WebSocket origin check, so
|
||||
keep `proxy_set_header Host $host`. When nginx reaches the containers through published ports, the
|
||||
app sees the Docker network's gateway as the peer, so trust the compose subnet (`[172.30.0.0/24]`).
|
||||
For a beacon-server running directly on the host, use `["127.0.0.1/32", "::1/128"]`.
|
||||
|
||||
Apache works the same way: see [`app_config/apache/analyzer-vhost.snippet.conf`](app_config/apache/analyzer-vhost.snippet.conf).
|
||||
Abuse controls (access log, block list, fail2ban) are in [`app_config/fail2ban/`](app_config/fail2ban/README.md).
|
||||
|
||||
**Split origins** (web and API on different hosts): add the web origin to
|
||||
`websocket.allowed_origins`, or browsers can't open `/ws`. REST CORS allows any origin by default.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading to 2.0.0
|
||||
|
||||
**2.0.0 needs a fresh database.** It starts from a new schema baseline; pointed at a 1.x database it
|
||||
refuses to start with `database schema predates Beacon 2.0.0; 2.0.0 needs a fresh database`. 1.x
|
||||
history does not carry over. For the type 1 stack:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
mv data/postgres data/postgres-1.x # or rm -rf data/postgres once you no longer need it
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker compose down` also lets the network come back with the new fixed subnet. If `172.30.0.0/24`
|
||||
is already used on your host, pick another in `docker-compose.yml` and change
|
||||
`server.trusted_proxies` to match.
|
||||
|
||||
Config that **fails startup** if left as it was:
|
||||
|
||||
- Every manual `scopes:` entry needs `region:` set to one of your `regions:` slugs.
|
||||
- MeshMapper refresh intervals must be within bounds: `meshmapper.scopes.refresh_interval` 1h–24h,
|
||||
`meshmapper.zones.refresh_interval` and `meshmapper.channels.refresh_interval` 24h–168h.
|
||||
- `packets.retention` and `analytics.rollup_retention` must be at least `24h`.
|
||||
- `server.trusted_proxies` entries must be CIDRs (`10.0.0.5/32`, not `10.0.0.5`).
|
||||
|
||||
Other changes to review:
|
||||
|
||||
- `meshmapper.scopes.sources` is ignored (with a warning). Scope catalogues are now found through
|
||||
MeshMapper's zone list for every known IATA.
|
||||
- **REST rate limiting is on by default:** 300 requests per minute per client IP on `/api/v1/*`
|
||||
(IPv6 clients share a /64), answered with `429` and `Retry-After`. Behind a proxy, set
|
||||
`server.trusted_proxies` and have the proxy send `X-Real-IP` (see [Reverse proxy](#reverse-proxy));
|
||||
`X-Forwarded-For` and `True-Client-IP` are not trusted. Tune or turn it off under `ratelimit:`.
|
||||
- **WebSocket upgrades are rate limited** to `websocket.max_connects_per_minute` (default 10) per
|
||||
client IP, on top of `max_connections_per_ip` (default 5). Only the page's own host may open `/ws`
|
||||
unless you list other origins in `websocket.allowed_origins`, e.g. when the web app is served from
|
||||
a different host than the API.
|
||||
- `packets.retention` now defaults to **7 days** and also covers observations and channel messages.
|
||||
Historical stats come from hourly rollups kept for `analytics.rollup_retention` (90 days).
|
||||
- Admin endpoints under `/api/v1/admin/` need `BEACON_API_KEY` (or `auth.api_key`).
|
||||
- Image tags: `latest` is the newest stable release from `main`. Pin `:2.0` on both images if you
|
||||
want to choose when to move to the next minor release.
|
||||
|
||||
[`app_config/config.yaml.example`](app_config/config.yaml.example) is the full 2.0.0 reference.
|
||||
|
||||
---
|
||||
|
||||
## Project documentation
|
||||
|
||||
Project-wide docs that describe the entire system live in [`app_documentation/`](app_documentation/):
|
||||
|
||||
- [**High Level Design**](app_documentation/high_level_design.md) — single source of truth. System overview, database schema, ingestion pipeline, and future features.
|
||||
- [**API Contract**](app_documentation/api_contract.md) — REST endpoints, auth and rate limits, WebSocket protocol, backpressure/reconnection, and mobile-specific concerns.
|
||||
Deployment folders are `docker-deployment-type1/` and `docker-deployment-type2/`; templates
|
||||
and proxy configs (`.env`, `config.yaml`, Caddy, nginx, Apache, fail2ban) are in `app_config/`;
|
||||
brand assets are in `logos/`.
|
||||
|
||||
## Source repositories
|
||||
|
||||
- **Beacon Server (API):** [github.com/MeshCore-Beacon/beacon-server](https://github.com/MeshCore-Beacon/beacon-server) — image `ghcr.io/meshcore-beacon/beacon-server`
|
||||
- **Beacon Web (Frontend):** [github.com/MeshCore-Beacon/beacon-web](https://github.com/MeshCore-Beacon/beacon-web) — image `ghcr.io/meshcore-beacon/beacon-web`
|
||||
- **beacon-server** (API and ingest): [github.com/MeshCore-Beacon/beacon-server](https://github.com/MeshCore-Beacon/beacon-server), image `ghcr.io/meshcore-beacon/beacon-server`
|
||||
- **beacon-web** (frontend): [github.com/MeshCore-Beacon/beacon-web](https://github.com/MeshCore-Beacon/beacon-web), image `ghcr.io/meshcore-beacon/beacon-web`
|
||||
|
||||
## Maintainers: publishing images
|
||||
|
||||
Container images are published automatically by each repo's `docker-publish.yml`
|
||||
workflow on pushes to `main`/`dev` and on `v*` tags. **GHCR packages are Private by
|
||||
default**, which makes anonymous `docker pull` fail with `403 Forbidden`. To make a
|
||||
package publicly pullable (one-time, per package):
|
||||
Container images are published automatically by each repo's `docker-publish.yml` workflow on
|
||||
pushes to `main` and `dev` and on `v*` tags. GHCR packages are private by default, which makes
|
||||
anonymous `docker pull` fail with `403 Forbidden`. To make a package publicly pullable, once per
|
||||
package:
|
||||
|
||||
1. GitHub → the **MeshCore-Beacon** org → **Packages** → select `beacon-server`.
|
||||
2. **Package settings** → **Danger Zone** → **Change visibility** → **Public**.
|
||||
1. GitHub, the **MeshCore-Beacon** org, **Packages**, select `beacon-server`.
|
||||
2. **Package settings**, **Danger Zone**, **Change visibility**, **Public**.
|
||||
3. Repeat for `beacon-web`.
|
||||
|
||||
Once a package is Public, every future CI push to it stays Public. `GITHUB_TOKEN`
|
||||
cannot change package visibility, so this step can't be automated in the workflow.
|
||||
|
||||
## Repo structure
|
||||
|
||||
- `/docker-deployment-type1` — Single-server (all-in-one) Docker Compose deployment ✅
|
||||
- `/docker-deployment-type2` — Split server/web Docker Compose deployment 🚧 WIP
|
||||
- `/app_config` — Example `.env`, `config.yaml`, Caddy/nginx/Apache proxy configs, and fail2ban rules
|
||||
- `/app_documentation` — Project-wide design & API docs
|
||||
- `/logos` — Brand assets
|
||||
Once a package is public, every future push keeps it public. `GITHUB_TOKEN` cannot change
|
||||
package visibility, so this step cannot be automated in the workflow.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
||||
workflow and the [Code of Conduct](CODE_OF_CONDUCT.md). Issues are disabled on
|
||||
this repo; to discuss a change first, reach out on the
|
||||
[MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf). Security reports go
|
||||
through [SECURITY.md](SECURITY.md), not public channels.
|
||||
Contributions are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) has the workflow and the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md) applies. Issues are disabled on this repo; to discuss a
|
||||
change first, reach out on the [MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf).
|
||||
Security reports go through [SECURITY.md](SECURITY.md), not public channels.
|
||||
|
||||
## License
|
||||
|
||||
Beacon is licensed under the [GNU Affero General Public License v3.0](LICENSE)
|
||||
(AGPL-3.0), the same license as [beacon-server](https://github.com/MeshCore-Beacon/beacon-server).
|
||||
Beacon is licensed under the [GNU Affero General Public License v3.0](LICENSE) (AGPL-3.0), the
|
||||
same license as [beacon-server](https://github.com/MeshCore-Beacon/beacon-server) and
|
||||
[beacon-web](https://github.com/MeshCore-Beacon/beacon-web).
|
||||
+1
-1
@@ -13,7 +13,7 @@ Please do not report security issues through public GitHub. Issues are disabled
|
||||
on this repository.
|
||||
|
||||
Instead, contact the maintainers directly via the MeshCore Canada Discord
|
||||
server: [MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf) — reach out to
|
||||
server: [MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf); reach out to
|
||||
**dedskelly** directly. Include as much detail as possible: the nature of the
|
||||
issue, steps to reproduce, and any potential impact.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ LISTEN_ADDR=:8080
|
||||
|
||||
# Postgres DSN. Host "db" is the compose service name; the password
|
||||
# must match POSTGRES_PASSWORD in docker-compose.yml.
|
||||
POSTGRES_DSN=postgres://tower:CHANGE_DB_PASS@db:5432/tower?sslmode=disable
|
||||
POSTGRES_DSN=postgres://beacon:CHANGE_DB_PASS@db:5432/beacon?sslmode=disable
|
||||
|
||||
# Redis cache. Host "redis" is the compose service name. If unset, the
|
||||
# server runs with caching disabled and every read hits Postgres.
|
||||
@@ -33,6 +33,7 @@ MQTT_BROKER_1_URL=wss://mqtt1.meshcore.ca:443
|
||||
MQTT_BROKER_1_USERNAME=CHANGE_NAME
|
||||
MQTT_BROKER_1_PASSWORD=CHANGE_PASS
|
||||
|
||||
# Optional second broker. Leave MQTT_BROKER_2_URL empty to run with one broker.
|
||||
MQTT_BROKER_2_URL=wss://mqtt2.meshcore.ca:443
|
||||
MQTT_BROKER_2_USERNAME=CHANGE_NAME
|
||||
MQTT_BROKER_2_PASSWORD=CHANGE_PASS
|
||||
@@ -41,13 +42,17 @@ MQTT_BROKER_2_PASSWORD=CHANGE_PASS
|
||||
# Site address + automatic Let's Encrypt certificate.
|
||||
DOMAIN=beacon.example.com
|
||||
|
||||
# Type 2 server host only: the web frontend's hostname. Caddy redirects everything that is
|
||||
# not /api/* or /ws there. Not used by the all-in-one stack.
|
||||
# WEB_DOMAIN=beacon.example.com
|
||||
|
||||
# ---- Frontend URLs (service: web) ---------------------------
|
||||
# Written to /config.js each time the web container starts.
|
||||
# MUST be the public domain (never localhost) and match DOMAIN above,
|
||||
# the browser makes these calls, not the container.
|
||||
# After changing these, recreate the web container so they re-inject:
|
||||
# docker compose up -d --force-recreate web
|
||||
# (and hard-refresh / incognito, since /assets/* is cached immutable)
|
||||
# After changing these, restart the web container so they are re-read:
|
||||
# docker compose up -d web
|
||||
# (then hard-refresh the page)
|
||||
VITE_API_BASE=https://beacon.example.com/api/v1
|
||||
VITE_WS_URL=wss://beacon.example.com/ws
|
||||
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
</RequireAll>
|
||||
</Location>
|
||||
|
||||
# /etc/apache2/beacon-blocklist.conf — one `Require not ip <ip-or-cidr>` per line.
|
||||
# /etc/apache2/beacon-blocklist.conf: one `Require not ip <ip-or-cidr>` per line.
|
||||
# Keep the TEST-NET placeholder so the file is never empty:
|
||||
#
|
||||
# Require not ip 192.0.2.0/24
|
||||
#
|
||||
# /etc/apache2/beacon-blocklist-ua.conf — one SetEnvIfNoCase per scraper User-Agent signature:
|
||||
# /etc/apache2/beacon-blocklist-ua.conf: one SetEnvIfNoCase per scraper User-Agent signature:
|
||||
#
|
||||
# SetEnvIfNoCase User-Agent "some-scraper" beacon_block
|
||||
@@ -1,20 +1,65 @@
|
||||
# Type 2 Server: API-only, redirect other traffic to web host
|
||||
# beacon-server needs this proxy's address in server.trusted_proxies, and the web host
|
||||
# (https://web.your.domain.com) in websocket.allowed_origins so browsers can open /ws.
|
||||
# Type 2, server host: API and WebSocket only; everything else is redirected to the web host.
|
||||
# Copy of docker-deployment-type2/server/data/Caddy/CaddyFile/Caddyfile.proxy.
|
||||
# Behind Cloudflare (or another CDN), {remote_host} is the CDN's address. Trust its ranges
|
||||
# in a global block, then use {client_ip} instead of {remote_host} below and client_ip
|
||||
# instead of remote_ip in conf.d/blocklist.caddy:
|
||||
# {
|
||||
# servers {
|
||||
# trusted_proxies static <Cloudflare IPv4 and IPv6 ranges>
|
||||
# client_ip_headers CF-Connecting-IP
|
||||
# }
|
||||
# }
|
||||
{$DOMAIN} {
|
||||
handle /api/* {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
# defines @blocked_ip and @blocked_ua; edit conf.d/blocklist.caddy then `caddy reload`
|
||||
import conf.d/blocklist.caddy
|
||||
|
||||
handle /ws {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
# JSON access log for the whole site (API, SPA and assets together, so per-IP joins are one file)
|
||||
log {
|
||||
output file /var/log/caddy/access.json {
|
||||
mode 0644
|
||||
roll_size 100MiB
|
||||
roll_keep 20
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
handle {
|
||||
redir https://web.your.domain.com{uri} permanent
|
||||
}
|
||||
# opt-in: 403 API calls whose Referer names another site. Spoofable; enable only if the
|
||||
# logs show a specific foreign host with sustained volume.
|
||||
# @foreign_referer {
|
||||
# header Referer *
|
||||
# not header Referer https://{$DOMAIN}/*
|
||||
# }
|
||||
|
||||
handle /api/* {
|
||||
respond @blocked_ip "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
respond @blocked_ua "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
# respond @foreign_referer "Forbidden" 403
|
||||
reverse_proxy app:8080 {
|
||||
# beacon-server takes the client IP only from X-Real-IP, and only from peers in
|
||||
# server.trusted_proxies; overwrite it so clients can't supply their own
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
handle /ws {
|
||||
respond @blocked_ip "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
respond @blocked_ua "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# Everything that is not the API lives on the web host.
|
||||
handle {
|
||||
redir https://{$WEB_DOMAIN}{uri} permanent
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
# Type 2 Web: Frontend-only
|
||||
# Type 2, web host: frontend only. Copy of docker-deployment-type2/web/data/Caddy/CaddyFile/Caddyfile.proxy.
|
||||
{$DOMAIN} {
|
||||
handle {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
reverse_proxy beacon-web:80
|
||||
}
|
||||
@@ -113,6 +113,8 @@ iatas:
|
||||
# # (or use meshmapper.zones above)
|
||||
# # for the region border map; path is relative to
|
||||
# # this file's directory; validated at boot.
|
||||
# # In Docker, also mount the folder: add
|
||||
# # ./data/app/borders:/app/borders:ro to the app service.
|
||||
YYJ:
|
||||
name: Victoria International
|
||||
lat: 48.6469
|
||||
@@ -263,7 +265,7 @@ websocket:
|
||||
|
||||
# CORS configuration.
|
||||
# Controls which origins, methods and headers are allowed for cross-origin requests.
|
||||
# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted — appropriate
|
||||
# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted, appropriate
|
||||
# for a public read-only API. Restrict allowed_origins if you expose write endpoints.
|
||||
#cors:
|
||||
# allowed_origins:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Copy from app_config/.env.example and fill in your values.
|
||||
# See the README for instructions.
|
||||
@@ -205,7 +205,7 @@ channel_keys:
|
||||
- meshcore
|
||||
- ottawa
|
||||
- testing
|
||||
- towertest
|
||||
- beacontest
|
||||
# - name: ottawa-mesh
|
||||
# region: eastern-canada
|
||||
|
||||
@@ -307,7 +307,7 @@ websocket:
|
||||
|
||||
# CORS configuration.
|
||||
# Controls which origins, methods and headers are allowed for cross-origin requests.
|
||||
# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted — appropriate
|
||||
# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted, appropriate
|
||||
# for a public read-only API. Restrict allowed_origins if you expose write endpoints.
|
||||
#cors:
|
||||
# allowed_origins:
|
||||
|
||||
@@ -16,15 +16,15 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: tower
|
||||
POSTGRES_USER: beacon
|
||||
POSTGRES_PASSWORD: CHANGE_PASS
|
||||
POSTGRES_DB: tower
|
||||
POSTGRES_DB: beacon
|
||||
volumes:
|
||||
- ./data/postgres/:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tower"]
|
||||
test: ["CMD-SHELL", "pg_isready -U beacon"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,109 @@
|
||||
# Type 2: API on one host, web on another
|
||||
|
||||
Use this when the frontend should live on a different server from the database and ingest, or
|
||||
when more than one web frontend should share one API. If one server is enough, use the
|
||||
all-in-one stack in the [main README](../README.md#deploy-the-all-in-one-stack) instead.
|
||||
|
||||
```text
|
||||
docker-deployment-type2/
|
||||
├── server/ API (app), Postgres (db), Redis (redis), Caddy → https://api.example.com
|
||||
└── web/ web frontend, Caddy → https://beacon.example.com
|
||||
```
|
||||
|
||||
## What you need
|
||||
|
||||
- Two hosts with Docker and ports 80 and 443 open.
|
||||
- Two DNS names pointing at them, for example `api.example.com` and `beacon.example.com`.
|
||||
- A subscriber account on a MeshCore MQTT broker. See
|
||||
[Getting packets in](../docs/getting-packets-in.md).
|
||||
|
||||
## Server host
|
||||
|
||||
1. Clone this repo on the host and go to the server folder:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MeshCore-Beacon/beacon-docs.git
|
||||
cd beacon-docs/docker-deployment-type2/server
|
||||
```
|
||||
|
||||
2. Create `.env` from the shared template and fill in every `CHANGE_*` value. Only the `app`
|
||||
and `caddy` lines matter here; the `VITE_*` lines can be deleted. If you have one broker,
|
||||
clear `MQTT_BROKER_2_URL`.
|
||||
|
||||
```bash
|
||||
cp ../../app_config/.env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Set `DOMAIN` to the API hostname (`api.example.com`) and add one line this stack needs that
|
||||
the all-in-one one does not:
|
||||
|
||||
```
|
||||
WEB_DOMAIN=beacon.example.com
|
||||
```
|
||||
|
||||
Caddy redirects anything that is not `/api/*` or `/ws` to that host. The password inside
|
||||
`POSTGRES_DSN` must match `POSTGRES_PASSWORD` in `docker-compose.yml`.
|
||||
|
||||
3. Edit `data/app/config.yaml` for your network (see
|
||||
[Configuration](../docs/configuration.md#configyaml)). Because the web app will be served
|
||||
from another origin, browsers may only open the WebSocket if that origin is listed:
|
||||
|
||||
```yaml
|
||||
websocket:
|
||||
allowed_origins: [https://beacon.example.com]
|
||||
```
|
||||
|
||||
`server.trusted_proxies` is already set to the compose subnet so Beacon sees real client
|
||||
addresses through Caddy. `ingest.allow_countries` is set to `[CA]`; change it to your
|
||||
country or delete the `ingest` block, or nothing outside Canada is stored.
|
||||
|
||||
4. Point DNS for `DOMAIN` at this host and bring it up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`https://api.example.com/api/v1/brokers` should answer with your brokers.
|
||||
|
||||
## Web host
|
||||
|
||||
1. Clone this repo on the host and go to the web folder:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MeshCore-Beacon/beacon-docs.git
|
||||
cd beacon-docs/docker-deployment-type2/web
|
||||
```
|
||||
|
||||
2. Create `.env` from the template in that folder (`cp .env.example .env`) and set:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `DOMAIN` | the web hostname, `beacon.example.com` |
|
||||
| `BEACON_WEB_IMAGE` | `ghcr.io/meshcore-beacon/beacon-web:latest` to follow releases like the server half does. To pin instead, pin both halves to the same release line, for example `:2.0` here and `beacon-server:2.0` in `server/docker-compose.yml`. |
|
||||
| `VITE_API_BASE` | `https://api.example.com/api/v1` |
|
||||
| `VITE_WS_URL` | `wss://api.example.com/ws` |
|
||||
|
||||
The optional `VITE_*` values are described in
|
||||
[Configuration](../docs/configuration.md#web-environment-variables).
|
||||
|
||||
3. Point DNS for `DOMAIN` at this host and bring it up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `https://beacon.example.com`. If the page loads but nothing is live, the server host's
|
||||
`websocket.allowed_origins` is the first thing to check; see
|
||||
[Troubleshooting](../docs/troubleshooting.md#the-page-loads-but-nothing-is-live).
|
||||
|
||||
## How the two connect
|
||||
|
||||
The browser talks to the API host directly; the web host only serves files. That is why the
|
||||
API host has to allow the web origin for WebSocket. REST CORS allows any origin by default.
|
||||
Rate limits and connection caps work the same as in the all-in-one stack because each host's
|
||||
Caddy sets `X-Real-IP` and is trusted through `server.trusted_proxies`; see
|
||||
[Reverse proxy](../docs/reverse-proxy.md).
|
||||
|
||||
To change a `VITE_*` value later, edit the web host's `.env` and run
|
||||
`docker compose up -d beacon-web`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Behind Cloudflare (or another CDN), {remote_host} is the CDN's address. Trust its ranges
|
||||
# in a global block, then use {client_ip} instead of {remote_host} below and client_ip
|
||||
# instead of remote_ip in conf.d/blocklist.caddy:
|
||||
# {
|
||||
# servers {
|
||||
# trusted_proxies static <Cloudflare IPv4 and IPv6 ranges>
|
||||
# client_ip_headers CF-Connecting-IP
|
||||
# }
|
||||
# }
|
||||
{$DOMAIN} {
|
||||
# defines @blocked_ip and @blocked_ua; edit conf.d/blocklist.caddy then `caddy reload`
|
||||
import conf.d/blocklist.caddy
|
||||
|
||||
# JSON access log for the whole site (API, SPA and assets together, so per-IP joins are one file)
|
||||
log {
|
||||
output file /var/log/caddy/access.json {
|
||||
mode 0644
|
||||
roll_size 100MiB
|
||||
roll_keep 20
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
# opt-in: 403 API calls whose Referer names another site. Spoofable; enable only if the
|
||||
# logs show a specific foreign host with sustained volume.
|
||||
# @foreign_referer {
|
||||
# header Referer *
|
||||
# not header Referer https://{$DOMAIN}/*
|
||||
# }
|
||||
|
||||
handle /api/* {
|
||||
respond @blocked_ip "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
respond @blocked_ua "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
# respond @foreign_referer "Forbidden" 403
|
||||
reverse_proxy app:8080 {
|
||||
# beacon-server takes the client IP only from X-Real-IP, and only from peers in
|
||||
# server.trusted_proxies; overwrite it so clients can't supply their own
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
handle /ws {
|
||||
respond @blocked_ip "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
respond @blocked_ua "Forbidden" 403 {
|
||||
close
|
||||
}
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# Everything that is not the API lives on the web host.
|
||||
handle {
|
||||
redir https://{$WEB_DOMAIN}{uri} permanent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Manual block list for /api/* and /ws.
|
||||
# @blocked_ip: one IP or CIDR per remote_ip line. Keep at least one line: 192.0.2.0/24 is TEST-NET
|
||||
# (never routable) and is a safe placeholder.
|
||||
# @blocked_ua: one header_regexp line per scraper User-Agent signature (Go regexp; (?i) = case-insensitive).
|
||||
# The placeholder line never matches; keep it so the matcher is never empty.
|
||||
# Apply changes with:
|
||||
# docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
|
||||
# docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
@blocked_ip {
|
||||
remote_ip 192.0.2.0/24
|
||||
}
|
||||
@blocked_ua {
|
||||
header_regexp User-Agent "^BLOCKED-UA-PLACEHOLDER-NEVER-MATCHES$"
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
# Beacon configuration for the type 1 (all-in-one) Docker deployment.
|
||||
# Full reference: app_config/config.yaml.example.
|
||||
# Durations are Go strings (30s, 1h, 168h); negative values prevent startup
|
||||
# (observers.delete_after excepted, where nonpositive disables it).
|
||||
|
||||
# Public reads stay available. No key means admin routes return 503.
|
||||
# Prefer BEACON_API_KEY in the service environment; never commit a real key.
|
||||
# At least 16 characters with no inner whitespace (surrounding whitespace is
|
||||
# trimmed); a shorter key prevents startup.
|
||||
auth:
|
||||
api_key: ""
|
||||
|
||||
log:
|
||||
# Environment LOG_LEVEL / LOG_FORMAT override these values.
|
||||
level: info # debug, info, warn, error
|
||||
format: text # text or json
|
||||
|
||||
server:
|
||||
# Only these direct proxy peers may set the client IP through X-Real-IP.
|
||||
# The proxy must overwrite that header, not pass through client input.
|
||||
# Leave empty for direct access (logs a startup warning). Invalid/empty CIDR
|
||||
# entries prevent startup. A single host needs /32 for IPv4 or /128 for IPv6;
|
||||
# bare IP addresses are not accepted.
|
||||
# For Docker, list the actual proxy/compose network CIDR (e.g. 172.20.0.0/24).
|
||||
# For a local host proxy, use ["127.0.0.1/32", "::1/128"].
|
||||
# Direct-client proxy examples; X-Forwarded-For alone is not used:
|
||||
# Apache 2.4.10+ (mod_headers, in the proxy virtual host):
|
||||
# RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"
|
||||
# nginx (in the proxy location):
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# Caddy (inside the reverse_proxy block):
|
||||
# header_up X-Real-IP {http.request.remote.host}
|
||||
# With additional proxy hops, first configure their trust at the edge proxy
|
||||
# and pass its validated client address rather than a client-supplied header.
|
||||
# Caddy reaches the app over the compose network (docker-compose.yml pins 172.30.0.0/24).
|
||||
trusted_proxies: [172.30.0.0/24]
|
||||
|
||||
# Applies to /api/v1/* only. Both counters are local to this Beacon instance.
|
||||
# Set trusted_proxies correctly before using a reverse proxy, or clients share
|
||||
# the proxy's budget. IPv6 clients share a /64 budget to prevent address rotation.
|
||||
ratelimit:
|
||||
enabled: true
|
||||
requests_per_minute: 300
|
||||
# Maximum requests in a one-second sliding window, not token-bucket capacity.
|
||||
# Omitted/zero defaults to requests_per_minute. Negative limits prevent startup.
|
||||
# A 429 returns error.code=rate_limited and Retry-After (1 or 60 seconds).
|
||||
burst: 300
|
||||
|
||||
# MeshMapper integrations (optional, no API key). Import transport scopes and
|
||||
# IATA border outlines from MeshMapper instead of maintaining scopes: and
|
||||
# iatas.*.borderFile by hand.
|
||||
#
|
||||
# Scope-name discovery. Manual scopes remain authoritative.
|
||||
# Every known IATA, including ones auto-created from traffic, is looked up in its
|
||||
# country's zone list (shared with zones below) and its site's get_scopes.php
|
||||
# catalogue is imported; IATAs MeshMapper doesn't list are skipped. No sources
|
||||
# to configure; a legacy sources: list is ignored with a warning.
|
||||
# Catalogues covering more than one IATA are rejected.
|
||||
# 64 names per catalogue, 64 KiB per response; no page scraping.
|
||||
# Catalogues and ETags persist in PostgreSQL. Failed refreshes retain known names.
|
||||
# Refresh success/failure/freshness is logged under component=meshmapper.scopes.
|
||||
# Turning scopes off deactivates imported matching keys on restart;
|
||||
# historical scope identities and recorded evidence are retained.
|
||||
# Region-filtered scope lists and scope stats show imported names only under the
|
||||
# IATAs whose catalogue lists them; IATAs without a catalogue show manual scopes only.
|
||||
#meshmapper:
|
||||
# scopes:
|
||||
# enabled: false
|
||||
# refresh_interval: 1h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
|
||||
#
|
||||
# Region boundaries (Zones API). Every known IATA, including ones auto-created
|
||||
# from traffic, is looked up in its country's zone list, then its get_geojson.php
|
||||
# outline is imported; new IATAs are picked up while running. An imported boundary
|
||||
# overrides that IATA's borderFile on the border map and for nodes.mark_foreign.
|
||||
# A missing or failed boundary keeps the last good import, or the borderFile when
|
||||
# there is none. Disabling drops every import on restart. Logged under
|
||||
# component=meshmapper.zones.
|
||||
# Each country's zone list is fetched at most once a day (MeshMapper allows one
|
||||
# get_zones.php per country per 23.5h) and saved, so restarts don't refetch it.
|
||||
# Failed or rate-limited requests also wait out the limit, or Retry-After if longer.
|
||||
# Listed IATAs also take their display name and approximate location from the
|
||||
# zone list, unless iatas: below sets a name or lat/lng for them.
|
||||
# import_groups turns MeshMapper zone groups (e.g. ONQC: YOW, YQB, YUL) into
|
||||
# regions, from the zone lists already fetched. A group is imported when at least
|
||||
# one member is heard by an observer or configured here, with its full member list (members Beacon hasn't
|
||||
# heard yet are added as IATAs); slug = lowercased group code, centered on the
|
||||
# members' average location, listed after the regions below. A region below with
|
||||
# the same slug wins.
|
||||
# Imported regions are removed when the group disappears or stops qualifying,
|
||||
# and all of them when import_groups is turned off. Requires zones.enabled.
|
||||
# zones:
|
||||
# enabled: false
|
||||
# import_groups: false
|
||||
# refresh_interval: 24h # 24h-168h (one get_geojson.php per region per 23.5h); one request per 15s tick, ETag-conditional
|
||||
#
|
||||
# Public hashtag channels (get_channels.php). Every known IATA that MeshMapper
|
||||
# lists is looked up on its site, the same way as scopes. Each key and hash is
|
||||
# checked against the name before use; a list with any mismatch is rejected and
|
||||
# the last good one kept. Imported channels decrypt like configured ones (history
|
||||
# is backfilled when a key first appears) and are listed under that IATA when
|
||||
# /channels is filtered by region. Saved in PostgreSQL across restarts; failures
|
||||
# wait out the 23.5h limit. Turning it off removes the region listing on restart.
|
||||
# Logged under component=meshmapper.channels.
|
||||
# channels:
|
||||
# enabled: false
|
||||
# refresh_interval: 24h # 24h-168h (one get_channels.php per region per 23.5h)
|
||||
|
||||
iatas:
|
||||
YVR:
|
||||
name: Vancouver International
|
||||
lat: 49.1967
|
||||
lng: -123.1815
|
||||
YYJ:
|
||||
name: Victoria International
|
||||
lat: 48.6469
|
||||
lng: -123.4260
|
||||
YYC:
|
||||
name: Calgary International
|
||||
lat: 51.1139
|
||||
lng: -114.0200
|
||||
YEG:
|
||||
name: Edmonton International
|
||||
lat: 53.3097
|
||||
lng: -113.5797
|
||||
YOW:
|
||||
name: Ottawa International
|
||||
lat: 45.3225
|
||||
lng: -75.6692
|
||||
YYZ:
|
||||
name: Toronto Pearson
|
||||
lat: 43.6777
|
||||
lng: -79.6248
|
||||
XCM:
|
||||
name: Chatham-Kent Airport
|
||||
lat: 42.3064
|
||||
lng: -82.0819
|
||||
YCD:
|
||||
name: Nanaimo Airport
|
||||
lat: 49.0544
|
||||
lng: -123.8700
|
||||
YKF:
|
||||
name: Region of Waterloo International
|
||||
lat: 43.4608
|
||||
lng: -80.3786
|
||||
YLK:
|
||||
name: Lake Simcoe Regional
|
||||
lat: 44.4853
|
||||
lng: -79.5556
|
||||
YPA:
|
||||
name: Prince Albert (Glass Field)
|
||||
lat: 53.2144
|
||||
lng: -105.6731
|
||||
YQA:
|
||||
name: Muskoka Airport
|
||||
lat: 44.9764
|
||||
lng: -79.3067
|
||||
YQB:
|
||||
name: Quebec City Jean Lesage International
|
||||
lat: 46.7911
|
||||
lng: -71.3933
|
||||
YQL:
|
||||
name: Lethbridge Airport
|
||||
lat: 49.6303
|
||||
lng: -112.7997
|
||||
YSJ:
|
||||
name: Saint John Airport
|
||||
lat: 45.3161
|
||||
lng: -65.8903
|
||||
YTR:
|
||||
name: CFB Trenton
|
||||
lat: 44.1189
|
||||
lng: -77.5281
|
||||
YUL:
|
||||
name: Montreal-Trudeau International
|
||||
lat: 45.4706
|
||||
lng: -73.7408
|
||||
|
||||
# Hand-written regions; optional with meshmapper.zones.import_groups. Seeding
|
||||
# sets each region's members to exactly this list.
|
||||
regions:
|
||||
- slug: western-canada
|
||||
name: Western Canada
|
||||
display_order: 1
|
||||
center_lat: 51.0
|
||||
center_lng: -114.0
|
||||
zoom_level: 5
|
||||
iatas: [YVR, YYJ, YYC, YEG]
|
||||
|
||||
- slug: eastern-canada
|
||||
name: Eastern Canada
|
||||
display_order: 2
|
||||
center_lat: 45.0
|
||||
center_lng: -75.0
|
||||
zoom_level: 5
|
||||
iatas: [YOW, YYZ]
|
||||
|
||||
channel_keys:
|
||||
# Hashtag channels: Beacon derives the PSK automatically from the tag name.
|
||||
# secret = SHA256("#tag")[:16], channel_hash = SHA256(secret)[0]
|
||||
# Hashtag names should be provided without the # prefix.
|
||||
# Beacon prepends # automatically before deriving the key.
|
||||
# A plain name is listed under every region; add region: <slug> (a configured
|
||||
# region) to list it only there when /channels is filtered by region.
|
||||
hashtags:
|
||||
- meshcore
|
||||
- ottawa
|
||||
- testing
|
||||
- beacontest
|
||||
# - name: ottawa-mesh
|
||||
# region: eastern-canada
|
||||
|
||||
# Explicit keys: provide the channel hash (hex) and key (hex) directly.
|
||||
# An optional name can be set for display purposes.
|
||||
keys:
|
||||
"11":
|
||||
key: "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
name: "Public"
|
||||
# region: eastern-canada # optional, as for hashtags
|
||||
|
||||
# Regional transport scopes for matching TRANSPORT_FLOOD packets.
|
||||
# Plain names have # prepended automatically (e.g. "bc" → "#bc").
|
||||
# region (required) is a configured region slug; region-filtered scope lists
|
||||
# and scope stats show the scope under that region's IATAs. Matching stays global.
|
||||
# Optional when meshmapper.scopes covers your regions.
|
||||
scopes:
|
||||
- name: bc
|
||||
region: western-canada
|
||||
- name: "#west"
|
||||
region: western-canada
|
||||
|
||||
telemetry:
|
||||
retention: 744h # 31 days
|
||||
resolution: 1h # store one snapshot per observer per hour
|
||||
|
||||
# Optional authenticated database/config downloads. Disabled unless explicitly enabled.
|
||||
# Requires auth.api_key, a supported PostgreSQL URL and a compatible pg_dump client.
|
||||
# A failed prerequisite leaves this feature unavailable; ingest and other APIs continue.
|
||||
#backup:
|
||||
# enabled: false
|
||||
|
||||
# Also covers observations and channel messages.
|
||||
packets:
|
||||
retention: 168h # 7 days
|
||||
|
||||
# Hourly analytics rollups (stats endpoints). Rollups outlive raw packets; this is also the
|
||||
# longest window /stats/series and the other historical stats endpoints accept.
|
||||
analytics:
|
||||
rollup_retention: 2160h # 90 days
|
||||
|
||||
# Presence write coalescing: observer last_seen and packet last_heard_at bumps are
|
||||
# batched in memory and flushed on an interval. An unclean shutdown loses at most
|
||||
# one interval of presence freshness.
|
||||
#presence:
|
||||
# flush_interval: 30s # default: 30s
|
||||
# packet_ttl: 30s # how long a quiet packet stays coalesced (default: 30s)
|
||||
|
||||
# Known-route retention. Routes are distilled packet history (the path a packet
|
||||
# took, hop by hop), so they may outlive packets.retention; there is no required
|
||||
# relationship between the two.
|
||||
routes:
|
||||
# How long a route is kept after it was last observed. Every new observation
|
||||
# of the same path resets this clock, so routes the mesh still uses never
|
||||
# expire -- only paths nothing has traveled for this long age out.
|
||||
retention: 336h # 14 days
|
||||
# Shorter window for rarely-seen routes: one observed fewer than
|
||||
# min_observations times in total is dropped once it goes unobserved for
|
||||
# this long. Must be shorter than retention, or it never applies.
|
||||
grace: 168h # 7 days
|
||||
# Lifetime observation count a route must reach to earn the full retention
|
||||
# window. Below it a route is treated as flood noise -- a path recorded once
|
||||
# or twice and never confirmed -- and only kept for the grace window. The
|
||||
# count never resets, so once a route crosses this bar it stays in the
|
||||
# retention tier for good.
|
||||
min_observations: 3
|
||||
|
||||
websocket:
|
||||
max_connections_per_ip: 5 # default: 5
|
||||
# Upgrade attempts, including failed handshakes; zero/omitted defaults to 10.
|
||||
# IPv6 clients share a /64 attempt budget. Negative values prevent startup.
|
||||
# Rate exhaustion rejects the handshake with 429 and Retry-After: 60.
|
||||
# Within this rate budget, a full concurrent cap accepts then closes with 1013
|
||||
# before hello, allowing browsers to back off without evicting existing clients.
|
||||
max_connects_per_minute: 10
|
||||
# Other sites allowed to open /ws. Exact scheme://host[:port], or
|
||||
# scheme://*.domain[:port] for every subdomain of domain at any depth. The
|
||||
# wildcard never matches the apex itself, and scheme and port stay exact.
|
||||
#allowed_origins:
|
||||
# - https://example.com
|
||||
# - https://*.example.com
|
||||
|
||||
# Node staleness, deletion, and clock-drift thresholds.
|
||||
#nodes:
|
||||
# mark_foreign: false # opt in to possiblyForeign on repeater nodes
|
||||
# # uses the union of configured iatas.*.borderFile polygons,
|
||||
# # with meshmapper.zones boundaries replacing them; requires
|
||||
# # a valid border file or meshmapper.zones; no packet filtering
|
||||
# stale_threshold: 24h # default: 24h
|
||||
# delete_after: 720h # default: 30 days
|
||||
# clock_drift_threshold: 5m # default: 5m
|
||||
|
||||
# Optional observer age-out. Omitted or nonpositive leaves observers intact.
|
||||
# Enable only with one Beacon ingest process for this database.
|
||||
# At most 1000 old observers are deleted per background.cleanup interval;
|
||||
# retained observations, telemetry and ownership records prevent deletion.
|
||||
#observers:
|
||||
# delete_after: 720h # opt in to 30 days unseen
|
||||
|
||||
# CORS configuration.
|
||||
# Controls which origins, methods and headers are allowed for cross-origin requests.
|
||||
# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted, appropriate
|
||||
# for a public read-only API. Restrict allowed_origins if you expose write endpoints.
|
||||
#cors:
|
||||
# allowed_origins:
|
||||
# - "https://app.example.com"
|
||||
# allowed_methods: [GET, HEAD, OPTIONS, POST, PUT, DELETE] # admin UI writes
|
||||
# allowed_headers: [Accept, Authorization, Content-Type]
|
||||
# allow_credentials: false
|
||||
# max_age: 300
|
||||
|
||||
# Redis caching layer.
|
||||
# If REDIS_ADDR is not set, caching is disabled and all reads go directly to PostgreSQL.
|
||||
# TTLs are specified as duration strings e.g. "30m", "1h", "2h".
|
||||
# Per-category TTLs override the global ttl. Any category left unset inherits ttl.
|
||||
cache:
|
||||
# Global default TTL for all cached responses. Defaults to 1h if not set.
|
||||
ttl: "1h"
|
||||
ttls:
|
||||
# Aggregated stats endpoints (overview, observations, breakdowns, top nodes/observers,
|
||||
# radio presets, scope stats). Backed by hourly rollups; keys change when a new hour rolls.
|
||||
stats: "1h"
|
||||
# Mostly-static reference data (IATAs, regions, scopes).
|
||||
# Only changes when new observers arrive or config is reseeded.
|
||||
reference: "1h"
|
||||
# Individual node detail. Also explicitly invalidated on upsert.
|
||||
nodes: "1h"
|
||||
# Individual observer detail. Also explicitly invalidated on upsert.
|
||||
observers: "1h"
|
||||
|
||||
# Geographic ingest filter (optional).
|
||||
# Drop packets from observers outside the specified area at ingest time.
|
||||
# Country codes are ISO 3166-1 alpha-2 (e.g. CA, US, GB).
|
||||
# Continent codes: AF (Africa), AN (Antarctica), AS (Asia),
|
||||
# EU (Europe), NA (North America), OC (Oceania), SA (South America).
|
||||
# If both are set an IATA passes if it matches either (OR semantics).
|
||||
# Omit this section entirely to accept packets from all IATAs (default).
|
||||
ingest:
|
||||
allow_countries: [CA]
|
||||
# allow_continents: [NA]
|
||||
|
||||
# Background task intervals.
|
||||
# Shorter intervals are useful during initial deployment to confirm data is
|
||||
# flowing. Back off to 1h or more once stable.
|
||||
#background:
|
||||
# view_refresh: 1h # rebuilds radio presets; default: 1h
|
||||
# reconfirm: 1h # default: 1h
|
||||
# cleanup: 1h # default: 1h
|
||||
@@ -0,0 +1,72 @@
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/meshcore-beacon/beacon-server:latest
|
||||
env_file: .env
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ./data/app/config.yaml:/app/config.yaml:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: beacon
|
||||
POSTGRES_PASSWORD: CHANGE_PASS
|
||||
POSTGRES_DB: beacon
|
||||
volumes:
|
||||
- ./data/postgres/:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U beacon"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- ./data/redis/:/data
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:?DOMAIN environment variable is required}
|
||||
- WEB_DOMAIN=${WEB_DOMAIN:?WEB_DOMAIN environment variable is required}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data/Caddy/CaddyFile/Caddyfile.proxy:/etc/caddy/Caddyfile
|
||||
- ./data/Caddy/conf.d:/etc/caddy/conf.d:ro
|
||||
- ./data/Caddy/logs:/var/log/caddy
|
||||
- ./data/Caddy/data:/data/caddy/
|
||||
- ./data/Caddy/config:/config/caddy/
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# Fixed subnet so data/app/config.yaml can trust Caddy in server.trusted_proxies.
|
||||
# Pick another private /24 if this one is already in use on the host.
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.30.0.0/24
|
||||
@@ -0,0 +1,34 @@
|
||||
# Domain for Caddy TLS
|
||||
DOMAIN=web.example.com
|
||||
|
||||
# Image to run. :latest follows releases, like the server half's compose file. To pin, use the
|
||||
# same release line on both halves (e.g. :2.0 here and beacon-server:2.0 on the server host).
|
||||
BEACON_WEB_IMAGE=ghcr.io/meshcore-beacon/beacon-web:latest
|
||||
|
||||
# BEACON REST API base URL
|
||||
VITE_API_BASE=https://api.example.com/api/v1
|
||||
|
||||
# WebSocket endpoint for live packets
|
||||
VITE_WS_URL=wss://api.example.com/ws
|
||||
|
||||
# Default map view for the "All" tab (leave both unset for a world view). VITE_MAP_CENTER is
|
||||
# decimal "lat,lon", VITE_MAP_ZOOM is 0-22. The example below is a Canada-wide view.
|
||||
VITE_MAP_CENTER=52.5,-96.8
|
||||
VITE_MAP_ZOOM=3.2
|
||||
|
||||
# Hide tabs completely (comma list, case-insensitive). Leave empty to show all.
|
||||
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
|
||||
VITE_DISABLED_TABS=
|
||||
|
||||
# Only offer these themes in the picker (comma list of theme ids). Leave empty for the default set.
|
||||
# Listing a hidden theme (meshmapper_dark, meshmapper_light) is the only way to enable it.
|
||||
VITE_ENABLED_THEMES=
|
||||
|
||||
# Custom app name shown in the top-left wordmark. Default: BEACON.
|
||||
VITE_APP_NAME=
|
||||
|
||||
# Skip the once-per-session load splash. Set to true to skip; leave empty to show it.
|
||||
VITE_SKIP_SPLASH=
|
||||
|
||||
# Notice above the header (e.g. on a dev instance); [label](url) and bare URLs become links. Blank = none.
|
||||
VITE_BANNER=
|
||||
@@ -0,0 +1,3 @@
|
||||
{$DOMAIN} {
|
||||
reverse_proxy beacon-web:80
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:?DOMAIN environment variable is required}
|
||||
volumes:
|
||||
- ./data/Caddy/CaddyFile/Caddyfile.proxy:/etc/caddy/Caddyfile
|
||||
- ./data/Caddy/data:/data/caddy/
|
||||
- ./data/Caddy/config:/config/caddy/
|
||||
restart: unless-stopped
|
||||
|
||||
beacon-web:
|
||||
image: ${BEACON_WEB_IMAGE:?Set BEACON_WEB_IMAGE to a reviewed release tag or digest}
|
||||
environment:
|
||||
- VITE_API_BASE=${VITE_API_BASE:?VITE_API_BASE environment variable is required}
|
||||
- VITE_WS_URL=${VITE_WS_URL:?VITE_WS_URL environment variable is required}
|
||||
# Optional "All" map view; unset = world overview
|
||||
- VITE_MAP_CENTER=${VITE_MAP_CENTER:-}
|
||||
- VITE_MAP_ZOOM=${VITE_MAP_ZOOM:-}
|
||||
# Optional branding/customization; unset = all tabs, hidden themes hidden, name "BEACON"
|
||||
- VITE_DISABLED_TABS=${VITE_DISABLED_TABS:-}
|
||||
- VITE_ENABLED_THEMES=${VITE_ENABLED_THEMES:-}
|
||||
- VITE_APP_NAME=${VITE_APP_NAME:-BEACON}
|
||||
# Skip the once-per-session load splash; unset = shown
|
||||
- VITE_SKIP_SPLASH=${VITE_SKIP_SPLASH:-}
|
||||
# Notice above the header, e.g. on a dev instance; unset = none
|
||||
- VITE_BANNER=${VITE_BANNER:-}
|
||||
restart: unless-stopped
|
||||
@@ -122,6 +122,29 @@ A packet summary:
|
||||
|
||||
`summary` is the advert name, `ACK <checksum>`, `TRACE <tag>` or `PING <tag>`, and is omitted for other types. `scope` is the matched transport scope, omitted when none. `latestObserver` can also carry `resolvedSource` / `resolvedDestination` (see below).
|
||||
|
||||
#### Packet summaries
|
||||
|
||||
Packet lists, regional lists, reconnect backfill and live `packetObservation` events carry the
|
||||
optional `summary` string. It comes from the packet's own stored metadata, never from a node's
|
||||
current name or a fresh decode.
|
||||
|
||||
| Payload | Summary | Source |
|
||||
|---|---|---|
|
||||
| ADVERT | Advertised name | Saved `appData.name` / verified decoded advert |
|
||||
| ACK | `ACK 01020304` | Four-byte acknowledgement checksum |
|
||||
| TRACE | `TRACE efbeadde` | Trace tag |
|
||||
| TRACE classified as PING | `PING efbeadde` | Trace tag |
|
||||
|
||||
- References are eight lowercase hex characters in the same byte order as packet detail's
|
||||
`checksum` / `traceTag`. Zero is valid. Extended ACK retry bytes do not change the checksum.
|
||||
- They are short references, not unique packet IDs or proof of delivery. No message body, trace
|
||||
auth code or inferred sender or recipient is included.
|
||||
- The text is projected from saved JSON in the existing list queries: no extra column, backfill
|
||||
or per-packet lookup. A stored value of the wrong type or not exactly eight hex characters
|
||||
omits the field.
|
||||
- Other payload types have no summary yet. A missing summary does not mean the packet is
|
||||
invalid.
|
||||
|
||||
### Packet detail
|
||||
|
||||
```
|
||||
@@ -226,6 +249,29 @@ Adverts with a bad signature are not decoded and have no `parsedPayload`.
|
||||
|
||||
`/nodes` params: `type` (1 companion, 2 repeater, 3 room server, 4 sensor) or `typeName`, the location filters, `name` (partial, case-insensitive), `scope`, `pubkey` (exact hex), `pubkeyPrefix`, `supportsMultibytePaths`, `supportsMultibyteTraces`, `neighbors` (adds `neighborIds`), `cursor`, `limit`. Summaries include `stale` (not seen within `nodes.stale_threshold`) and, when `nodes.mark_foreign` is on, `possiblyForeign`.
|
||||
|
||||
#### Foreign repeater indication
|
||||
|
||||
With `nodes.mark_foreign: true`, repeater nodes carry `possiblyForeign`. It is `true` when the
|
||||
repeater's reported position is outside the union of all configured `iatas.<code>.borderFile`
|
||||
polygons, with `meshmapper.zones` borders replacing them where MeshMapper has one. IATAs without
|
||||
a border add no area, and airport coordinates are not used. Enabling it with no border source, a
|
||||
missing file or invalid geometry stops startup. Border changes need a restart.
|
||||
|
||||
- Points on an edge (including hole edges) are inside; hole interiors are outside.
|
||||
- Other roles and missing or invalid positions get no value. A 0/0 advert clears the stored
|
||||
position.
|
||||
- It is a hint from the reported position, not proof of origin. Ingest, heard-in IATAs and route
|
||||
matching are unaffected.
|
||||
- Computed at read time, so existing nodes need no backfill. The field is omitted when the
|
||||
feature is off (the default).
|
||||
- In `nodeUpdate`, it is a boolean when the advert carries a position, `null` when the position
|
||||
is 0/0 or invalid or the node stops being a repeater, and omitted to keep the previous value.
|
||||
`lat`/`lng` follow the same rule.
|
||||
|
||||
GeoJSON uses longitude, latitude order. Split borders that cross the antimeridian into
|
||||
MultiPolygons per [RFC 7946 section 3.1.9](https://www.rfc-editor.org/rfc/rfc7946#section-3.1.9);
|
||||
edges spanning more than 180 degrees are rejected.
|
||||
|
||||
### Observers
|
||||
|
||||
| Endpoint | Notes |
|
||||
@@ -267,7 +313,7 @@ additionally cap `range` at `48h`, and an unknown observer is a `404`. An option
|
||||
at most 30 days old) ends the window earlier.
|
||||
Buckets are aligned to the clock in UTC (a `15m` bucket always starts at :00, :15, :30 or :45) and the
|
||||
window start is rounded up to the next bucket boundary, so bucket starts are stable across requests.
|
||||
Only non-empty buckets are returned — gaps in `points` mean the observer heard nothing in that bucket,
|
||||
Only non-empty buckets are returned. Gaps in `points` mean the observer heard nothing in that bucket,
|
||||
and the client is expected to render them as zero, except for uncovered hours (below).
|
||||
|
||||
Hourly intervals (`1h`, `6h`, `24h`) read the analytics rollups up to the newest complete rollup hour,
|
||||
@@ -291,7 +337,15 @@ only partially costed, and observations recorded without radio parameters are ne
|
||||
|
||||
Sub-hour intervals read live observation rows and are always current. The response also reports
|
||||
`windowStart`, `windowEnd`, `generatedAt`, `source` (`raw` or `hourly`) and a `summary` of recorded
|
||||
packets and the last complete hour.
|
||||
packets and the last complete hour:
|
||||
|
||||
- `summary.recordedPackets` counts stored observations in the window; repeated broker deliveries of
|
||||
the same packet count once. Unknown payload types show as `-1`.
|
||||
- Freshness fields (`lastCompleteHour` with its start and end, `latestRecordedAt`) are measured at
|
||||
`generatedAt`, even for historical `until` requests.
|
||||
- `until` lines up two observers' charts.
|
||||
- Missing records do not prove downtime. The observer's `observationCount` is a legacy cumulative
|
||||
presence counter (including status and neighbour events), not a packet total.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -398,7 +452,7 @@ To page `/routes`, send the last item's `lastSeen` as `cursor` and its `id` as `
|
||||
|
||||
`/routes/cross` returns `[{ sourceSegment, crossHop, targetSegment, totalHops }]`, where the segments are hop arrays as above and `crossHop` is `{ fromNode, toNode, fromIata, toIata, lastSeen }`.
|
||||
|
||||
The observations endpoint returns `{ items, hasMore, nextPageCursor, route, windowStart, windowEnd, generatedAt, matchType, matchAvailable, hashSize, pathBytes }`. Each item is `{ id, packetHash, observerId, observerName, heardAt, payloadType, payloadTypeName, rssi, snr }`.
|
||||
The observations endpoint returns `{ items, hasMore, nextPageCursor, route, windowStart, windowEnd, generatedAt, matchType, matchAvailable, hashSize, pathBytes }`. Each item is `{ id, packetHash, observerId, observerName, heardAt, payloadType, payloadTypeName, rssi, snr }`. It matches on the route's full path bytes, hash size and hop count within its IATA; `limit` is capped at 200. Evidence expires with raw packets, so a route can outlive every observation that produced it.
|
||||
|
||||
### Stats
|
||||
|
||||
@@ -542,14 +596,32 @@ All under `/api/v1/admin/`, bearer key required (see [Auth](#auth)).
|
||||
|
||||
| Endpoint | Notes |
|
||||
|---|---|
|
||||
| `GET /admin/config` | Running CORS settings, whether auth is configured, and the broker count. No secrets. |
|
||||
| `GET /admin/config` | Running CORS settings with defaults applied, `auth.configured`, and `ingest.broker_count` (configured broker workers, not connection status). No credentials, broker addresses, channel material or database settings. |
|
||||
| `PUT /admin/config` | Replaces `cors.allowed_origins` until the next restart. JSON body up to 16 KiB. |
|
||||
| `GET /admin/accounts`, `POST /admin/accounts` | List or create operator account records (name only; not logins). |
|
||||
| `GET /admin/accounts/{id}`, `DELETE /admin/accounts/{id}` | Fetch or deactivate one. |
|
||||
| `GET /admin/backup` | Streams a private `.tar.gz` of the database and saved config. Off unless `backup.enabled: true`; needs `pg_dump` in the server's runtime. One export at a time (`409`), `504` on timeout, `507` over the size limit. Contains secrets. |
|
||||
|
||||
Backup details are in beacon-server's
|
||||
[`docs/backup-export.md`](https://github.com/MeshCore-Beacon/beacon-server/blob/main/docs/backup-export.md).
|
||||
`PUT /admin/config` accepts only `{"cors":{"allowed_origins":[...]}}` and applies the new list
|
||||
immediately. It is runtime-only: nothing is written to disk, and a restart reloads the file. The
|
||||
response carries `config`, `persisted: false` and `requires_restart: false`. Send 1 to 32 ASCII
|
||||
http(s) origins of at most 512 bytes each, with an optional single hostname wildcard; a lone `*`
|
||||
allows all. Empty or null lists, paths, queries, credentials, control characters and unknown
|
||||
fields are rejected. Concurrent updates apply one at a time; requests already in flight may see
|
||||
the old list.
|
||||
|
||||
`POST /admin/accounts` takes `{"name": "..."}` in a body of at most 4 KiB. The name is trimmed,
|
||||
case-sensitive, 1 to 128 characters with no control characters, and unique among active
|
||||
accounts. `DELETE` deactivates: `204`, or `404` if missing, `409` if already inactive. The name
|
||||
can then be reused. The list returns active and inactive accounts, newest first, unpaginated.
|
||||
|
||||
Browser admin clients need the matching methods in `cors.allowed_methods`. The default
|
||||
`GET, HEAD, OPTIONS` is read-only. For an admin UI, use
|
||||
`[GET, HEAD, OPTIONS, POST, PUT, DELETE]`, restrict `cors.allowed_origins` to that UI and allow
|
||||
the `Authorization` and `Content-Type` headers, or preflight blocks requests that work from
|
||||
curl. CORS controls browser access, not authentication.
|
||||
|
||||
Backup details are in [Backup and export](backup-export.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -748,13 +820,13 @@ Connections with `includeRepeats` also get repeats, which have `packet.isRepeat:
|
||||
|
||||
### Backpressure and reconnection
|
||||
|
||||
Each connection has a 256-event send buffer. When it is full, the server drops the oldest queued event, discards the new one, and queues a `lagged` notice:
|
||||
Each connection has a 256-event send buffer. When it is full, the server evicts the oldest queued event to make room and then queues the new one; if that still fails, the new event is dropped too. A `lagged` notice follows with the count:
|
||||
|
||||
```json
|
||||
{ "v": 1, "type": "lagged", "droppedCount": 1, "since": 1747665440000 }
|
||||
```
|
||||
|
||||
Each notice reports one drop; `since` is when the notice was sent. Several can arrive in a burst. Clients should treat a `lagged` notice as a gap and refresh the affected view over REST.
|
||||
`droppedCount` is 1 or 2 per notice; `since` is when the notice was sent. Several can arrive in a burst. Clients should treat a `lagged` notice as a gap and refresh the affected view over REST.
|
||||
|
||||
Reconnection is the client's responsibility. On any disconnect, the client should:
|
||||
1. Reconnect with backoff (1s, 2s, 5s, 10s, 30s cap)
|
||||
@@ -789,4 +861,4 @@ The protocol doesn't need to know about backgrounding; the client just treats re
|
||||
|
||||
- **packetObservation payload size.** With `resolvePath` on, events carry the full resolved path with node coordinates, which can be 1-2 KB each in heavy traffic. Clients that only need counts should leave it off and fetch detail over REST.
|
||||
|
||||
(See [Questions and Answers](high_level_design.md#questions-and-answers) in the high level design for resolved items.)
|
||||
(See [Questions and Answers](high-level-design.md#questions-and-answers) in the high level design for resolved items.)
|
||||
@@ -0,0 +1,124 @@
|
||||
# Backup and export
|
||||
|
||||
Beacon exports its PostgreSQL database and saved `config.yaml` as a private, versioned
|
||||
`.tar.gz`, either with the standalone `beacon-backup` command or an opt-in admin download.
|
||||
`beacon-backup -verify` checks an archive offline. There is no import, scheduling or remote
|
||||
storage.
|
||||
|
||||
## Archive format 1
|
||||
|
||||
Exactly three regular files:
|
||||
|
||||
- `manifest.json`: format and exporter version, creation time, payload sizes and SHA-256
|
||||
hashes, and the exclusions below.
|
||||
- `database.sql`: one consistent `pg_dump` snapshot (schema, data, migration journal),
|
||||
without ownership, ACLs or tablespaces so it restores under any role.
|
||||
- `config.yaml`: the saved file byte-for-byte, read just before the snapshot. Avoid config
|
||||
edits during an export.
|
||||
|
||||
**It is sensitive and unencrypted** (keys, message data); keep it private. It is not a full
|
||||
recovery package: `.env` and environment variables, `borderFile` and TLS files, runtime-only
|
||||
changes, PostgreSQL roles and cluster settings, Redis and deployment files are not included.
|
||||
|
||||
## Requirements and limits
|
||||
|
||||
- `pg_dump` must be in the **same runtime** as the exporter (the CLI's environment, or the
|
||||
server's PATH for the download), with a major version at least as new as the database's. A client on
|
||||
the Docker host or in another container doesn't count.
|
||||
- The Docker image ships the PostgreSQL 16 client. Its Alpine 3.19 base has no 17/18
|
||||
packages, so a newer database needs a different runtime, not just `POSTGRES_CLIENT_MAJOR`.
|
||||
- The temp directory (`TMPDIR`) needs about twice the SQL limit free. A small tmpfs won't do.
|
||||
- The output filesystem must support hard links (used to publish atomically). Existing
|
||||
destinations, symlinks included, are never replaced. Files are 0600 and staging dirs 0700;
|
||||
on Windows, restrict the directory ACLs yourself.
|
||||
|
||||
| Limit | Default |
|
||||
| --- | --- |
|
||||
| Timeout | 10 min (`-timeout`) |
|
||||
| Uncompressed SQL | 1 GiB (`-max-bytes`, up to 1 TiB) |
|
||||
| Saved YAML / manifest | 1 MiB / 64 KiB |
|
||||
| Compressed archive (verify) | SQL limit + 1% + 2 MiB |
|
||||
| Lock wait | 5 s |
|
||||
|
||||
Failures, timeouts and interrupts clean up and publish nothing. A SIGKILL or power loss can
|
||||
leave a private staging dir: `.beacon-backup-*` next to the CLI output, or
|
||||
`beacon-download-*` in the server's temp dir, which Beacon sweeps on its next start.
|
||||
|
||||
## CLI export
|
||||
|
||||
In a beacon-server clone:
|
||||
|
||||
```sh
|
||||
go build ./cmd/beacon-backup
|
||||
./beacon-backup -config /private/config.yaml -output /private/beacon-20260913.tar.gz
|
||||
```
|
||||
|
||||
Connection comes only from the standard libpq environment (`PGHOST`, `PGPORT`,
|
||||
`PGDATABASE`, `PGUSER`, TLS settings); `PGDATABASE` is required. Keep the password in a 0600
|
||||
`PGPASSFILE` or a service file. The command ignores `.env` and `POSTGRES_DSN`, runs no
|
||||
migrations, and never prints `pg_dump` stderr since it can leak private data. If it fails,
|
||||
check the client version, privileges, connection settings and free space.
|
||||
|
||||
## Download API
|
||||
|
||||
`GET /api/v1/admin/backup` streams a fresh archive. Enable it with `backup.enabled: true` and
|
||||
the admin bearer key, then restart. Send the key only in the `Authorization` header over
|
||||
HTTPS; the endpoint takes no query or body. Leave it off on public previews.
|
||||
|
||||
At startup Beacon checks `pg_dump --version` against the server. If that or another
|
||||
prerequisite fails, it logs why and disables only backup; fix it and restart.
|
||||
|
||||
**Connection.** The download uses `POSTGRES_DSN`, which must be a single-host `postgres://`
|
||||
URL with an explicit user and database. TLS options, `passfile`, `connect_timeout`,
|
||||
`application_name`, `target_session_attrs` and `options` are passed through; `pool_*`
|
||||
options are dropped; supported `PG*` env defaults apply under the URL. Keyword DSNs,
|
||||
services, multiple hosts, unknown or duplicate options, protocol-negotiation settings and
|
||||
the `PGSERVICE`, `PGSERVICEFILE`, `PGSSLNEGOTIATION`, `PG{MIN,MAX}PROTOCOLVERSION` and `PGTZ`
|
||||
env vars are rejected. Use the CLI for those setups. Prefer `sslmode=verify-full` for remote
|
||||
databases. Credentials go to a 0600 service file in staging, never into process arguments,
|
||||
logs or errors.
|
||||
|
||||
**Behaviour.** One export at a time. Disconnecting cancels it, and the transfer has a
|
||||
10-minute write deadline. Responses are `no-store`. A failed export sends no partial archive
|
||||
and logs a sanitized reason.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| 400 | Query string or body sent |
|
||||
| 401 | Missing or invalid key |
|
||||
| 409 | Another export is running |
|
||||
| 500 | Export failed |
|
||||
| 503 | Backup unavailable, no admin key, or shutting down |
|
||||
| 504 | Timed out |
|
||||
| 507 | SQL size limit exceeded |
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
./beacon-backup -verify /private/beacon-20260913.tar.gz
|
||||
```
|
||||
|
||||
Reads the file without extracting, running SQL or connecting to anything, so it needs no
|
||||
config, `pg_dump` or credentials. `-max-bytes` and `-timeout` apply. It requires exactly the
|
||||
three members as 0600 regular files with USTAR headers (size-only PAX allowed for SQL over
|
||||
8 GiB). It rejects duplicates, links, devices, extra metadata, truncated or concatenated
|
||||
streams, and unknown or duplicate manifest keys. Sizes and SHA-256 hashes must match.
|
||||
|
||||
Exit 0 means structure and checksums passed. Errors don't include payload, manifest values
|
||||
or the path. The manifest is unsigned, so a pass does **not** prove authenticity, safe SQL or
|
||||
that the dump restores.
|
||||
|
||||
## Restore
|
||||
|
||||
Only restore trusted bundles, because a dump can run arbitrary SQL. Verify, extract
|
||||
privately, and load into a **new, empty database** on a compatible PostgreSQL with the
|
||||
needed extensions:
|
||||
|
||||
```sh
|
||||
psql -X --set ON_ERROR_STOP=on --single-transaction --file database.sql
|
||||
```
|
||||
|
||||
Check the migration journal and some records, review the config, and restore external
|
||||
secrets and files before pointing a new Beacon at it. Never test an export by restoring over
|
||||
live data. See [pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html) and
|
||||
[libpq environment](https://www.postgresql.org/docs/16/libpq-envars.html).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Configuration
|
||||
|
||||
Beacon takes settings from two places. Secrets and addresses go in environment variables, which
|
||||
the Docker deployments read from `.env`. Everything that describes your network goes in
|
||||
`config.yaml`.
|
||||
|
||||
## Server environment variables
|
||||
|
||||
beacon-server reads these at startup. A missing value that the server needs is logged as a
|
||||
warning, and the first thing that needs it fails (ingest fails to connect, the database fails
|
||||
to open).
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `POSTGRES_DSN` | none, required | PostgreSQL connection string, for example `postgres://beacon:password@db:5432/beacon?sslmode=disable`. In the Docker stack the password must match `POSTGRES_PASSWORD` in `docker-compose.yml`. |
|
||||
| `REDIS_ADDR` | unset | Redis `host:port`. Leave it unset to run without the cache; every read then goes to PostgreSQL. |
|
||||
| `REDIS_PASSWORD` | unset | Redis password, if the server has one. |
|
||||
| `REDIS_DB` | `0` | Redis database index. |
|
||||
| `LISTEN_ADDR` | `:8080` | Address and port the HTTP server listens on. |
|
||||
| `CONFIG_PATH` | `config.yaml` | Path to the YAML config file. Beacon starts without one, with every setting at its default. |
|
||||
| `MQTT_BROKER_1_URL` | unset | WebSocket URL of your first MeshCore MQTT broker, for example `wss://mqtt1.example.com:443`. |
|
||||
| `MQTT_BROKER_1_USERNAME` | unset | Subscriber username for broker 1. See [Getting packets in](getting-packets-in.md) for the account type you need. |
|
||||
| `MQTT_BROKER_1_PASSWORD` | unset | Subscriber password for broker 1. |
|
||||
| `MQTT_BROKER_2_URL`, `_USERNAME`, `_PASSWORD` | unset | The same for an optional second broker. A packet heard through both brokers is stored once. |
|
||||
| `BEACON_API_KEY` | unset | Bearer key for `/api/v1/admin/*`. When set, even to an empty string, it replaces `auth.api_key` from the config file. See [Admin API key](#admin-api-key). |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. Overrides `log.level`. Anything else stops startup. |
|
||||
| `LOG_FORMAT` | `text` | `text` or `json`. Overrides `log.format`. Anything else stops startup. |
|
||||
| `BEACON_CPU_PROFILE_DIR`, `BEACON_CPU_PROFILE_UNTIL` | unset | Enable a bounded CPU capture in production. See [Profiling](profiling.md). |
|
||||
| `PGDATABASE` | unset | Used only by the admin backup export. See [Backup and export](backup-export.md). |
|
||||
|
||||
## Web environment variables
|
||||
|
||||
The web container reads these every time it starts and writes them to `/config.js`, which the
|
||||
browser loads fresh on each visit. To change one, edit `.env` and run `docker compose up -d web` (`beacon-web` in the split deployment).
|
||||
Visitors get the new values on their next page load. Any characters are fine in values.
|
||||
|
||||
| Variable | Required | What it does |
|
||||
|---|---|---|
|
||||
| `VITE_API_BASE` | yes | REST base URL as the browser sees it, for example `https://beacon.example.com/api/v1`. It must be the public address, never `localhost`. |
|
||||
| `VITE_WS_URL` | yes | WebSocket URL as the browser sees it, for example `wss://beacon.example.com/ws`. |
|
||||
| `VITE_MAP_CENTER`, `VITE_MAP_ZOOM` | no | Fallback map view as decimal `lat,lon` and a zoom from 0 to 22, used before airports load or when the selected region has no location. Unset is a world view. |
|
||||
| `VITE_DISABLED_TABS` | no | Comma-separated tabs to hide. Options: `Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics`. |
|
||||
| `VITE_ENABLED_THEMES` | no | Comma-separated theme ids. When set, only these themes are offered. Listing `meshmapper_dark` or `meshmapper_light` is the only way to enable those two. |
|
||||
| `VITE_APP_NAME` | no | Wordmark text in the top left. Default `BEACON`. |
|
||||
| `VITE_SKIP_SPLASH` | no | `true` skips the once-per-session loading splash. |
|
||||
| `VITE_BANNER` | no | Notice shown above the header on every page, for example on a test instance. `[label](url)` and bare URLs become links. |
|
||||
|
||||
## Deployment variables
|
||||
|
||||
These are read by `docker-compose.yml` and Caddy, not by Beacon itself.
|
||||
|
||||
| Variable | Used by | What it does |
|
||||
|---|---|---|
|
||||
| `DOMAIN` | Caddy, both deployments | The public hostname Caddy serves and requests a certificate for. |
|
||||
| `WEB_DOMAIN` | Caddy, type 2 server host only | The web frontend's hostname. Anything that is not `/api/*` or `/ws` is redirected there. |
|
||||
| `BEACON_WEB_IMAGE` | type 2 web host | The image to run, for example `ghcr.io/meshcore-beacon/beacon-web:2.0`. Keep its `X.Y` equal to the server's. |
|
||||
| `POSTGRES_PASSWORD` | the `db` service | Set in `docker-compose.yml`, not `.env`. It must match the password inside `POSTGRES_DSN`. |
|
||||
|
||||
## config.yaml
|
||||
|
||||
[`app_config/config.yaml.example`](../app_config/config.yaml.example) documents every key with
|
||||
its default and is the file to copy from. This is a map of what each block is for, so you know
|
||||
which ones to read.
|
||||
|
||||
| Block | What it controls | Worth reading when |
|
||||
|---|---|---|
|
||||
| `auth` | The admin API key, if you would rather keep it in the file than in `BEACON_API_KEY`. | You want the admin endpoints. |
|
||||
| `log` | Log level and format. | You want JSON logs for a collector. |
|
||||
| `server` | `trusted_proxies`: the proxy addresses allowed to tell Beacon the real client IP. | Always, if Beacon sits behind a proxy. See [Reverse proxy](reverse-proxy.md). |
|
||||
| `ratelimit` | Per-client REST limits for `/api/v1/*`. On by default at 300 requests a minute. | You get 429s you did not expect. |
|
||||
| `meshmapper` | Import transport scopes, IATA borders, region groups and public channels from MeshMapper instead of listing them by hand. | You are in a region MeshMapper covers. |
|
||||
| `iatas` | Display names, coordinates and optional border files for airport codes. IATAs are created automatically when traffic arrives; this block only decorates them. A `borderFile` path is relative to `config.yaml`; in Docker the compose file mounts only `config.yaml`, so add a mount for the folder too (`- ./data/app/borders:/app/borders:ro` under the `app` service). | You want names on the map or a border drawn. |
|
||||
| `regions` | Groups of IATAs with a name, map centre and zoom. | Always, unless MeshMapper imports them. |
|
||||
| `channel_keys` | Hashtag channels and explicit keys for decrypting group messages. | Always. Without keys, channel messages are stored as hashes only. |
|
||||
| `scopes` | Transport scope names for matching `TRANSPORT_FLOOD` packets. Each needs a `region`. | Your mesh uses transport scopes. |
|
||||
| `telemetry` | How long observer telemetry snapshots are kept and how often one is stored. | Disk is tight. |
|
||||
| `backup` | Turns on the admin backup download. Off by default. | You want [Backup and export](backup-export.md). |
|
||||
| `packets` | How long packets, observations and channel messages are kept. Default 7 days. | Disk is tight, or you want more history. |
|
||||
| `analytics` | How long hourly stats rollups are kept. Default 90 days. They outlive raw packets. | You want longer stats windows. |
|
||||
| `presence` | How observer and packet last-seen timestamps are batched before writing. | Rarely. |
|
||||
| `routes` | How long known routes are kept, with a shorter window for routes seen only a few times. | Rarely. |
|
||||
| `websocket` | Connections per client, upgrade attempts per minute, and which other sites may open `/ws`. | The web app is served from a different host than the API. |
|
||||
| `nodes` | When a node is marked stale, when it is deleted, the clock-drift threshold, and the optional foreign repeater flag. | You want `possiblyForeign` on repeaters. |
|
||||
| `observers` | Optional deletion of observers not seen for a long time. Off by default. | Rarely. |
|
||||
| `cors` | Browser cross-origin rules for REST. Default allows any origin, read-only methods. | You are building an admin UI on another origin. |
|
||||
| `cache` | Redis TTLs per response category. | Rarely. |
|
||||
| `ingest` | Only store packets from observers in listed countries or continents. | You run a regional instance and want to ignore the rest of the world. |
|
||||
| `background` | How often cleanup, route reconfirmation and preset rebuilds run. | Rarely. |
|
||||
|
||||
### Things that stop startup
|
||||
|
||||
Beacon checks the file when it starts and refuses to run rather than run with a setting it
|
||||
cannot honour. These are the ones people hit:
|
||||
|
||||
- A manual `scopes:` entry without `region:`, or with a slug that is not under `regions:`.
|
||||
- `server.trusted_proxies` entries that are not CIDRs. One host is `10.0.0.5/32`, not `10.0.0.5`.
|
||||
- `packets.retention` or `analytics.rollup_retention` under `24h`, or any negative duration
|
||||
(`observers.delete_after` is the exception: zero or negative turns it off).
|
||||
- MeshMapper refresh intervals outside their range: `scopes.refresh_interval` 1h to 24h,
|
||||
`zones.refresh_interval` and `channels.refresh_interval` 24h to 168h.
|
||||
- A `borderFile` that is missing or not a valid GeoJSON Polygon or MultiPolygon Feature, or
|
||||
`nodes.mark_foreign: true` with no border source at all.
|
||||
- `log.level` or `log.format` set to anything other than the listed values.
|
||||
- An admin key under 16 characters or containing whitespace.
|
||||
- A negative `ratelimit.requests_per_minute`, `ratelimit.burst` or `websocket.max_connects_per_minute`.
|
||||
A negative `websocket.max_connections_per_ip` is not caught at startup and rejects every
|
||||
connection instead, so leave it at a positive number.
|
||||
|
||||
## Admin API key
|
||||
|
||||
The `/api/v1/admin/*` endpoints need `Authorization: Bearer <key>`. Everything else, including
|
||||
the WebSocket, is public.
|
||||
|
||||
- Set the key with `BEACON_API_KEY` or `auth.api_key`. If the environment variable is set, it
|
||||
wins, and setting it to an empty string turns admin access off even if the file has a key.
|
||||
Beacon never generates a key for you; `openssl rand -hex 32` makes a good one.
|
||||
- The key must be at least 16 characters with no whitespace inside it. Surrounding whitespace
|
||||
is trimmed. A key that breaks these rules stops startup. Changing the key needs a restart.
|
||||
- With no key, admin requests return `503`. A missing, wrong or duplicated header returns `401`.
|
||||
- Send the key only in the header, never in a URL or body, and keep it out of source control
|
||||
and logs.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Getting packets into Beacon
|
||||
|
||||
Beacon does not listen to the radio. It subscribes to one or two MeshCore MQTT brokers that
|
||||
observers publish into, and stores what it hears. No broker, no data.
|
||||
|
||||
## How packets reach Beacon
|
||||
|
||||
An observer is a MeshCore device, or a bridge next to one, with a network link. It hears a LoRa
|
||||
packet, notes the signal strength, and publishes it to a broker under its own IATA (airport
|
||||
code) and public key. Beacon connects to the broker over secure WebSocket MQTT, decodes each
|
||||
packet, works out whether it has already seen it through another observer or broker, stores it,
|
||||
and streams it to anyone connected to the web app.
|
||||
|
||||
Two brokers can be configured. A packet heard by observers on both is stored once, with one
|
||||
observation per observer.
|
||||
|
||||
## Which broker
|
||||
|
||||
- **Use an existing community broker.** The MeshCore Canada brokers (`mqtt1.meshcore.ca` and
|
||||
`mqtt2.meshcore.ca`) feed the public Beacon instances. Ask on the
|
||||
[MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf) for a subscriber account.
|
||||
- **Run your own** with [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker)
|
||||
and point your observers at it. Beacon only needs a subscriber account on it.
|
||||
|
||||
## The account Beacon needs
|
||||
|
||||
The broker defines three subscriber roles:
|
||||
|
||||
| Role | Sees | Notes |
|
||||
|---|---|---|
|
||||
| 1, admin | Everything, including `/internal` topics and `$SYS` | Carries observer owners' personal data. Beacon does not need it. |
|
||||
| 2, full | All public topics, nothing stripped | **This is the one Beacon needs.** |
|
||||
| 3, limited | Public topics with `snr`, `rssi`, `score`, `stats`, `model` and `firmware_version` removed | Not enough: Beacon stores SNR and RSSI per observation. |
|
||||
|
||||
Ask the broker operator for a Role 2 subscriber username and password. Put them in
|
||||
`MQTT_BROKER_1_USERNAME` and `MQTT_BROKER_1_PASSWORD` (and `_2_` for a second broker), and the
|
||||
broker's WebSocket endpoint in `MQTT_BROKER_1_URL`, for example `wss://mqtt1.example.com:443`.
|
||||
See [Configuration](configuration.md#server-environment-variables).
|
||||
|
||||
## Topics
|
||||
|
||||
Observers publish under `meshcore/{IATA}/{observer public key}/`:
|
||||
|
||||
| Subtopic | Contents | What Beacon does with it |
|
||||
|---|---|---|
|
||||
| `packets` | Raw LoRa packets with SNR and RSSI | Decode, dedupe, store, stream |
|
||||
| `status` | Observer health, position and firmware | Update the observer record |
|
||||
| `neighbors` | The observer's neighbour list | Update neighbour links |
|
||||
| `internal` | Owner details from the broker login | Ignored. A Role 2 account is not sent it anyway. |
|
||||
|
||||
Beacon subscribes to `meshcore/#` and routes by subtopic. The IATA in the topic is where the
|
||||
observer says it is. Beacon creates an IATA the first time it sees one; `iatas:` in
|
||||
`config.yaml` only adds a name, coordinates and an optional border.
|
||||
|
||||
## Checking it works
|
||||
|
||||
- `GET /api/v1/brokers` shows each broker and whether it is connected.
|
||||
- `docker compose logs -f app` shows a `connected` line per broker. Packets are not logged at
|
||||
the default level, so do not wait for them there.
|
||||
- `GET /api/v1/observers` fills in as observers publish.
|
||||
- Within a few minutes the web app's Packets tab should be moving. If it is not, see
|
||||
[Troubleshooting](troubleshooting.md#no-packets-are-arriving).
|
||||
@@ -34,7 +34,7 @@ This is the single source of truth for the project; sub-documents (deployment, r
|
||||
- **Mobile client:** Flutter (native iOS + Android)
|
||||
- **Edge:** Caddy for TLS and reverse proxy
|
||||
- **Deployment:** Docker Compose with five services (app, db, redis, web, caddy)
|
||||
- **Observability:** structured `slog` logs (text or JSON); opt-in, time-boxed CPU profiles written to a private directory (beacon-server `PROFILING.md`). There is no HTTP profiling endpoint.
|
||||
- **Observability:** structured `slog` logs (text or JSON); opt-in, time-boxed CPU profiles written to a private directory ([CPU profiling](profiling.md)). There is no HTTP profiling endpoint.
|
||||
- **MQTT brokers:** mqtt1.meshcore.ca + mqtt2.meshcore.ca over WSS, Role 2 SUBSCRIBER account auth (see Broker authentication below)
|
||||
|
||||
### Flow
|
||||
@@ -91,7 +91,7 @@ We need Role 2 because the analyzer relies on SNR and RSSI per observation. Coor
|
||||
|
||||
### Why this is fast
|
||||
|
||||
One process, no inter-service network hops for live packets. Postgres queries hit Redis or in-memory cache most of the time. BRIN indexes keep time-range scans cheap at billions of rows. Caddy speaks HTTP/3 and brotli. React + virtualized lists keep the UI snappy regardless of how many packets are on screen. Flutter on mobile is native-compiled and runs at 60fps by default.
|
||||
One process, no inter-service network hops for live packets. Postgres queries hit Redis or in-memory cache most of the time. BRIN indexes keep time-range scans cheap as the observation table grows. Caddy terminates TLS and serves gzip. React with virtualized lists renders only the rows on screen. Flutter on mobile is native-compiled and runs at 60fps by default.
|
||||
|
||||
---
|
||||
|
||||
@@ -782,7 +782,7 @@ With `backup.enabled: true` and an admin key configured, `GET /api/v1/admin/back
|
||||
new as the Postgres server inside the app's runtime (the Docker image ships PostgreSQL 16's client);
|
||||
if that check fails at startup the endpoint stays unavailable and everything else runs normally. A
|
||||
standalone `beacon-backup` command can also export a bundle and verify one offline. Details:
|
||||
beacon-server `docs/backup-export.md`.
|
||||
[Backup and export](backup-export.md).
|
||||
|
||||
### IATA and super-region seeding
|
||||
|
||||
@@ -880,7 +880,7 @@ The implementation picks the correct prefix column based on `hashSize` (`prefix_
|
||||
|
||||
## API contract
|
||||
|
||||
Moved to its own document: **[API Contract](api_contract.md)**
|
||||
Moved to its own document: **[API Contract](api-contract.md)**
|
||||
|
||||
Covers REST endpoints (`/api/v1/`), auth and rate limits, WebSocket protocol (`/ws`), backpressure/reconnection, and mobile-specific concerns.
|
||||
|
||||
@@ -1027,7 +1027,7 @@ Responses to dev feedback on this design. Captures decisions and rationale for t
|
||||
|
||||
### Q: pprof endpoints, internal only with auth middleware, or exposed for perf stats?
|
||||
|
||||
**A:** Neither, as it turned out. There is no pprof HTTP endpoint; CPU profiling is opt-in and time-boxed, and writes profile files to a private directory (beacon-server `PROFILING.md`).
|
||||
**A:** Neither, as it turned out. There is no pprof HTTP endpoint; CPU profiling is opt-in and time-boxed, and writes profile files to a private directory ([CPU profiling](profiling.md)).
|
||||
|
||||
### Q: MQTT brokers, internal WSS to our own broker for distribution? Should we include Mosquitto or EMQX as a deploy option for other communities?
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Day-to-day operations
|
||||
|
||||
Everything here assumes the all-in-one Docker stack. The commands translate to the split
|
||||
deployment by running them on the server host.
|
||||
|
||||
## Checking it is healthy
|
||||
|
||||
Beacon has no health endpoint yet. Use these instead:
|
||||
|
||||
- `docker compose ps` shows every container `Up`. `db` and `redis` have health checks; `app`
|
||||
waits for both before it starts.
|
||||
- `curl -s https://<your-domain>/api/v1/brokers` lists each MQTT broker with its connection
|
||||
status. A broker that never connects is a URL or credentials problem; see
|
||||
[Getting packets in](getting-packets-in.md).
|
||||
- Open `wss://<your-domain>/ws` in a WebSocket client. The first frame is `hello`.
|
||||
- `docker compose logs -f app` shows a `connected` line per broker. Individual packets are
|
||||
not logged at the default `info` level, so a quiet log after that is normal; use the
|
||||
observers endpoint or the web app to see traffic.
|
||||
|
||||
## Logs
|
||||
|
||||
Logs go to stderr and Docker collects them. Docker's default `json-file` driver does not
|
||||
rotate, so on a long-running host set a cap, either in `/etc/docker/daemon.json` for every
|
||||
container or per service in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "50m", max-file: "5" }
|
||||
```
|
||||
|
||||
Set `log.level` (`debug`, `info`,
|
||||
`warn`, `error`; default `info`) and `log.format` (`text` or `json`) in `config.yaml`, or
|
||||
override them with `LOG_LEVEL` and `LOG_FORMAT`. Invalid values stop startup.
|
||||
|
||||
Every record has a `component`. Ingest records add the broker name; HTTP completion records
|
||||
add the validated client address, route, status and duration. Query strings and WebSocket
|
||||
hello payloads are never logged. Expected ingest skips and routine WebSocket lifecycle events
|
||||
are at debug level.
|
||||
|
||||
```bash
|
||||
docker compose logs -f app # the API and ingest
|
||||
docker compose logs -f caddy # the proxy
|
||||
```
|
||||
|
||||
Caddy also writes a JSON access log to `data/Caddy/logs/access.json`, which the fail2ban rules
|
||||
in [`app_config/fail2ban/`](../app_config/fail2ban/README.md) read.
|
||||
|
||||
## Where the data lives
|
||||
|
||||
Everything persistent is under `data/` next to `docker-compose.yml`:
|
||||
|
||||
- `data/postgres/` is the database. Deleting it deletes all history.
|
||||
- `data/redis/` is the cache. It can be removed while the stack is down; Beacon rebuilds it.
|
||||
- `data/Caddy/data/` holds TLS certificates. Deleting it means a new certificate request on
|
||||
the next start, which counts against Let's Encrypt rate limits.
|
||||
- `data/app/config.yaml` and `.env` are your configuration. Keep copies of both somewhere
|
||||
else; no backup below includes `.env`.
|
||||
|
||||
## Retention
|
||||
|
||||
Everything below is a duration in `config.yaml`. Shorten them if disk is tight, lengthen them
|
||||
if you want more history.
|
||||
|
||||
| Setting | Default | What it covers |
|
||||
|---|---|---|
|
||||
| `packets.retention` | `168h` (7 days) | Packets, observations and channel messages |
|
||||
| `analytics.rollup_retention` | `2160h` (90 days) | Hourly stats rollups. Also the longest window the stats endpoints accept |
|
||||
| `telemetry.retention` | `744h` (31 days) | Observer telemetry snapshots |
|
||||
| `routes.retention` | `336h` (14 days) | Known routes, counted from when a route was last seen |
|
||||
| `nodes.delete_after` | `720h` (30 days) | Nodes not heard from |
|
||||
| `observers.delete_after` | off | Observers not heard from. Opt in by setting a duration |
|
||||
|
||||
Historical stats read the hourly rollups, not raw packets. Each UTC hour is rolled about 95
|
||||
minutes after it closes, and packet cleanup holds back up to 24 hours of raw rows until their
|
||||
hours are rolled, so raw data lives a little longer than `packets.retention` says. An hour
|
||||
whose raw rows were deleted first is reported as partial, with no values.
|
||||
|
||||
## Backing up
|
||||
|
||||
Two options.
|
||||
|
||||
**A plain `pg_dump`** from the host, which is enough for most operators:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db pg_dump -U beacon -d beacon -Fc > beacon-$(date +%F).dump
|
||||
```
|
||||
|
||||
Keep `data/app/config.yaml` and `.env` with it. The dump holds message data and anything you
|
||||
have decrypted, so treat it as private.
|
||||
|
||||
**The admin backup endpoint** produces an archive of the database and the saved
|
||||
`config.yaml`, with a manifest and checksums, and a command-line tool that verifies it. It is
|
||||
off by default and needs the admin key. See [Backup and export](backup-export.md).
|
||||
|
||||
## Restoring
|
||||
|
||||
Restore into an empty database, never over live data. With the stack running and the app
|
||||
stopped, drop and recreate the database, load the dump, and only then start the app:
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
docker compose exec -T db psql -U beacon -d postgres -c 'DROP DATABASE beacon;' -c 'CREATE DATABASE beacon;' \
|
||||
&& docker compose exec -T db pg_restore -U beacon -d beacon < beacon-YYYY-MM-DD.dump \
|
||||
&& docker compose start app
|
||||
```
|
||||
|
||||
If `pg_restore` reports errors the app is not started; read them before trying again.
|
||||
|
||||
A 1.x dump cannot be restored into 2.0.0; the server refuses the old schema. See
|
||||
[Upgrading](upgrading.md#upgrading-to-200-from-1x).
|
||||
|
||||
## CPU profiling
|
||||
|
||||
Bounded CPU captures on a running instance are described in [Profiling](profiling.md).
|
||||
@@ -0,0 +1,70 @@
|
||||
# CPU profiling
|
||||
|
||||
Beacon can record bounded CPU profiles on Linux and macOS. It's off by default, writes
|
||||
private files only (there's no HTTP endpoint or port), and a profiling failure stops the
|
||||
recorder with a warning without affecting ingest.
|
||||
|
||||
## Enable a session
|
||||
|
||||
Create a dedicated `0700` directory writable by the container user, mount it, and set both
|
||||
variables:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
BEACON_CPU_PROFILE_DIR: /profiles
|
||||
BEACON_CPU_PROFILE_UNTIL: "2026-10-02T12:00:00Z" # fixed deadline
|
||||
volumes:
|
||||
- ./profiles:/profiles
|
||||
```
|
||||
|
||||
- The deadline is RFC 3339, at most 72 hours ahead. It survives restarts and is inactive
|
||||
once past. Don't generate a fresh deadline on every start.
|
||||
- Changing either setting needs a restart.
|
||||
- Start with a deadline about a minute out, and compare process CPU, packet freshness, queue
|
||||
drops and database timeouts with an unprofiled period before running a longer session.
|
||||
- One Beacon process per directory. Keep it out of web roots, public backups and source
|
||||
control, and don't run another CPU profiler in the process meanwhile.
|
||||
|
||||
## What gets captured
|
||||
|
||||
- A 30-second sample at start, another 35 minutes later, then every 30 minutes (the extra
|
||||
five minutes keeps a maintenance trigger from landing in the cooldown).
|
||||
- An extra sample when route reconfirmation maintenance starts (including its retention
|
||||
step).
|
||||
- A five-minute cooldown between capture starts. A periodic sample due during it runs when it
|
||||
ends; a triggered one is skipped, and any sample cancels a periodic one that's waiting.
|
||||
- Background task stacks carry a `task` label.
|
||||
- Shutdown or the deadline ends the active sample early and saves it.
|
||||
|
||||
Limits: 8 MiB per profile; 256 MiB and 512 files per directory, counting metadata, leftovers
|
||||
and unrelated regular files (subdirectories are ignored). The recorder reserves room for a
|
||||
full capture first and stops at a limit. Nothing is deleted automatically. Files are `0600`;
|
||||
a `.partial` file is an interrupted capture.
|
||||
|
||||
## Reading the output
|
||||
|
||||
Each `.pprof` has a `.pprof.json` sidecar with UTC start and end, the trigger, Go version,
|
||||
process CPU counters, goroutine count, and database-pool counters before and after (pool
|
||||
values are cumulative, so subtract). No SQL text or credentials.
|
||||
|
||||
Copy completed files off the host privately, noting the image revision and digest. Then:
|
||||
|
||||
```sh
|
||||
go tool pprof -top capture.pprof
|
||||
go tool pprof -top -cum capture.pprof
|
||||
go tool pprof -tags capture.pprof
|
||||
```
|
||||
|
||||
Profiles show CPU inside Beacon, not PostgreSQL work or lock waits. Correlate each interval
|
||||
with host/container CPU and I/O, PostgreSQL wait states, observation throughput and Beacon's
|
||||
timeout/drop logs. Don't turn on SQL text logging or run full-table counts for this; the
|
||||
recorder itself makes no database queries.
|
||||
|
||||
Review quiet, burst and maintenance captures separately: maintenance samples bias an
|
||||
aggregate. Thirty-second windows can miss brief stalls, so a missing stack isn't proof it
|
||||
never runs.
|
||||
|
||||
When the deadline passes, look for `CPU profiling stopped` in the log, export the files, and
|
||||
remove the variables and mount at the next deployment.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Releases and versioning
|
||||
|
||||
## Version numbers
|
||||
|
||||
From 2.0.0, beacon-server and beacon-web share the same major and minor version. Patch numbers
|
||||
move on their own, so server 2.0.3 runs with web 2.0.1. Keep both on the same `X.Y`.
|
||||
|
||||
The `/api/v1` in request paths is the API contract version. It does not change with releases;
|
||||
see the [API contract](api-contract.md#versioning).
|
||||
|
||||
## Image tags
|
||||
|
||||
Both images live on GitHub Container Registry and are public, so no `docker login` is needed:
|
||||
`ghcr.io/meshcore-beacon/beacon-server` and `ghcr.io/meshcore-beacon/beacon-web`.
|
||||
|
||||
| Event | Tags published |
|
||||
|---|---|
|
||||
| push to `main` | `latest`, `sha-<short>` |
|
||||
| push to `dev` | `dev`, `sha-<short>` |
|
||||
| tag `vX.Y.Z` | `X.Y.Z`, `X.Y`, `sha-<short>` |
|
||||
|
||||
`latest` is the newest release on `main`. To decide for yourself when to upgrade, pin both
|
||||
images to the same release line (`:2.0`) or an exact release (`:2.0.0`). `dev` follows the
|
||||
development branch and can break between pushes. Prerelease tags (`v2.1.0-rc1`) get `2.1.0-rc1`
|
||||
but not `2.1`.
|
||||
|
||||
If a pull fails with `403 Forbidden`, the package has been switched back to private on GHCR;
|
||||
see [Troubleshooting](troubleshooting.md#docker-compose-up-fails-with-403-forbidden-pulling-an-image).
|
||||
|
||||
## Cutting a release
|
||||
|
||||
Maintainers only. Releases are cut by hand, not from the GitHub UI, and release commits are
|
||||
signed. The flow is the same in both repos; only the version bump differs.
|
||||
|
||||
1. Make sure `dev` is green in CI.
|
||||
2. Bump the version on `dev`:
|
||||
- beacon-server: the `@version` annotation in `cmd/beacon/main.go`, then regenerate Swagger
|
||||
with `swag init -g cmd/beacon/main.go -o docs --parseInternal --parseDependency`.
|
||||
- beacon-web: `version` in `package.json`.
|
||||
|
||||
Commit as `chore: bump version to vX.Y.Z`.
|
||||
3. Fast-forward `main` to `dev`: `git checkout main && git merge --ff-only dev`.
|
||||
4. Tag and push: `git tag vX.Y.Z && git push origin main --tags`.
|
||||
5. The tag builds and publishes the container image in both repos. In beacon-server it also
|
||||
builds binaries for every supported platform and attaches them to a draft GitHub release.
|
||||
Open the draft, paste the release notes and publish it.
|
||||
6. Rebase `dev` on `main` so the histories stay in sync: `git checkout dev && git rebase main`.
|
||||
|
||||
## Where to find release notes
|
||||
|
||||
Each repo's GitHub Releases page:
|
||||
[beacon-server](https://github.com/MeshCore-Beacon/beacon-server/releases) and
|
||||
[beacon-web](https://github.com/MeshCore-Beacon/beacon-web/releases).
|
||||
@@ -0,0 +1,117 @@
|
||||
# Reverse proxy
|
||||
|
||||
However you front Beacon, the proxy has the same three jobs:
|
||||
|
||||
- Send `/api/*` and `/ws` to beacon-server (port `8080`) and everything else to beacon-web. `/ws`
|
||||
is a WebSocket, so the proxy must pass the upgrade through and keep idle sockets open for more
|
||||
than 90 seconds (the browser pings every 30 seconds; the server drops a socket after 90 seconds
|
||||
of silence).
|
||||
- Overwrite `X-Real-IP` with the connecting client's address and pass the original `Host`
|
||||
header. beacon-server ignores `X-Forwarded-For` and `True-Client-IP` entirely.
|
||||
- Be listed in beacon-server's `server.trusted_proxies` (CIDR: `/32` for one IPv4 host, `/128`
|
||||
for IPv6). Only those peers may set `X-Real-IP`. Get this wrong and every visitor shares the
|
||||
proxy's rate limit and WebSocket connection cap.
|
||||
|
||||
`VITE_API_BASE` and `VITE_WS_URL` then point at the public paths, for example
|
||||
`https://beacon.example.com/api/v1` and `wss://beacon.example.com/ws`.
|
||||
|
||||
## Caddy
|
||||
|
||||
Caddy is the default in both Docker deployments:
|
||||
[`Caddyfile.proxy`](../docker-deployment-type1/data/Caddy/CaddyFile/Caddyfile.proxy). The
|
||||
relevant part:
|
||||
|
||||
```caddy
|
||||
handle /api/* {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
handle /ws {
|
||||
reverse_proxy app:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles the WebSocket upgrade and keeps the `Host` header by itself. It reaches the app
|
||||
over the compose network, so `data/app/config.yaml` trusts that subnet:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
trusted_proxies: [172.30.0.0/24]
|
||||
```
|
||||
|
||||
## Behind a CDN
|
||||
|
||||
Behind Cloudflare or another CDN, `{remote_host}` is the CDN, not the visitor. The comment at
|
||||
the top of `Caddyfile.proxy` shows how to trust the CDN's ranges and forward `{client_ip}`
|
||||
instead. The same idea applies to nginx and Apache: trust the CDN at the edge and pass its
|
||||
validated client address, never a header the client could have set.
|
||||
|
||||
## nginx
|
||||
|
||||
[`app_config/nginx/beacon.conf`](../app_config/nginx/beacon.conf) is a complete server block for
|
||||
nginx on the host in front of the containers. The essentials:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location = /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade; # map is in the example file
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081; # beacon-web published on loopback
|
||||
}
|
||||
```
|
||||
|
||||
nginx sends the upstream address as `Host` by default, which fails the WebSocket origin check,
|
||||
so keep `proxy_set_header Host $host`. When nginx reaches the containers through published
|
||||
ports, the app sees the Docker network's gateway as the peer, so trust the compose subnet
|
||||
(`[172.30.0.0/24]`). For a beacon-server running directly on the host, use
|
||||
`["127.0.0.1/32", "::1/128"]`.
|
||||
|
||||
## Apache
|
||||
|
||||
Apache works the same way. [`app_config/apache/analyzer-vhost.snippet.conf`](../app_config/apache/analyzer-vhost.snippet.conf)
|
||||
is a working virtual host snippet, including the `RequestHeader set X-Real-IP` line that
|
||||
replaces whatever the client sent.
|
||||
|
||||
## Rate limits and connection caps
|
||||
|
||||
REST requests under `/api/v1` are rate limited per client IP, 300 a minute by default. IPv6
|
||||
clients share one budget per /64. A client over the limit gets `429` with `rate_limited` in the
|
||||
body and a `Retry-After` header.
|
||||
|
||||
WebSocket has two separate limits. At most 5 open connections per IP: a connection over the cap
|
||||
is accepted and then closed with code `1013` (try again later) before `hello`. At most 10
|
||||
upgrade attempts per IP per minute: beyond that the handshake gets `429` with `Retry-After: 60`.
|
||||
Both are set under `websocket:` in `config.yaml`.
|
||||
|
||||
Behind a reverse proxy all of these only work with `server.trusted_proxies` set, or every
|
||||
visitor counts as the proxy's IP. Without it every visitor shares the proxy's budget and the
|
||||
site gets 429s under normal load. Beacon logs a warning at startup when limits are on and no
|
||||
proxy is trusted, and once at runtime when a request carries forwarding headers it is ignoring.
|
||||
|
||||
## Split origins
|
||||
|
||||
When the web app and the API are on different hosts, browsers may only open `/ws` from the
|
||||
page's own host unless the web origin is listed in `websocket.allowed_origins`
|
||||
(`https://*.example.com` covers every subdomain). REST CORS allows any origin by default.
|
||||
|
||||
## Abuse controls
|
||||
|
||||
Access logs, a manual block list and fail2ban jails for both Caddy and Apache are in
|
||||
[`app_config/fail2ban/`](../app_config/fail2ban/README.md).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Securing a deployment
|
||||
|
||||
Beacon is a read-mostly public service. The things worth protecting are the admin key, the API
|
||||
listener, your broker credentials, and the database, which holds decrypted channel messages.
|
||||
|
||||
## The admin key
|
||||
|
||||
The `/api/v1/admin/*` endpoints are the only authenticated part of the API. Generate the key
|
||||
with `openssl rand -hex 32`, set it as `BEACON_API_KEY`, and send it only in the
|
||||
`Authorization: Bearer` header over HTTPS, never in a URL or a request body. Keep it out of
|
||||
source control and logs. The rules Beacon enforces (16 characters minimum, no whitespace,
|
||||
environment wins over the file) are in [Configuration](configuration.md#admin-api-key).
|
||||
|
||||
If you do not need the admin endpoints, do not set a key. They answer `503` and nothing else
|
||||
changes.
|
||||
|
||||
## Keep the API listener private
|
||||
|
||||
beacon-server listens on `:8080` with no TLS. In the Docker deployments it is only reachable
|
||||
from the compose network and from the host's loopback; Caddy is the only thing on a public
|
||||
port. If you run it another way, bind `LISTEN_ADDR` to a private address and terminate HTTPS
|
||||
at the proxy.
|
||||
|
||||
## Browser origins
|
||||
|
||||
Only the page's own host may open `/ws` unless other origins are listed under
|
||||
`websocket.allowed_origins`. REST CORS allows any origin by default but only the read-only
|
||||
methods (`GET`, `HEAD`, `OPTIONS`). An admin UI on another origin needs `cors.allowed_origins`
|
||||
restricted to that UI, the write methods added, and the `Authorization` and `Content-Type`
|
||||
headers allowed, or its preflight requests fail. CORS controls what browsers may do, not who
|
||||
is authenticated.
|
||||
|
||||
## Rate limits and bans
|
||||
|
||||
REST is limited to 300 requests a minute per client by default, WebSocket upgrades to 10 a
|
||||
minute, with 5 open sockets per client. Behind a proxy none of that works until
|
||||
`server.trusted_proxies` is set; see
|
||||
[Reverse proxy](reverse-proxy.md#rate-limits-and-connection-caps). For repeat offenders, the
|
||||
fail2ban jails and block list in [`app_config/fail2ban/`](../app_config/fail2ban/README.md)
|
||||
ban at the edge.
|
||||
|
||||
## What Beacon logs and returns
|
||||
|
||||
Query strings and WebSocket hello payloads are never logged. HTTP completion records carry
|
||||
the validated client address, route, status and duration. `GET /api/v1/admin/config` returns
|
||||
no credentials, broker addresses, channel material or database settings. The backup export,
|
||||
if you enable it, contains everything the database holds, so keep archives private; see
|
||||
[Backup and export](backup-export.md).
|
||||
|
||||
## Secrets in files
|
||||
|
||||
`.env` holds the database password, broker credentials and the admin key. It is gitignored in
|
||||
the deployment folders; keep it that way, and keep its permissions tight on the host.
|
||||
`data/app/config.yaml` holds channel keys. Neither belongs in a public backup.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Troubleshooting
|
||||
|
||||
Each heading is a symptom. Under it: the usual cause, then the fix.
|
||||
|
||||
## `docker compose up` fails with `403 Forbidden` pulling an image
|
||||
|
||||
The error looks like `... manifests/<tag>: 403 Forbidden`. The GHCR package has been switched,
|
||||
or defaulted, to private. A maintainer has to set it back to public; the steps are under
|
||||
[Maintainers: publishing images](../README.md#maintainers-publishing-images). Nothing on your
|
||||
side fixes it.
|
||||
|
||||
## Everyone is getting 429
|
||||
|
||||
Beacon rate limits per client IP, and behind a proxy every visitor arrives from the proxy's
|
||||
address unless Beacon is told otherwise. Two things have to be true:
|
||||
|
||||
1. `server.trusted_proxies` in `config.yaml` lists the proxy's address as a CIDR: `/32` for one
|
||||
IPv4 host, `/128` for one IPv6 host, `172.30.0.0/24` for the all-in-one compose network. A
|
||||
bare address stops startup with `failed to load config ... netip.ParsePrefix("10.0.0.5"): no '/'`.
|
||||
2. The proxy overwrites `X-Real-IP` with the connecting client's address. The shipped Caddy,
|
||||
nginx and Apache configs do; see [Reverse proxy](reverse-proxy.md).
|
||||
|
||||
Beacon logs a warning at startup when limits are on and no proxy is trusted. The same mistake
|
||||
makes the WebSocket connection cap (5 per IP) hit for everyone at once.
|
||||
|
||||
## The page loads but nothing is live
|
||||
|
||||
The browser could not open the WebSocket. Open the browser's developer tools, Network tab,
|
||||
filter `WS`, and look at the `/ws` request. Causes, most common first:
|
||||
|
||||
- The proxy is not passing the upgrade, or sends the upstream address as `Host`. nginx needs
|
||||
`proxy_http_version 1.1`, the `Upgrade` and `Connection` headers, and
|
||||
`proxy_set_header Host $host`.
|
||||
- The web app is served from a different host than the API and that origin is not in
|
||||
`websocket.allowed_origins`.
|
||||
- The per-IP connection cap is being hit because every visitor looks like the proxy. See the
|
||||
429 item above.
|
||||
- `VITE_WS_URL` points at the wrong place. It must be the public `wss://` address, not
|
||||
`localhost`.
|
||||
|
||||
## Caddy never gets a certificate
|
||||
|
||||
`docker compose logs caddy` shows ACME errors. Either DNS for `DOMAIN` is not pointing at this
|
||||
server yet, or ports 80 and 443 are not reachable from the internet. Fix that and Caddy
|
||||
retries on its own.
|
||||
|
||||
## Startup fails with `database schema predates Beacon 2.0.0`
|
||||
|
||||
The database was created by Beacon 1.x. 2.0.0 cannot migrate it. Move the old data directory
|
||||
aside and start with an empty one; see
|
||||
[Upgrading](upgrading.md#upgrading-to-200-from-1x).
|
||||
|
||||
## Startup fails on a config value
|
||||
|
||||
Beacon refuses to run with a setting it cannot honour. The message names the key:
|
||||
|
||||
| Message | Fix |
|
||||
|---|---|
|
||||
| `scope "x" needs a region` | Add `region: <slug>` to that `scopes:` entry. |
|
||||
| `scope "x" region "y" is not a configured region` | Use a slug that appears under `regions:`. |
|
||||
| `netip.ParsePrefix("10.0.0.5"): no '/'` | Write `10.0.0.5/32`, not `10.0.0.5`. An empty entry fails with `server.trusted_proxies[0] must be a valid CIDR` instead. |
|
||||
| `packets.retention must be at least 24h` | Raise it. Same for `analytics.rollup_retention`. |
|
||||
| `meshmapper.scopes.refresh_interval must be between 1h and 24h` | Set it inside that range. Zones and channels intervals are 24h to 168h. |
|
||||
| `nodes.mark_foreign requires an iatas.*.borderFile or meshmapper.zones` | Add a border source or turn `mark_foreign` off. |
|
||||
| `not a well-formed GeoJSON Feature`, `ring 0 is not closed`, `geometry must be Polygon or MultiPolygon` | The border file is invalid. It must be a single GeoJSON Feature with a closed Polygon or MultiPolygon in longitude, latitude order. |
|
||||
| `log level must be debug, info, warn or error` | Check `log.level` and `LOG_LEVEL`. `log format must be text or json` is the same for format. |
|
||||
| `auth.api_key / BEACON_API_KEY must be at least 16 characters` | Use a longer key, with no whitespace inside it. |
|
||||
|
||||
## No packets are arriving
|
||||
|
||||
Check `GET /api/v1/brokers` first.
|
||||
|
||||
- **A broker shows disconnected.** `MQTT_BROKER_1_URL` must be the WebSocket endpoint,
|
||||
usually `wss://host:443`, and the username and password must be a subscriber account on that
|
||||
broker. `docker compose logs app` shows the connect error. See
|
||||
[Getting packets in](getting-packets-in.md).
|
||||
- **Connected but nothing stored.** No observers are publishing to that broker, or
|
||||
`ingest.allow_countries` / `allow_continents` in `config.yaml` is filtering them out.
|
||||
`GET /api/v1/observers` shows who Beacon has heard from.
|
||||
- **Packets arrive but with no signal data.** The account is Role 3, which strips `snr` and
|
||||
`rssi`. Ask for a Role 2 account.
|
||||
|
||||
## Every path hop says `"confidence": "none"`
|
||||
|
||||
Path resolution needs to know which nodes exist, and Beacon learns that from adverts. On a
|
||||
fresh database every hop is `none` and `supportsMultibytePaths` is `false` until nodes have
|
||||
advertised. This fills in on its own as the mesh is observed.
|
||||
|
||||
## Channel messages show a hash but no text
|
||||
|
||||
The channel's key is not configured, or it was added without a restart. Add it under
|
||||
`channel_keys:` in `config.yaml` and run `docker compose restart app`. On start, Beacon decrypts
|
||||
the stored messages for the new key and logs
|
||||
`config: backfilled N previously-undecrypted channel message(s)`.
|
||||
|
||||
## I changed a `VITE_*` value and nothing changed
|
||||
|
||||
Run `docker compose up -d web` (`docker compose up -d beacon-web` in the split deployment). The web container writes the values to `/config.js` when it
|
||||
starts, so an edited `.env` does nothing until the container is recreated. Then hard-refresh
|
||||
the page.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Upgrading
|
||||
|
||||
## Upgrading to 2.0.0 from 1.x
|
||||
|
||||
**2.0.0 needs a fresh database.** It starts from a new schema baseline. Pointed at a 1.x
|
||||
database it refuses to start with:
|
||||
|
||||
```
|
||||
database schema predates Beacon 2.0.0; 2.0.0 needs a fresh database
|
||||
```
|
||||
|
||||
1.x history does not carry over. For the all-in-one stack, in this order:
|
||||
|
||||
**1. Stop the stack and keep a copy of everything you edited.**
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
cp -r ../docker-deployment-type1 ../docker-deployment-type1.bak # .env, config.yaml, Caddy files, blocklist
|
||||
```
|
||||
|
||||
**2. Update the deployment files.** If the folder is a git clone, `git pull` will refuse
|
||||
because the 1.x layout tracked `.env` and you edited `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
git checkout -- . && git pull
|
||||
```
|
||||
|
||||
Then put your own files back from the copy: `.env`, `data/app/config.yaml`, and anything you
|
||||
changed under `data/Caddy/`. Do not copy `docker-compose.yml` back; re-apply your
|
||||
`POSTGRES_PASSWORD` to the new one instead.
|
||||
|
||||
**3. Fix the database names.** The user and database are called `beacon` now, not `tower`.
|
||||
In `.env`, change both in `POSTGRES_DSN` (`postgres://beacon:...@db:5432/beacon`). The new
|
||||
`docker-compose.yml` already uses `beacon`. The names only have to agree with each other, so
|
||||
if you have already initialised a 2.0.0 database under `tower`, either keep `tower` in both
|
||||
places or move that data directory away as well.
|
||||
|
||||
**4. Move the 1.x database away and start.** Postgres only creates the user and database on
|
||||
an empty data directory, so do this after step 3, not before:
|
||||
|
||||
```bash
|
||||
mv data/postgres data/postgres-1.x # or rm -rf data/postgres once you no longer need it
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker compose down` also lets the network come back with the new fixed subnet. If
|
||||
`172.30.0.0/24` is already used on your host, pick another in `docker-compose.yml` and change
|
||||
`server.trusted_proxies` in `data/app/config.yaml` to match.
|
||||
|
||||
Config that **stops startup** if left as it was:
|
||||
|
||||
- Every manual `scopes:` entry needs `region:` set to one of your `regions:` slugs.
|
||||
- MeshMapper refresh intervals must be within bounds: `meshmapper.scopes.refresh_interval` 1h
|
||||
to 24h, `meshmapper.zones.refresh_interval` and `meshmapper.channels.refresh_interval` 24h to
|
||||
168h.
|
||||
- `packets.retention` and `analytics.rollup_retention` must be at least `24h`.
|
||||
- `server.trusted_proxies` entries must be CIDRs (`10.0.0.5/32`, not `10.0.0.5`).
|
||||
|
||||
Other changes to review:
|
||||
|
||||
- `meshmapper.scopes.sources` is ignored, with a warning. Scope catalogues are now found
|
||||
through MeshMapper's zone list for every known IATA.
|
||||
- **REST rate limiting is on by default:** 300 requests per minute per client IP on
|
||||
`/api/v1/*` (IPv6 clients share a /64), answered with `429` and `Retry-After`. Behind a
|
||||
proxy, set `server.trusted_proxies` and have the proxy send `X-Real-IP` (see
|
||||
[Reverse proxy](reverse-proxy.md)); `X-Forwarded-For` and `True-Client-IP` are not trusted.
|
||||
Tune or turn it off under `ratelimit:`.
|
||||
- **WebSocket upgrades are rate limited** to `websocket.max_connects_per_minute` (default 10)
|
||||
per client IP, on top of `max_connections_per_ip` (default 5). Only the page's own host may
|
||||
open `/ws` unless you list other origins in `websocket.allowed_origins`, for example when the
|
||||
web app is served from a different host than the API.
|
||||
- `packets.retention` now defaults to **7 days** and also covers observations and channel
|
||||
messages. Historical stats come from hourly rollups kept for `analytics.rollup_retention`
|
||||
(90 days).
|
||||
- Admin endpoints under `/api/v1/admin/` need `BEACON_API_KEY` (or `auth.api_key`).
|
||||
- Image tags: `latest` is the newest stable release from `main`. Pin `:2.0` on both images if
|
||||
you want to choose when to move to the next minor release.
|
||||
|
||||
[`app_config/config.yaml.example`](../app_config/config.yaml.example) is the full 2.0.0
|
||||
reference.
|
||||
|
||||
## Routine upgrades
|
||||
|
||||
Pin both images to the same release line in `docker-compose.yml` (see
|
||||
[Image tags](releases.md#image-tags)), then:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Database migrations run when the server starts. Read the release notes first; a release that
|
||||
needs a config change says so there. Keep server and web on the same `X.Y`.
|
||||
|
||||
## Changes that need a restart
|
||||
|
||||
Beacon reads `config.yaml` once at startup. After editing it, run
|
||||
`docker compose restart app`. On the next start:
|
||||
|
||||
- A newly added channel key decrypts the matching stored messages. Look for
|
||||
`config: backfilled N previously-undecrypted channel message(s)` in the log.
|
||||
- Border file and MeshMapper import changes take effect.
|
||||
- A changed admin key is picked up.
|
||||
|
||||
The one runtime-only setting is CORS origins: `PUT /api/v1/admin/config` changes them without a
|
||||
restart, and nothing is written to the file, so a restart reloads whatever the file says.
|
||||
Reference in new issue
Block a user