diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7382821 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index c861031..e50faa7 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6b592b..dc401ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 01081a6..f9077c4 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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 diff --git a/README.md b/README.md index 082eeb5..7404752 100644 --- a/README.md +++ b/README.md @@ -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:///api/v1` — must be the **public** domain, never localhost. | -| `VITE_WS_URL` | `web` | `wss:///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:///api/v1`. The browser calls this, so it must be the public domain, never `localhost`. | +| `VITE_WS_URL` | `wss:///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://` 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://` and watch the Packets tab, or +check `https:///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/: 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). diff --git a/SECURITY.md b/SECURITY.md index 05b32a7..7bcaa9d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/app_config/.env.example b/app_config/.env.example index 83d3ef4..41353e2 100644 --- a/app_config/.env.example +++ b/app_config/.env.example @@ -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 diff --git a/app_config/apache/analyzer-vhost.snippet.conf b/app_config/apache/analyzer-vhost.snippet.conf index acc8400..17e3710 100644 --- a/app_config/apache/analyzer-vhost.snippet.conf +++ b/app_config/apache/analyzer-vhost.snippet.conf @@ -36,11 +36,11 @@ -# /etc/apache2/beacon-blocklist.conf — one `Require not ip ` per line. +# /etc/apache2/beacon-blocklist.conf: one `Require not ip ` 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 diff --git a/app_config/caddy/Caddyfile.type2-server b/app_config/caddy/Caddyfile.type2-server index d462de9..16a9b27 100644 --- a/app_config/caddy/Caddyfile.type2-server +++ b/app_config/caddy/Caddyfile.type2-server @@ -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 +# 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 + } } diff --git a/app_config/caddy/Caddyfile.type2-web b/app_config/caddy/Caddyfile.type2-web index c9657ec..7975a4f 100644 --- a/app_config/caddy/Caddyfile.type2-web +++ b/app_config/caddy/Caddyfile.type2-web @@ -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 } diff --git a/app_config/config.yaml.example b/app_config/config.yaml.example index d68edb8..6491285 100644 --- a/app_config/config.yaml.example +++ b/app_config/config.yaml.example @@ -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: diff --git a/docker-deployment-type1/.env b/docker-deployment-type1/.env deleted file mode 100644 index 4f67daa..0000000 --- a/docker-deployment-type1/.env +++ /dev/null @@ -1,2 +0,0 @@ -# Copy from app_config/.env.example and fill in your values. -# See the README for instructions. diff --git a/docker-deployment-type1/data/app/config.yaml b/docker-deployment-type1/data/app/config.yaml index 6ecf26a..6dfc478 100644 --- a/docker-deployment-type1/data/app/config.yaml +++ b/docker-deployment-type1/data/app/config.yaml @@ -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: diff --git a/docker-deployment-type1/docker-compose.yml b/docker-deployment-type1/docker-compose.yml index cbc611f..9f445d2 100644 --- a/docker-deployment-type1/docker-compose.yml +++ b/docker-deployment-type1/docker-compose.yml @@ -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 diff --git a/docker-deployment-type2/PLACEHOLDER.md b/docker-deployment-type2/PLACEHOLDER.md deleted file mode 100644 index e69de29..0000000 diff --git a/docker-deployment-type2/README.md b/docker-deployment-type2/README.md new file mode 100644 index 0000000..de6a7ff --- /dev/null +++ b/docker-deployment-type2/README.md @@ -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`. diff --git a/docker-deployment-type2/server/data/Caddy/CaddyFile/Caddyfile.proxy b/docker-deployment-type2/server/data/Caddy/CaddyFile/Caddyfile.proxy new file mode 100644 index 0000000..e7ca162 --- /dev/null +++ b/docker-deployment-type2/server/data/Caddy/CaddyFile/Caddyfile.proxy @@ -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 +# 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 + } +} diff --git a/docker-deployment-type2/server/data/Caddy/conf.d/blocklist.caddy b/docker-deployment-type2/server/data/Caddy/conf.d/blocklist.caddy new file mode 100644 index 0000000..4ffc0b6 --- /dev/null +++ b/docker-deployment-type2/server/data/Caddy/conf.d/blocklist.caddy @@ -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$" +} diff --git a/docker-deployment-type2/server/data/app/config.yaml b/docker-deployment-type2/server/data/app/config.yaml new file mode 100644 index 0000000..6dfc478 --- /dev/null +++ b/docker-deployment-type2/server/data/app/config.yaml @@ -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: (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 diff --git a/docker-deployment-type2/server/docker-compose.yml b/docker-deployment-type2/server/docker-compose.yml new file mode 100644 index 0000000..2a69d51 --- /dev/null +++ b/docker-deployment-type2/server/docker-compose.yml @@ -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 diff --git a/docker-deployment-type2/web/.env.example b/docker-deployment-type2/web/.env.example new file mode 100644 index 0000000..98f3723 --- /dev/null +++ b/docker-deployment-type2/web/.env.example @@ -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= diff --git a/docker-deployment-type2/web/data/Caddy/CaddyFile/Caddyfile.proxy b/docker-deployment-type2/web/data/Caddy/CaddyFile/Caddyfile.proxy new file mode 100644 index 0000000..71a0a98 --- /dev/null +++ b/docker-deployment-type2/web/data/Caddy/CaddyFile/Caddyfile.proxy @@ -0,0 +1,3 @@ +{$DOMAIN} { + reverse_proxy beacon-web:80 +} diff --git a/docker-deployment-type2/web/docker-compose.yml b/docker-deployment-type2/web/docker-compose.yml new file mode 100644 index 0000000..bf9a013 --- /dev/null +++ b/docker-deployment-type2/web/docker-compose.yml @@ -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 diff --git a/app_documentation/api_contract.md b/docs/api-contract.md similarity index 87% rename from app_documentation/api_contract.md rename to docs/api-contract.md index 6efce8f..32a0b0c 100644 --- a/app_documentation/api_contract.md +++ b/docs/api-contract.md @@ -122,6 +122,29 @@ A packet summary: `summary` is the advert name, `ACK `, `TRACE ` or `PING `, 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..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.) diff --git a/docs/backup-export.md b/docs/backup-export.md new file mode 100644 index 0000000..355ef37 --- /dev/null +++ b/docs/backup-export.md @@ -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). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..9d12ac0 --- /dev/null +++ b/docs/configuration.md @@ -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 `. 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. diff --git a/docs/getting-packets-in.md b/docs/getting-packets-in.md new file mode 100644 index 0000000..bbf244d --- /dev/null +++ b/docs/getting-packets-in.md @@ -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). diff --git a/app_documentation/high_level_design.md b/docs/high-level-design.md similarity index 99% rename from app_documentation/high_level_design.md rename to docs/high-level-design.md index 80b506c..776ae2a 100644 --- a/app_documentation/high_level_design.md +++ b/docs/high-level-design.md @@ -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? diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..ad755ea --- /dev/null +++ b/docs/operations.md @@ -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:///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:///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). diff --git a/docs/profiling.md b/docs/profiling.md new file mode 100644 index 0000000..c5e553c --- /dev/null +++ b/docs/profiling.md @@ -0,0 +1,70 @@ +# CPU profiling + +Beacon can record bounded CPU profiles on Linux and macOS. It's off by default, writes +private files only (there's no HTTP endpoint or port), and a profiling failure stops the +recorder with a warning without affecting ingest. + +## Enable a session + +Create a dedicated `0700` directory writable by the container user, mount it, and set both +variables: + +```yaml +services: + app: + environment: + BEACON_CPU_PROFILE_DIR: /profiles + BEACON_CPU_PROFILE_UNTIL: "2026-10-02T12:00:00Z" # fixed deadline + volumes: + - ./profiles:/profiles +``` + +- The deadline is RFC 3339, at most 72 hours ahead. It survives restarts and is inactive + once past. Don't generate a fresh deadline on every start. +- Changing either setting needs a restart. +- Start with a deadline about a minute out, and compare process CPU, packet freshness, queue + drops and database timeouts with an unprofiled period before running a longer session. +- One Beacon process per directory. Keep it out of web roots, public backups and source + control, and don't run another CPU profiler in the process meanwhile. + +## What gets captured + +- A 30-second sample at start, another 35 minutes later, then every 30 minutes (the extra + five minutes keeps a maintenance trigger from landing in the cooldown). +- An extra sample when route reconfirmation maintenance starts (including its retention + step). +- A five-minute cooldown between capture starts. A periodic sample due during it runs when it + ends; a triggered one is skipped, and any sample cancels a periodic one that's waiting. +- Background task stacks carry a `task` label. +- Shutdown or the deadline ends the active sample early and saves it. + +Limits: 8 MiB per profile; 256 MiB and 512 files per directory, counting metadata, leftovers +and unrelated regular files (subdirectories are ignored). The recorder reserves room for a +full capture first and stops at a limit. Nothing is deleted automatically. Files are `0600`; +a `.partial` file is an interrupted capture. + +## Reading the output + +Each `.pprof` has a `.pprof.json` sidecar with UTC start and end, the trigger, Go version, +process CPU counters, goroutine count, and database-pool counters before and after (pool +values are cumulative, so subtract). No SQL text or credentials. + +Copy completed files off the host privately, noting the image revision and digest. Then: + +```sh +go tool pprof -top capture.pprof +go tool pprof -top -cum capture.pprof +go tool pprof -tags capture.pprof +``` + +Profiles show CPU inside Beacon, not PostgreSQL work or lock waits. Correlate each interval +with host/container CPU and I/O, PostgreSQL wait states, observation throughput and Beacon's +timeout/drop logs. Don't turn on SQL text logging or run full-table counts for this; the +recorder itself makes no database queries. + +Review quiet, burst and maintenance captures separately: maintenance samples bias an +aggregate. Thirty-second windows can miss brief stalls, so a missing stack isn't proof it +never runs. + +When the deadline passes, look for `CPU profiling stopped` in the log, export the files, and +remove the variables and mount at the next deployment. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..1afc112 --- /dev/null +++ b/docs/releases.md @@ -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-` | +| push to `dev` | `dev`, `sha-` | +| tag `vX.Y.Z` | `X.Y.Z`, `X.Y`, `sha-` | + +`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). diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..d868472 --- /dev/null +++ b/docs/reverse-proxy.md @@ -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). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..05c68fb --- /dev/null +++ b/docs/security.md @@ -0,0 +1,54 @@ +# Securing a deployment + +Beacon is a read-mostly public service. The things worth protecting are the admin key, the API +listener, your broker credentials, and the database, which holds decrypted channel messages. + +## The admin key + +The `/api/v1/admin/*` endpoints are the only authenticated part of the API. Generate the key +with `openssl rand -hex 32`, set it as `BEACON_API_KEY`, and send it only in the +`Authorization: Bearer` header over HTTPS, never in a URL or a request body. Keep it out of +source control and logs. The rules Beacon enforces (16 characters minimum, no whitespace, +environment wins over the file) are in [Configuration](configuration.md#admin-api-key). + +If you do not need the admin endpoints, do not set a key. They answer `503` and nothing else +changes. + +## Keep the API listener private + +beacon-server listens on `:8080` with no TLS. In the Docker deployments it is only reachable +from the compose network and from the host's loopback; Caddy is the only thing on a public +port. If you run it another way, bind `LISTEN_ADDR` to a private address and terminate HTTPS +at the proxy. + +## Browser origins + +Only the page's own host may open `/ws` unless other origins are listed under +`websocket.allowed_origins`. REST CORS allows any origin by default but only the read-only +methods (`GET`, `HEAD`, `OPTIONS`). An admin UI on another origin needs `cors.allowed_origins` +restricted to that UI, the write methods added, and the `Authorization` and `Content-Type` +headers allowed, or its preflight requests fail. CORS controls what browsers may do, not who +is authenticated. + +## Rate limits and bans + +REST is limited to 300 requests a minute per client by default, WebSocket upgrades to 10 a +minute, with 5 open sockets per client. Behind a proxy none of that works until +`server.trusted_proxies` is set; see +[Reverse proxy](reverse-proxy.md#rate-limits-and-connection-caps). For repeat offenders, the +fail2ban jails and block list in [`app_config/fail2ban/`](../app_config/fail2ban/README.md) +ban at the edge. + +## What Beacon logs and returns + +Query strings and WebSocket hello payloads are never logged. HTTP completion records carry +the validated client address, route, status and duration. `GET /api/v1/admin/config` returns +no credentials, broker addresses, channel material or database settings. The backup export, +if you enable it, contains everything the database holds, so keep archives private; see +[Backup and export](backup-export.md). + +## Secrets in files + +`.env` holds the database password, broker credentials and the admin key. It is gitignored in +the deployment folders; keep it that way, and keep its permissions tight on the host. +`data/app/config.yaml` holds channel keys. Neither belongs in a public backup. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..4d29677 --- /dev/null +++ b/docs/troubleshooting.md @@ -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/: 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: ` 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. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..7b46061 --- /dev/null +++ b/docs/upgrading.md @@ -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.