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:
MrAlders0n authored and GitHub committed 2026-10-02 21:42:13 -04:00
1 parent b38be34aad
commit f2728b059b
35 files changed
+2033 -381

No files matched your search

+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+120 -245
View File
@@ -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
View File
@@ -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.
+9 -4
View File
@@ -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
+61 -16
View File
@@ -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
}
}
+2 -4
View File
@@ -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
}
+3 -1
View File
@@ -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:
-2
View File
@@ -1,2 +0,0 @@
# Copy from app_config/.env.example and fill in your values.
# See the README for instructions.
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -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.
+109
View File
@@ -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
+34
View File
@@ -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.)
+124
View File
@@ -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).
+121
View File
@@ -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.
+62
View File
@@ -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?
+115
View File
@@ -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).
+70
View File
@@ -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.
+53
View File
@@ -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).
+117
View File
@@ -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).
+54
View File
@@ -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.
+100
View File
@@ -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.
+107
View File
@@ -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.