Replace the dedicated admin tag management page with inline tag editing on the node detail page. Operators can now edit tags directly on nodes they've adopted; admins retain unrestricted access. Key changes: - Remove admin SPA page (admin/index.js, admin/node-tags.js) - Add inline tag editor to node-detail.js with add/edit/delete modals - Replace RequireAdmin with RequireOperatorOrAdmin for tag API routes - Add ownership check: operators restricted to adopted nodes only - Add validate_and_coerce_tag_value for number/boolean coercion - Remove unused bulk endpoints (copy, move, replace all) - Use AbortController for event listeners to prevent accumulation on lit-html DOM reuse across re-renders - Track Leaflet map instance at module scope for defensive cleanup - Fix checkAuthResponse to only redirect on 401 (not 403) - Update tests for new OIDC-based auth model - Update en.json locale, i18n.md, upgrading.md, AGENTS.md
19 KiB
Upgrading MeshCore Hub
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
v0.10.0
This release introduces OIDC authentication, user profiles with node adoption, removes the Members system, replaces role=infra tags with adoption-based infrastructure detection, and replaces the admin tag editor with an inline editor on the node detail page.
Breaking Changes
| Area | Before | After |
|---|---|---|
| Admin auth | WEB_ADMIN_ENABLED=true (open access) |
OIDC/OAuth2 authentication via identity provider |
| Network Members | members table + CRUD API + YAML seed |
Removed — replaced by UserProfile roles |
| Infrastructure detection | role=infra NodeTag |
user_profile_nodes adoption records |
| Tag editing | /admin/node-tags dedicated page |
Inline editor on node detail page |
| Tag API auth | RequireAdmin (API key with open fallback) |
RequireOperatorOrAdmin (OIDC role-based, always requires auth) |
| Admin UI | /admin/ routes with SPA pages |
Removed entirely |
| Map API field | infra_center |
adopted_center |
| Map API field | is_infra (on node objects) |
is_adopted |
| Prometheus label | role="infra" / role="" |
adopted="true" / adopted="false" |
| Profile endpoint | GET /api/v1/user/profile/{user_id} |
GET /api/v1/user/profile/{profile_id} (UUID) |
| Node cleanup default | 7 days | 30 days |
| Python | 3.13 | 3.14 |
Removed API Endpoints
| Method | Path | Replacement |
|---|---|---|
GET |
/nodes/{pk}/tags/{key} |
Use GET /nodes/{pk} and filter tags client-side |
PUT |
/nodes/{pk}/tags/{key}/move |
No replacement (delete + recreate) |
POST |
/nodes/{pk}/tags/copy-to/{dest} |
No replacement (create tags individually) |
DELETE |
/nodes/{pk}/tags (bulk) |
No replacement (delete tags individually) |
POST |
/api/v1/commands/send-message |
Removed |
POST |
/api/v1/commands/send-channel-message |
Removed |
POST |
/api/v1/commands/send-advertisement |
Removed |
| All | /api/v1/members/* |
Use /api/v1/user/profiles |
Removed Schemas
NodeTagMoveNodeTagsCopyResult
Removed CLI Commands
meshcore-hub collector import-members--membersflag onmeshcore-hub collector truncate
Removed Files
src/meshcore_hub/web/static/js/spa/pages/admin/index.jssrc/meshcore_hub/web/static/js/spa/pages/admin/node-tags.jstests/test_web/test_admin.pyseed/members.yamlexample/seed/members.yaml
Upgrade Actions
-
Set up an OIDC identity provider (LogTo, Keycloak, etc.) and configure these environment variables:
OIDC_ENABLED=true OIDC_CLIENT_ID=your-client-id OIDC_CLIENT_SECRET=your-client-secret OIDC_DISCOVERY_URL=https://your-idp.example.com/.well-known/openid-configuration OIDC_SESSION_SECRET=$(openssl rand -hex 32) -
Remove obsolete variables from your
.env:WEB_ADMIN_ENABLED(replaced byOIDC_ENABLED)OIDC_ADMIN_ROLE→ renamed toOIDC_ROLE_ADMINOIDC_MEMBER_ROLE→ renamed toOIDC_ROLE_MEMBER
-
Remove
members.yamlfrom your seed directory (no longer used) -
Remove
member_idtag keys fromnode_tags.yaml(replaced by node adoption) -
Run database migration — the migration:
- Adds
rolescolumn touser_profiles - Creates
user_profilesanduser_profile_nodestables (if not present) - Drops
memberstable - Deletes obsolete
role=infraandmember_idtags fromnode_tags
- Adds
-
Update Prometheus alerting rules that reference
role="infra"to useadopted="true"(seeetc/prometheus/alerts.yml) -
Update Grafana dashboards that query
meshcore_node_last_seen_timestamp_seconds{role="infra"}to useadopted="true" -
If you relied on the 7-day node cleanup default, set it explicitly:
NODE_CLEANUP_DAYS=7
OIDC-Disabled Deployments
When OIDC_ENABLED=false:
- Tag writes require OIDC authentication → 401 on direct API access (tags are read-only via web UI)
- The inline tag editor is hidden on the node detail page
adopted_centeris alwaysnull, all nodes haveis_adopted: false- The map shows no "Infrastructure Only" filter, no legend — all nodes render as green markers
- The web proxy only allows GET access to known API endpoints; writes are blocked
Tag Editor Authorization
Tag write endpoints now use RequireOperatorOrAdmin (OIDC role-based). The previous RequireAdmin had a fallback allowing open access when no admin key was configured. The new system always requires OIDC authentication:
- Operators can edit tags on their adopted nodes only
- Admins can edit tags on any node
- The admin API key no longer grants tag write access
New Variables
| Variable | Default | Description |
|---|---|---|
OIDC_ROLE_ADMIN |
admin |
IdP role name granting admin access |
OIDC_ROLE_OPERATOR |
operator |
IdP role name for operator access |
OIDC_ROLE_MEMBER |
member |
IdP role name for member access |
See .env.example for the full list of OIDC environment variables.
v0.9.0
This release includes breaking changes to the MQTT broker, packet capture service, data ingestion pipeline, and public key handling.
Overview of Changes
| Area | Before | After |
|---|---|---|
| MQTT broker | Eclipse Mosquitto (TCP) | meshcore-mqtt-broker (WebSocket, JWT auth) |
| Packet capture | Proprietary interface-receiver service |
meshcore-packet-capture (LetsMesh Observer model) |
| Auth model | MQTT username/password for publishing | JWT signed by device hardware public key |
| Collector MQTT | Anonymous subscriber | Subscriber account (admin-level) with credentials |
| Decoder | Node.js meshcore-decoder CLI subprocess |
Native Python meshcoredecoder library |
| Python | 3.13 | 3.14 |
| DB columns | receiver_node_id |
observer_node_id |
| DB table | event_receivers |
event_observers |
| API commands | /api/v1/commands/* |
Removed |
| Compose profiles | receiver, sender, mock |
observer |
| Compose files | Single docker-compose.yml |
Base + environment overrides (.dev.yml, .prod.yml) |
| Container names | meshcore-* |
Parameterized via COMPOSE_PROJECT_NAME (default: hub-*) |
| Volume names | meshcore_* |
Parameterized via COMPOSE_PROJECT_NAME (default: hub_*) |
| Public key case | Mixed (uppercase/lowercase) | Normalized to lowercase |
Public Key Case Normalization
Previously, the tag importer stored public_key as lowercase while the LetsMesh packet normalizer stored it as UPPERCASE. This could create duplicate nodes for the same physical device — with tags linked to one node and mesh events linked to another.
An Alembic migration (b1c2d3e4f5a6) automatically:
- Merges duplicate nodes (keeping the one with the earliest
first_seen) - Re-points all foreign key references to the surviving node
- Deletes the duplicate node
- Normalizes all remaining
public_keyvalues to lowercase
No manual action is required — the migration runs as part of meshcore-hub db upgrade (or the migrate Docker Compose service).
Step 1: Backup
Do not skip this step. Back up all data volumes before proceeding.
Back up the database volume. Volume names use the old meshcore_* prefix:
mkdir -p backup
docker run --rm -v meshcore_hub_data:/data -v $(pwd)/backup:/backup \
alpine tar czf /backup/meshcore_hub_data-$(date +%Y%m%d-%H%M%S).tar.gz -C / data
To restore from backup if needed:
# Extract the volume name from the backup filename
docker run --rm -v meshcore_hub_data:/data -v $(pwd)/backup:/backup \
alpine sh -c "cd / && tar xzf /backup/meshcore_hub_data-YYYYMMDD-HHMMSS.tar.gz"
Step 2: Stop and Remove Containers
Stop all services and remove orphaned containers from the old configuration:
docker compose --profile all down --remove-orphans
Important: Do NOT use
--volumes/-v. That would delete your database. The--remove-orphansflag cleans up old services (likeinterface-receiver,interface-sender) that no longer exist in the new compose file.
Step 3: Rename Docker Volumes
Container and volume names are now parameterized via COMPOSE_PROJECT_NAME. The default is hub, so volumes are renamed from meshcore_* to hub_*.
First, check which volumes you have:
docker volume ls | grep meshcore
Volumes to migrate
These volumes always need migrating:
| Old Name | New Name |
|---|---|
meshcore_hub_data |
hub_data |
Note:
observer_dataandmqtt_dataare new — they are created automatically on first run and do not need migrating.
Option A: Rename (Docker Engine 23.0+)
Note:
docker volume renameis not available in all Docker builds (e.g., Docker Desktop). If the command is not found, use Option B instead.
docker volume rename meshcore_hub_data hub_data
Option B: Copy (all Docker versions)
If docker volume rename is not available in your Docker build:
# Create new volume, copy data, remove old
docker volume create hub_data
docker run --rm -v meshcore_hub_data:/from -v hub_data:/to alpine sh -c "cp -a /from/. /to/"
# Verify the new volume has data, then remove old one
docker volume rm meshcore_hub_data
Note: If any volumes show "in use", remove any stopped containers first:
docker rm -f <container_id>.
Note: If setting up a multi-instance deployment (e.g.,
hub-prod,hub-beta), use that project name instead ofhub.
Note: After migrating volumes, you may see warnings like
volume "hub_data" already exists but was not created by Docker Compose. Use \external: true` to use an existing volume`. This is safe to ignore — it appears because the volumes were created manually during migration rather than by Docker Compose. Fresh deployments will not see this warning.
Step 4: Update Configuration Files
Download the latest configuration files:
# Download the base compose file and environment overrides
wget -O docker-compose.yml https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/main/docker-compose.yml
wget -O docker-compose.dev.yml https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/main/docker-compose.dev.yml
wget -O docker-compose.prod.yml https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/main/docker-compose.prod.yml
# Download the new .env.example for reference
wget -O .env.example https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/main/.env.example
Then compare your existing .env against the new .env.example and update it (see Step 5).
Step 5: Migrate Your .env File
Variables to Remove
These variables no longer exist and should be removed from your .env:
# Removed: ingest mode is now always LetsMesh upload
COLLECTOR_INGEST_MODE=native
# Removed: decoder is now a native Python library, always enabled
COLLECTOR_LETSMESH_DECODER_ENABLED=true
COLLECTOR_LETSMESH_DECODER_COMMAND=meshcore-decoder
COLLECTOR_LETSMESH_DECODER_TIMEOUT_SECONDS=2.0
# Removed: serial baud is handled by meshcore-packet-capture
SERIAL_BAUD=115200
# Removed: sender service no longer exists
SERIAL_PORT_SENDER=/dev/ttyUSB1
NODE_ADDRESS_SENDER=
# Removed: device name/address now handled by meshcore-packet-capture
MESHCORE_DEVICE_NAME=
NODE_ADDRESS=
# Removed: contact cleanup was specific to the proprietary receiver
CONTACT_CLEANUP_ENABLED=true
CONTACT_CLEANUP_DAYS=7
# Removed: Mosquitto-specific ports
MQTT_EXTERNAL_PORT=1883
MQTT_WS_PORT=9001
Variables to Update
| Variable | Old Value | New Value | Notes |
|---|---|---|---|
MQTT_TRANSPORT |
tcp |
websockets |
Required by the new JWT-based broker |
MQTT_WS_PATH |
/mqtt |
/ |
New broker accepts connections on / |
MQTT_USERNAME |
(empty/optional) | Subscriber username | Now required for collector subscriber auth. Set to match your broker's SUBSCRIBER_1 config. |
MQTT_PASSWORD |
(empty/optional) | Subscriber password | Now required for collector subscriber auth. Generate a secure password: openssl rand -base64 32 |
Note: The Python-level defaults for
MQTT_TRANSPORTandMQTT_WS_PATHare nowwebsocketsand/, matching the Docker Compose and.env.examplevalues. No additional configuration is needed for non-Docker users.
Variables to Add
# Docker Compose project name (container and volume prefix)
COMPOSE_PROJECT_NAME=hub
# JWT audience claim for packet capture authentication tokens
# Must match AUTH_EXPECTED_AUDIENCE on the broker
MQTT_TOKEN_AUDIENCE=mqtt.localhost
# IATA airport code for your observer location (required for packet capture)
# Use the 3-letter code for the nearest airport.
# Look up your code: https://www.iata.org/en/publications/directories/code-search/
PACKETCAPTURE_IATA=LOC
All other PACKETCAPTURE_* variables have sensible defaults in docker-compose.yml and only need to be set in .env if you want to override them. See .env.example for the full list.
Step 6: Run Database Migration
The migration renames receiver_node_id → observer_node_id across all event tables, event_receivers → event_observers, and received_at → observed_at in the event observers table:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core run --rm migrate
This runs automatically as part of the core profile, but can also be run standalone with the migrate profile:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile migrate run --rm migrate
Step 7: Start Services
With local MQTT broker (single-host deployment)
# Start everything including the MQTT broker
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile mqtt --profile core up -d
# Or include packet capture on the same host
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile mqtt --profile core --profile observer up -d
With external MQTT broker
# Start core services only (broker runs elsewhere)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core up -d
Verify
# Check all containers are running
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile all ps
# Check collector connected to MQTT
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile all logs collector | grep -i "connected to mqtt"
# Check the web dashboard
open http://localhost:8080
Notes
JWT-Based Packet Capture Authentication
The new packet capture service (meshcore-packet-capture) uses the LetsMesh Observer model:
- No custom MQTT credentials needed for publishing. Authentication is handled via JWT tokens signed by the capture device's hardware public key. The MQTT broker validates the JWT and authorizes publishing automatically.
- The collector connects as a subscriber to read all published events, including
/internaltopics. ConfigureMQTT_USERNAMEandMQTT_PASSWORDto match the broker's subscriber account.
Production MQTT Configuration
In production, the MQTT WebSocket server should be hosted behind a TLS/SSL-terminated reverse proxy (e.g., Nginx Proxy Manager, Caddy, Traefik) under the /mqtt path. The proxy handles TLS termination and forwards plain WebSocket connections to the broker on port 1883.
Local / development (default):
MQTT_PORT=1883
MQTT_TRANSPORT=websockets
MQTT_WS_PATH=/
MQTT_TLS=false
MQTT_TOKEN_AUDIENCE=mqtt.localhost
Production (behind reverse proxy):
MQTT_PORT=443
MQTT_TRANSPORT=websockets
MQTT_WS_PATH=/mqtt
MQTT_TLS=true
MQTT_TOKEN_AUDIENCE=mqtt.example.com # your public domain
Existing LetsMesh Observer Installs
If you already run meshcore-packet-capture separately, configure MQTT server #3 to point at your MeshCore Hub MQTT broker. Servers #1 and #2 are reserved for Let's Mesh US (mqtt-us-v1.letsmesh.net) and Let's Mesh EU (mqtt-eu-v1.letsmesh.net) respectively.
# In your packet-capture .env or docker-compose environment:
PACKETCAPTURE_MQTT3_ENABLED=true
PACKETCAPTURE_MQTT3_SERVER=your-meshcore-hub-host
PACKETCAPTURE_MQTT3_PORT=1883
PACKETCAPTURE_MQTT3_TRANSPORT=websockets
PACKETCAPTURE_MQTT3_USE_TLS=false
PACKETCAPTURE_MQTT3_USE_AUTH_TOKEN=true
PACKETCAPTURE_MQTT3_TOKEN_AUDIENCE=mqtt.localhost
Removed Services
The following Docker Compose services have been removed:
| Old Service | Replacement |
|---|---|
interface-receiver |
observer (profile: observer) |
interface-sender |
None (removed) |
interface-mock-receiver |
None (removed) |
The observer service uses the meshcore-packet-capture image and is included in docker-compose.yml under the observer profile for an easy transition.
New Docker Compose File Structure
The Docker Compose configuration is now split into multiple files:
| File | Purpose |
|---|---|
docker-compose.yml |
Base shared config (services, profiles, healthchecks, environment) |
docker-compose.dev.yml |
Development overrides (port mappings for direct access) |
docker-compose.prod.yml |
Production overrides (external proxy network, no exposed ports) |
docker-compose.traefik.yml |
Optional Traefik auto-discovery labels |
All docker compose commands now require explicit file selection:
# Development (exposes ports for local access)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile all up -d
# Production (connects to reverse proxy network)
docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile all up -d
# Production with Traefik
docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.traefik.yml --profile all up -d
Container and volume names are parameterized via COMPOSE_PROJECT_NAME in .env. This enables multiple instances (e.g., hub-prod, hub-beta) on the same Docker host.
Removed API Endpoints
The command dispatch API endpoints have been removed:
POST /api/v1/commands/send-messagePOST /api/v1/commands/send-channel-messagePOST /api/v1/commands/send-advertisement
Native Python Decoder
The Node.js meshcore-decoder CLI tool has been replaced by the native Python meshcoredecoder library. This means:
- No Node.js runtime is needed in the Docker image
- The decoder is always enabled (no toggle)
- The
COLLECTOR_LETSMESH_DECODER_*configuration variables have been removed COLLECTOR_LETSMESH_DECODER_KEYShas been renamed toCOLLECTOR_CHANNEL_KEYS- New
COLLECTOR_INCLUDE_TEST_CHANNELvariable controls whether built-in test channel messages are collected (default:false)