mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-11 11:22:50 +02:00
Add database-backed channels with role-based visibility and web dashboard
Replaces env-var channel keys with a Channel database model and periodic DB refresh in the collector. Adds Channels dashboard page with QR codes, channel visibility filtering on messages/dashboard APIs, and channel card navigation to filtered messages view.
This commit is contained in:
@@ -74,8 +74,6 @@ Some code bypasses Pydantic Settings entirely and reads env vars directly:
|
||||
|
||||
| File | Env Var | Default | Purpose |
|
||||
|------|---------|---------|---------|
|
||||
| `src/meshcore_hub/web/app.py` | `COLLECTOR_CHANNEL_KEYS` | `None` | Reads channel keys for web UI label building |
|
||||
| `src/meshcore_hub/web/app.py` | `COLLECTOR_INCLUDE_TEST_CHANNEL` | `"false"` | Reads test channel flag for web UI |
|
||||
| `src/meshcore_hub/common/health.py` | `HEALTH_DIR` | `/tmp/meshcore-hub` | Health status file directory |
|
||||
| `src/meshcore_hub/alembic/env.py` | `DATABASE_URL` | Falls to config | Alembic migration DB URL |
|
||||
| `src/meshcore_hub/alembic/env.py` | `DATA_HOME` | Falls to config | Alembic fallback for computing DB URL |
|
||||
|
||||
@@ -141,8 +141,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
|
||||
| `MQTT_WS_PATH` | `/` | MQTT |
|
||||
| `DATA_HOME` | `/data` (hardcoded) | Path |
|
||||
| `SEED_HOME` | `/seed` (hardcoded) | Path |
|
||||
| `COLLECTOR_CHANNEL_KEYS` | (empty) | Collector |
|
||||
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Collector |
|
||||
| `CHANNEL_REFRESH_INTERVAL_SECONDS` | `300` | Collector |
|
||||
| `WEBHOOK_ADVERTISEMENT_URL` | (passthrough) | Webhook |
|
||||
| `WEBHOOK_ADVERTISEMENT_SECRET` | (passthrough) | Webhook |
|
||||
| `WEBHOOK_MESSAGE_URL` | (passthrough) | Webhook |
|
||||
@@ -221,8 +220,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
|
||||
| `NETWORK_WELCOME_TEXT` | (empty) | Network |
|
||||
| `CONTENT_HOME` | `/content` (hardcoded) | Path |
|
||||
| `TZ` | `UTC` | Display |
|
||||
| `COLLECTOR_CHANNEL_KEYS` | (empty) | Display |
|
||||
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Display |
|
||||
| `FEATURE_CHANNELS` | `true` | Feature |
|
||||
| `FEATURE_DASHBOARD` | `true` | Feature |
|
||||
| `FEATURE_NODES` | `true` | Feature |
|
||||
| `FEATURE_ADVERTISEMENTS` | `true` | Feature |
|
||||
@@ -230,6 +228,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
|
||||
| `FEATURE_MAP` | `true` | Feature |
|
||||
| `FEATURE_MEMBERS` | `true` | Feature |
|
||||
| `FEATURE_PAGES` | `true` | Feature |
|
||||
| `FEATURE_CHANNELS` | `true` | Feature |
|
||||
|
||||
### Observer (Packet Capture) Env Vars
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ docs/letsmesh.md documents the LetsMesh packet normalization and decoding behavi
|
||||
|
||||
- [ ] MQTT subscription topics match `subscriber.py` topic patterns
|
||||
- [ ] Payload type mappings match `letsmesh_decoder.py` and `letsmesh_normalizer.py` logic
|
||||
- [ ] Channel key handling documented matches `COLLECTOR_CHANNEL_KEYS` config behavior
|
||||
- [ ] Channel key handling documented matches database-backed channels (channels table, CHANNEL_REFRESH_INTERVAL_SECONDS)
|
||||
- [ ] Known channel indexes (`17 -> Public`, `217 -> #test`) match built-in defaults in decoder
|
||||
- [ ] Message normalization rules match collector handler implementations
|
||||
- [ ] GPS/location update behavior documented matches advertisement handler logic
|
||||
@@ -219,7 +219,7 @@ Verify sections exist and are correctly ordered:
|
||||
2. [ ] Common Settings (`COMPOSE_PROJECT_NAME`, `TRAEFIK_DOMAIN`, `IMAGE_VERSION`, `LOG_LEVEL`, `DATA_HOME`, `SEED_HOME`)
|
||||
3. [ ] MQTT Settings (`MQTT_HOST`, `MQTT_PORT`, `MQTT_USERNAME`, `MQTT_PASSWORD`, `MQTT_PREFIX`, `MQTT_TLS`, `MQTT_TRANSPORT`, `MQTT_WS_PATH`, `MQTT_TOKEN_AUDIENCE`)
|
||||
4. [ ] Packet Capture Settings (all `PACKETCAPTURE_*` vars + `SERIAL_PORT`)
|
||||
5. [ ] Collector Settings (`COLLECTOR_CHANNEL_KEYS`, `COLLECTOR_INCLUDE_TEST_CHANNEL`, webhooks, retention, cleanup)
|
||||
5. [ ] Collector Settings (`CHANNEL_REFRESH_INTERVAL_SECONDS`, webhooks, retention, cleanup)
|
||||
6. [ ] API Settings (`API_PORT`, `API_READ_KEY`, `API_ADMIN_KEY`, metrics)
|
||||
7. [ ] Web Dashboard Settings (`WEB_PORT`, `API_BASE_URL`, `API_KEY`, theme, locale, auto-refresh, admin, TZ, content home, network info, feature flags, contact info)
|
||||
|
||||
@@ -243,7 +243,7 @@ For every variable in `.env.example`:
|
||||
- [ ] `MQTT_WS_PATH` comment notes default `/` vs production `/mqtt`
|
||||
- [ ] `MQTT_TOKEN_AUDIENCE` comment explains it must match broker config
|
||||
- [ ] `PACKETCAPTURE_*` comments reference the external packet capture image
|
||||
- [ ] `COLLECTOR_CHANNEL_KEYS` comment explains label=hex format
|
||||
- [ ] `CHANNEL_REFRESH_INTERVAL_SECONDS` comment explains channel key refresh from database
|
||||
- [ ] `WEB_*` comments reference web dashboard behavior
|
||||
- [ ] `FEATURE_*` comments explain what each flag controls
|
||||
- [ ] `NETWORK_*` comments explain where values appear in UI
|
||||
|
||||
+10
-9
@@ -197,15 +197,15 @@ PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=true
|
||||
# LetsMesh decoder support
|
||||
# The native Python decoder is always enabled.
|
||||
# Optional: channel secret keys (comma or space separated) used to decrypt GroupText
|
||||
# packets. This supports unlimited keys.
|
||||
# Note: Public + #test keys are built into the collector code by default.
|
||||
# To show friendly channel names in the web feed, use label=hex (example: bot=ABCDEF...).
|
||||
# Without keys, encrypted packets cannot be shown as plaintext.
|
||||
# COLLECTOR_CHANNEL_KEYS=
|
||||
|
||||
# Include built-in 'test' channel messages (channel_idx 217)
|
||||
# Default: false (test channel messages are discarded)
|
||||
# COLLECTOR_INCLUDE_TEST_CHANNEL=false
|
||||
# -------------------
|
||||
# Channel Settings
|
||||
# -------------------
|
||||
# Channel keys are now managed via the database (channels table).
|
||||
# Use `meshcore-hub collector channel add --name X --key HEX` or seed via channels.yaml.
|
||||
# See docs/seeding.md for the channels.yaml format.
|
||||
#
|
||||
# Refresh interval for reloading channel keys from the database (seconds).
|
||||
# CHANNEL_REFRESH_INTERVAL_SECONDS=300
|
||||
|
||||
# -------------------
|
||||
# Webhook Settings
|
||||
@@ -457,6 +457,7 @@ NETWORK_ANNOUNCEMENT=
|
||||
# FEATURE_MAP=true
|
||||
# FEATURE_MEMBERS=true
|
||||
# FEATURE_PAGES=true
|
||||
# FEATURE_CHANNELS=true
|
||||
|
||||
# -------------------
|
||||
# Contact Information
|
||||
|
||||
@@ -264,11 +264,13 @@ meshcore-hub/
|
||||
│ │ ├── hash_utils.py # Hash utility functions
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ │ ├── node.py # Node model
|
||||
│ │ │ ├── channel.py # Channel model (encryption keys)
|
||||
│ │ │ ├── user_profile.py # User profile model (OIDC users)
|
||||
│ │ │ ├── user_profile_node.py # User-node adoption join table
|
||||
│ │ │ └── ...
|
||||
│ │ └── schemas/ # Pydantic schemas
|
||||
│ │ ├── user_profiles.py # User profile API schemas
|
||||
│ │ ├── channels.py # Channel API schemas
|
||||
│ │ └── ...
|
||||
│ ├── collector/
|
||||
│ │ ├── cli.py # Collector CLI with seed commands
|
||||
@@ -287,8 +289,9 @@ meshcore-hub/
|
||||
│ │ ├── metrics.py # Prometheus metrics endpoint
|
||||
│ │ └── routes/ # API routes
|
||||
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
|
||||
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
|
||||
│ │ └── ...
|
||||
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
|
||||
│ │ ├── channels.py # Channel CRUD endpoints (GET/POST/PUT/DELETE channels)
|
||||
│ │ └── ...
|
||||
│ └── web/
|
||||
│ ├── cli.py
|
||||
│ ├── app.py # FastAPI app
|
||||
@@ -635,8 +638,7 @@ Key variables:
|
||||
- `MQTT_TRANSPORT` - MQTT transport protocol (default: `websockets`)
|
||||
- `MQTT_WS_PATH` - WebSocket path (default: `/`)
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`, set `true` for `wss://`)
|
||||
- `COLLECTOR_CHANNEL_KEYS` - Additional decoder channel keys for decrypting GroupText packets
|
||||
- `COLLECTOR_INCLUDE_TEST_CHANNEL` - Include built-in 'test' channel messages (default: `false`)
|
||||
- `CHANNEL_REFRESH_INTERVAL_SECONDS` - Seconds between channel key refresh from database (default: `300`, min: `10`)
|
||||
- `API_HOST` - API server bind address (default: `0.0.0.0`)
|
||||
- `API_PORT` - API server port (default: `8000`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
@@ -668,7 +670,7 @@ Key variables:
|
||||
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
|
||||
- `WEB_DEBUG` - Enable debug mode in the web dashboard (default: `false`)
|
||||
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
|
||||
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
|
||||
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES`, `FEATURE_CHANNELS` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
|
||||
- `NETWORK_DOMAIN` - Network domain name (default: none)
|
||||
- `NETWORK_NAME` - Network display name (default: `MeshCore Network`)
|
||||
- `NETWORK_CITY` - Network city location (default: none)
|
||||
|
||||
@@ -330,10 +330,9 @@ All components are configured via environment variables. Create a `.env` file or
|
||||
|
||||
### Collector Settings
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `COLLECTOR_CHANNEL_KEYS` | _(none)_ | Additional decoder channel keys (`label=hex`, `label:hex`, or `hex`) |
|
||||
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Include built-in 'test' channel messages |
|
||||
| Variable | Default | Description |
|
||||
| ---------------------------------- | ------- | -------------------------------------------------------- |
|
||||
| `CHANNEL_REFRESH_INTERVAL_SECONDS` | `300` | Seconds between channel key refresh from database (min 10) |
|
||||
|
||||
#### LetsMesh Packet Decoding
|
||||
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ Group/broadcast messages on specific channels.
|
||||
- In LetsMesh upload compatibility mode, packet type `5` is normalized to `CHANNEL_MSG_RECV` and packet types `1`, `2`, and `7` are normalized to `CONTACT_MSG_RECV` when decryptable text is available.
|
||||
- LetsMesh packets without decryptable message text are treated as informational `letsmesh_packet` events instead of message events.
|
||||
- For UI labels, known channel indexes are mapped (`17 -> Public`, `217 -> #test`) and preferred over ambiguous/stale channel-name hints.
|
||||
- Additional channel labels can be provided through `COLLECTOR_CHANNEL_KEYS` using `label=hex` entries.
|
||||
- Additional channel labels are loaded from the `channels` database table via the collector's periodic refresh.
|
||||
- When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message`; sender identity remains unknown when only hash/prefix metadata is available.
|
||||
|
||||
**Compatibility ingest note (advertisements)**:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add channels table
|
||||
|
||||
Revision ID: 82dff87d6576
|
||||
Revises: 20260515_1920
|
||||
Create Date: 2026-05-19 21:25:58.828179+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "82dff87d6576"
|
||||
down_revision: Union[str, None] = "20260515_1920"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"channels",
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("key_hex", sa.String(length=64), nullable=False),
|
||||
sa.Column("channel_hash", sa.String(length=2), nullable=False),
|
||||
sa.Column("visibility", sa.String(length=20), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key_hex"),
|
||||
)
|
||||
with op.batch_alter_table("channels", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_channels_name"), ["name"], unique=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("channels", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_channels_name"))
|
||||
|
||||
op.drop_table("channels")
|
||||
# ### end Alembic commands ###
|
||||
+2
-4
@@ -155,8 +155,7 @@ services:
|
||||
- MQTT_TLS=${MQTT_TLS:-false}
|
||||
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
|
||||
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
|
||||
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
||||
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
||||
- CHANNEL_REFRESH_INTERVAL_SECONDS=${CHANNEL_REFRESH_INTERVAL_SECONDS:-300}
|
||||
- DATA_HOME=/data
|
||||
- SEED_HOME=/seed
|
||||
# Webhook configuration
|
||||
@@ -295,8 +294,6 @@ services:
|
||||
- NETWORK_ANNOUNCEMENT=${NETWORK_ANNOUNCEMENT:-}
|
||||
- CONTENT_HOME=/content
|
||||
- TZ=${TZ:-UTC}
|
||||
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
|
||||
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
|
||||
# Feature flags (set to false to disable specific pages)
|
||||
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
|
||||
- FEATURE_NODES=${FEATURE_NODES:-true}
|
||||
@@ -305,6 +302,7 @@ services:
|
||||
- FEATURE_MAP=${FEATURE_MAP:-true}
|
||||
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
|
||||
- FEATURE_PAGES=${FEATURE_PAGES:-true}
|
||||
- FEATURE_CHANNELS=${FEATURE_CHANNELS:-true}
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ The collector subscribes to packets published by [meshcore-packet-capture](https
|
||||
|
||||
- For channel packets, if a channel key is available, a channel label is attached (for example `Public` or `#test`) for UI display.
|
||||
- In the messages feed and dashboard channel sections, known channel indexes are preferred for labels (`17 -> Public`, `217 -> #test`) to avoid stale channel-name mismatches.
|
||||
- Additional channel names are loaded from `COLLECTOR_CHANNEL_KEYS` when entries are provided as `label=hex` (for example `bot=<key>`).
|
||||
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_CHANNEL_KEYS`.
|
||||
- Additional channel names are loaded from the `channels` database table (managed via CLI, API, or seed YAML).
|
||||
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from enabled database channel rows.
|
||||
|
||||
## Location and Messages
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Channel Model: Database-Backed Decrypt Keys with Permission-Based Visibility
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `Channel` database model to replace the `COLLECTOR_CHANNEL_KEYS` environment variable entirely. Channels store their name, secret key, computed channel hash, and a **visibility/permission level** (`public`, `member`, `operator`, `admin`). The collector loads keys from the database at startup and periodically refreshes them without restart. The web dashboard and API enforce permission-based visibility: "public" channels are visible to everyone (including logged-out users), "member" channels only to authenticated members, and so on.
|
||||
|
||||
A new **Channels page** in the web dashboard presents channels as cards (desktop and mobile), each showing a **QR code** for easy joining (`meshcore://channel/add?name=...&key=...`). The page is always visible regardless of OIDC status. When OIDC is enabled, admin users see inline channel management (add/edit/delete) and non-admin users see read-only cards filtered by their role. When OIDC is disabled, all channels are `public` by default, no admin UI is shown, and channels can only be configured via the seed mechanism.
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
### Current State
|
||||
|
||||
Channel decryption keys flow through the system as follows:
|
||||
|
||||
1. `COLLECTOR_CHANNEL_KEYS` env var (comma/space-separated hex strings, e.g. `"MyChannel=ABC123...,Other=DEF456..."`)
|
||||
2. Parsed by `CollectorSettings.collector_channel_keys_list` into a `list[str]` (`config.py:185-193`)
|
||||
3. Passed to `create_subscriber(channel_keys=...)` and then `LetsMeshPacketDecoder(channel_keys=...)` (`subscriber.py:88-90`)
|
||||
4. The decoder builds a `MeshCoreKeyStore` with `add_channel_secrets()` and uses it to decrypt GroupText (type 5) packets via the `meshcoredecoder` library (`letsmesh_decoder.py:63-68`)
|
||||
5. Built-in keys (`Public`, `test`) are always included via `BUILTIN_CHANNEL_KEYS` (`letsmesh_decoder.py:32-35`)
|
||||
6. The web app independently builds channel labels via `_build_channel_labels()` in `web/app.py:171-185`, reading the same env var to create a decoder instance just for label resolution
|
||||
|
||||
### Problems
|
||||
|
||||
- **Restart required**: Adding or changing a channel key requires editing `.env` and restarting the collector process.
|
||||
- **No permission model**: All channels are visible to all users. There is no way to restrict sensitive channels (e.g., operator-only coordination channels) from public view.
|
||||
- **No API/CLI management**: There is no way to add/remove channel keys at runtime.
|
||||
- **No audit trail**: Keys exist only in config; there is no record of when a key was added or by whom.
|
||||
- **Duplicated decoder construction**: The web app builds its own `LetsMeshPacketDecoder` just to resolve channel labels (`web/app.py:179-182`). With a database source, both collector and web can query the same `channels` table.
|
||||
- **No user-facing channel info**: Users cannot discover available channels, see their names, or get QR codes to join them from their devices.
|
||||
|
||||
### Why Now
|
||||
|
||||
The collector already queries the database for cleanup and event persistence. The web app already has a QR code library (`qrcodejs`) loaded globally and used on the node detail page (`node-detail.js:361-374`) with the `meshcore://` URL scheme. The API proxy already has a role-based access control framework (`_build_endpoint_access` / `check_api_access` in `web/app.py:68-161`). Making channels a first-class database entity unblocks permission-based message filtering, a channels management page, and QR code distribution.
|
||||
|
||||
## Goals
|
||||
|
||||
- Introduce a `Channel` SQLAlchemy model with name, key, channel hash, **visibility/permission level**, and enabled flag
|
||||
- Replace `COLLECTOR_CHANNEL_KEYS` env var entirely with database-backed channels
|
||||
- Have the collector load keys from the `channels` table at startup and refresh periodically (no restart)
|
||||
- Enforce permission-based visibility in messages view and dashboard: only show messages on channels the user has access to
|
||||
- Provide a **Channels page** (`/channels`) that is always visible (with or without OIDC)
|
||||
- When OIDC is enabled: show admin-only inline channel management; filter channel visibility by user role
|
||||
- When OIDC is disabled: show all (public-only) channels read-only; channels configured only via seed
|
||||
- Provide CLI commands and API endpoints for channel CRUD
|
||||
- Support seeding channels from YAML (visibility defaults to `public`; no visibility field in seed data)
|
||||
- Remove `COLLECTOR_CHANNEL_KEYS` and related config plumbing
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing the `meshcoredecoder` library or the decryption logic itself
|
||||
- Storing node-specific private keys (this is about channel shared secrets)
|
||||
- Encrypting channel keys at rest in the database
|
||||
- End-to-end encryption or per-user channel access control beyond the role-based visibility model
|
||||
- Filtering messages at the collector level (the collector decrypts all channels; filtering is done at the API/web layer)
|
||||
- Admin UI for channels when OIDC is disabled (seed-only configuration)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-1**: A `Channel` database model with fields:
|
||||
- `id` (UUID primary key)
|
||||
- `name` (String(100), unique, non-empty)
|
||||
- `key_hex` (String(64), uppercase hex, unique — supports both AES-128 and AES-256 keys)
|
||||
- `channel_hash` (String(2), computed: first byte of SHA-256 of `key_hex`)
|
||||
- `visibility` (Enum: `public`, `member`, `operator`, `admin`; default `public`)
|
||||
- `enabled` (Boolean, default `true`)
|
||||
- `created_at`, `updated_at` (timestamps)
|
||||
- **FR-2**: On startup, the collector queries all `Channel` rows where `enabled=true`, merges them with the hardcoded built-in keys (`Public`, `test` — both always available to the decoder), and builds the `MeshCoreKeyStore`. The `Public` built-in key always has `visibility=public` and cannot be overridden. The `test` built-in key is always loaded into the decoder for decryption, **but** test channel messages are **discarded by the normalizer by default** — they are only stored when a `test` channel row exists in the DB with `enabled=true` (added by an admin via CLI/API/seed). This replaces the `COLLECTOR_INCLUDE_TEST_CHANNEL` env var.
|
||||
- **FR-3**: The collector periodically refreshes its key store from the database (configurable interval, default 5 minutes).
|
||||
- **FR-4**: **Permission-based message visibility**: The API `/messages` endpoint accepts the user's role context (via OIDC X-User-Roles header from the web proxy, or API key auth for direct calls) and filters channel messages so that:
|
||||
- No OIDC / OIDC disabled: all channel messages visible (all channels are `public` in this mode)
|
||||
- Logged-out (OIDC enabled): only messages on `public` channels
|
||||
- `member` role: messages on `public` + `member` channels
|
||||
- `operator` role: messages on `public` + `member` + `operator` channels
|
||||
- `admin` role: all messages (all channels)
|
||||
- Direct messages (non-channel) remain governed by existing read access rules.
|
||||
- **FR-4b**: **Dashboard channel activity filtering**: The `/dashboard/stats` and `/dashboard/message-activity` endpoints filter channel-related data by the same role-based visibility rules as `/messages`. Channel message counts, channel-specific message lists, and activity charts only include data from channels visible to the requesting user.
|
||||
- **FR-5**: The dashboard and messages page channel filter dropdowns only show channels visible to the current user's role. When OIDC is disabled, all channels appear.
|
||||
- **FR-6**: **Channels page** (`/channels`) -- always visible regardless of OIDC status:
|
||||
- **OIDC disabled**: Read-only card grid showing all channels (all `public`). No add/edit/delete UI. No visibility badges.
|
||||
- **OIDC enabled, no auth**: Read-only cards showing only `public` channels.
|
||||
- **OIDC enabled, logged in**: Read-only cards showing channels up to the user's role level, with visibility badges.
|
||||
- **OIDC enabled, admin**: Full management -- "Add Channel" button, edit/delete buttons per card, visibility select in forms.
|
||||
- Card layout (responsive, works on desktop and mobile)
|
||||
- Each card shows: channel name, channel hash, visibility badge (if OIDC enabled), QR code, enabled status
|
||||
- QR code format: `meshcore://channel/add?name=<encoded_name>&key=<key_hex>`
|
||||
- Card shows masked key (first/last 4 chars) with a reveal toggle for admins
|
||||
- **FR-7**: **Admin inline channel management** (OIDC enabled + admin role only): Add/edit/delete channels via modal dialogs (following the tag editor pattern in `node-detail.js`). Only the `admin` role can perform these operations. This is not available when OIDC is disabled.
|
||||
- **FR-8**: CLI commands: `meshcore-hub collector channel list`, `channel add --name X --key HEX --visibility public`, `channel remove --name X`, `channel enable/disable --name X`.
|
||||
- **FR-9**: API endpoints:
|
||||
- `GET /channels` -- list channels; filtered by user role visibility when OIDC enabled; returns all public channels when OIDC disabled
|
||||
- `POST /channels` -- create (admin only, OIDC required)
|
||||
- `PUT /channels/{id}` -- update (admin only, OIDC required)
|
||||
- `DELETE /channels/{id}` -- delete (admin only, OIDC required)
|
||||
- The web proxy (`_build_endpoint_access`) guards mutations behind the `admin` role; `GET` is `_OPEN`
|
||||
- **FR-10**: Channel seeding from `${SEED_HOME}/channels.yaml` via `meshcore-hub collector seed`. Seed format does not include a `visibility` field -- seeded channels always get `visibility=public`. This is the only way to configure channels when OIDC is disabled.
|
||||
- **FR-11**: `COLLECTOR_CHANNEL_KEYS` env var and related config (`collector_channel_keys`, `collector_channel_keys_list`) are removed. Migration guide documents the removal.
|
||||
- **FR-12**: The web app's `_build_channel_labels()` in `web/app.py` is updated to query the `channels` table from the shared database instead of re-parsing the env var.
|
||||
- **FR-13**: The `FEATURE_CHANNELS` feature flag controls page visibility. It does not depend on OIDC being enabled (unlike `feature_members` which requires OIDC). The page is available to all users when the flag is `true`.
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
- **TR-1**: New model `Channel` in `src/meshcore_hub/common/models/channel.py`, exported from `models/__init__.py`.
|
||||
- **TR-2**: Alembic migration to create the `channels` table.
|
||||
- **TR-3**: Pydantic schemas for channel CRUD in `src/meshcore_hub/common/schemas/channels.py`.
|
||||
- **TR-4**: `LetsMeshPacketDecoder.reload_keys(channel_keys: list[str])` method that rebuilds `MeshCoreKeyStore` and `_channel_names_by_hash` without discarding the decode cache. Thread-safe via atomic reference swap.
|
||||
- **TR-5**: `Subscriber` gains a `_start_channel_refresh_scheduler()` method following the cleanup scheduler pattern (`subscriber.py:245-357`). Uses `DatabaseManager.async_session()` to query channels.
|
||||
- **TR-6**: Message filtering at the API layer: the `/messages` route resolves the user's highest role, queries `channels` table for visible channel hashes, then filters channel messages. Filtering logic:
|
||||
- OIDC disabled (no auth roles): no filtering — all channels treated as `public`.
|
||||
- OIDC enabled: query DB for all channel hashes up to the user's visibility level. The query filter is: `(message_type != 'channel') OR (channel_idx IN (visible_hashes_as_ints)) OR (channel_idx NOT IN (all_known_hashes_as_ints))`. This shows direct messages always, channels at/below the user's visibility level, and unknown channels (treated as `public`). No pre-filtering means channels visible in the filter dropdown may differ from visible messages, but the `<10 channel count makes this negligible.
|
||||
- **TR-7**: New SPA page module `src/meshcore_hub/web/static/js/spa/pages/channels.js` with card layout, QR code generation (reusing the `QRCode` library from `qrcodejs`), and admin modal editors.
|
||||
- **TR-8**: Navigation placement: **All navigation surfaces** use the order `Messages → Channels → Members → Map`:
|
||||
- `spa.html` desktop sidebar and mobile menu: insert Channels `<li>` between Messages and Members
|
||||
- `app.js` dynamic nav: insert Channels `if (features.channels)` block between Messages and Members blocks
|
||||
- `home.js` hero card grid: insert Channels `renderNavCard()` between Messages and Members cards
|
||||
- Add CSS custom property `--color-channels` in `app.css` for hero card accent color
|
||||
- **TR-9**: `FEATURE_CHANNELS` feature flag in `WebSettings` (default `true`), registered in the `features` property. Unlike `feature_members`, it does not gate on `oidc_enabled`.
|
||||
- **TR-10**: The `channel_labels` config passed to the web frontend via `/config` endpoint stays in its current format (`{str(channel_idx): label}`). It is built from the `channels` DB table instead of parsing `COLLECTOR_CHANNEL_KEYS`, using a synchronous SQLAlchemy engine (SQLite allows concurrent reads). The existing `getChannelLabelsMap()` function in `components.js` continues to work unchanged. Channel visibility is fetched separately by the Channels page via `/api/v1/channels` — it does not go through the `/config` endpoint.
|
||||
- **TR-11**: Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from `CollectorSettings`, remove `collector_channel_keys_list` property, remove `_parse_decoder_key_entries()` from `web/app.py`.
|
||||
- **TR-12**: i18n keys for channel-related UI strings added to `en.json` and documented in `docs/i18n.md`.
|
||||
- **TR-13**: Web proxy access mapping in `_build_endpoint_access()` updated:
|
||||
- `"v1/channels": { "GET": _OPEN }` -- anyone can list
|
||||
- `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }` -- admin-only mutations
|
||||
- **TR-14**: `CHANNEL_REFRESH_INTERVAL_SECONDS` env var added to `CollectorSettings` (default `300`). Not in `WebSettings`.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Channel Model & Migration
|
||||
|
||||
- Create `src/meshcore_hub/common/models/channel.py`:
|
||||
```python
|
||||
class ChannelVisibility(str, Enum):
|
||||
PUBLIC = "public"
|
||||
MEMBER = "member"
|
||||
OPERATOR = "operator"
|
||||
ADMIN = "admin"
|
||||
|
||||
class Channel(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "channels"
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
key_hex: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
channel_hash: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(String(20), default="public")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
```
|
||||
- Export from `models/__init__.py`
|
||||
- Generate Alembic migration: `meshcore-hub db revision --autogenerate -m "add channels table"`
|
||||
- Unit tests for the model
|
||||
|
||||
### Phase 2: Decoder Reload Support
|
||||
|
||||
- Add `reload_keys(channel_keys: list[str])` to `LetsMeshPacketDecoder`
|
||||
- Normalize new key list, rebuild `MeshCoreKeyStore`, update `_channel_names_by_hash`
|
||||
- Preserve decode cache across reloads
|
||||
- **Thread safety**: Add a `threading.Lock` (`_state_lock`) to guard access to `_key_store` and `_channel_names_by_hash`. The MQTT message callback thread reads these during decode; the refresh thread writes during reload. Lock is only held during the atomic swap (not during key normalization/KeyStore construction).
|
||||
- Unit tests for reload behavior
|
||||
|
||||
### Phase 3: Collector DB Key Loading & Refresh
|
||||
|
||||
- On startup (`Subscriber.__init__` or `start()`), query `Channel` table for `enabled=true` rows via `self.db.session_scope()`
|
||||
- Merge DB channels with the hardcoded built-in keys (`Public`, `test` — both always available to the decoder). The `test` key is always loaded into the decoder but test messages are discarded unless a DB row exists (see FR-2).
|
||||
- The `_include_test_channel` flag moves from env var to a DB query: `self.db.async_session()->query(Channel).filter(name="test", enabled=True).first() is not None`. Evaluated once at startup and on refresh.
|
||||
- Add `_start_channel_refresh_scheduler()` to `Subscriber`, following the cleanup scheduler pattern (`subscriber.py:245-357`)
|
||||
- Add `channel_refresh_interval_seconds` field to `CollectorSettings` (env var `CHANNEL_REFRESH_INTERVAL_SECONDS`, default `300`)
|
||||
- Pass interval to `Subscriber.__init__` alongside other scheduler params (cleanup_enabled, cleanup_retention_days, etc.)
|
||||
- Remove `channel_keys` parameter from `Subscriber.__init__`, `create_subscriber()`, and `run_collector()`
|
||||
- Remove `COLLECTOR_CHANNEL_KEYS` from `CollectorSettings` and related parsing
|
||||
- Integration tests
|
||||
|
||||
### Phase 4: API Endpoints & Message Filtering
|
||||
|
||||
- Create `src/meshcore_hub/common/schemas/channels.py`: `ChannelCreate`, `ChannelRead`, `ChannelUpdate`, `ChannelList`
|
||||
- Create `src/meshcore_hub/api/routes/channels.py`:
|
||||
- `GET /channels` -- list channels, filtered by user role. When no OIDC roles are present (OIDC disabled or not logged in), returns only `public` channels
|
||||
- `POST /channels` -- create (admin only, uses `RequireAdmin` dependency on API side)
|
||||
- `PUT /channels/{id}` -- update (admin only)
|
||||
- `DELETE /channels/{id}` -- delete (admin only)
|
||||
- Add role-aware message filtering to `GET /messages`:
|
||||
- Resolve user's highest role from auth context (X-User-Roles header or API key)
|
||||
- When no roles available (OIDC disabled): no filtering (all channels visible)
|
||||
- When OIDC enabled: query visible channel hashes from `channels` table based on role hierarchy (role → visibility levels up to that role)
|
||||
- Channel hash to channel_idx conversion: `channel_idx = int(channel_hash, 16)` (both are 0-255)
|
||||
- Query filter: `(message_type != 'channel') OR (channel_idx IN (visible_idxs)) OR (channel_idx NOT IN (all_known_idxs))`
|
||||
- Unknown channel hashes (not in DB) are treated as `public` and pass through the third clause
|
||||
- Add role-aware channel filtering to `GET /dashboard/stats` and `/dashboard/message-activity`:
|
||||
- Resolve user's highest role from auth context
|
||||
- Filter channel counts and channel activity lists by visible channels using the same visibility logic as `/messages`
|
||||
- When OIDC disabled: show all channels
|
||||
- Update `_build_channel_labels()` in `web/app.py` to query DB using a synchronous SQLAlchemy engine against the shared SQLite database. This is safe because SQLite allows concurrent readers. The function is called once at startup; results are stored in `app.state.channel_labels`. The existing format (`{str(channel_idx): label}`) is preserved.
|
||||
- Add entries to `_build_endpoint_access()`:
|
||||
- `"v1/channels": { "GET": _OPEN }`
|
||||
- `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }`
|
||||
- Register router in `api/routes/__init__.py`
|
||||
- Tests for all endpoints and filtering
|
||||
|
||||
### Phase 5: CLI Commands
|
||||
|
||||
- Add `channel` subgroup to collector CLI in `cli.py`
|
||||
- `meshcore-hub collector channel list` -- name, masked key, hash, visibility, enabled
|
||||
- `meshcore-hub collector channel add --name NAME --key HEX --visibility public`
|
||||
- `meshcore-hub collector channel remove --name NAME`
|
||||
- `meshcore-hub collector channel enable/disable --name NAME`
|
||||
- Remove `channel_keys` and `include_test_channel` params from `_run_collector_service()`
|
||||
- Tests for each command
|
||||
|
||||
### Phase 6: Web Dashboard -- Channels Page
|
||||
|
||||
- Create `src/meshcore_hub/web/static/js/spa/pages/channels.js`:
|
||||
- Fetch `/api/v1/channels` (API returns only channels the user can see)
|
||||
- Render responsive card grid (DaisyUI `card` component)
|
||||
- Each card: channel name, channel hash badge, visibility badge (OIDC only), QR code, masked key
|
||||
- QR code: `meshcore://channel/add?name=<encoded>&key=<hex>` using `QRCode` library
|
||||
- **OIDC disabled**: read-only cards, no visibility badges, no add/edit/delete controls
|
||||
- **OIDC enabled, admin**: "Add Channel" button, edit/delete buttons per card
|
||||
- **OIDC enabled, non-admin**: read-only cards filtered by role
|
||||
- Add/edit modal (following tag editor pattern from `node-detail.js`): name, key (hex), visibility select, enabled toggle -- only rendered when `hasRole('admin')`
|
||||
- Delete confirmation modal (following `tagDeleteModal` pattern)
|
||||
- Use `getConfig().oidc_enabled` to conditionally show/hide admin controls and visibility badges
|
||||
- **Navigation ordering** -- Channels appears **after Messages and before Members** in all navigation surfaces:
|
||||
- `spa.html` desktop sidebar and mobile menu: insert Channels `<li>` between Messages and Members
|
||||
- `app.js` dynamic nav (`renderNavItems()`): insert Channels `if (features.channels !== false)` block between Messages and Members blocks
|
||||
- `home.js` hero card grid: insert Channels `renderNavCard()` between Messages and Members cards
|
||||
- Add CSS custom property `--color-channels` in `app.css` for hero card accent color
|
||||
- **Icon**: Use the existing `iconChannel` SVG function from `icons.js` (hash/# icon, already defined at `icons.js:77-79`). Import it in `channels.js`, `home.js`, `app.js`, and use it inline in `spa.html` nav links.
|
||||
- Register route in `app.js`: `router.addRoute('/channels', pageHandler(pages.channels))`
|
||||
- No OIDC dependency in route registration (unlike members which gates on `features.members`)
|
||||
- Add `FEATURE_CHANNELS` feature flag to `WebSettings` -- does NOT gate on `oidc_enabled`:
|
||||
```python
|
||||
feature_channels: bool = Field(default=True, description="Enable the /channels page")
|
||||
# In features property:
|
||||
"channels": self.feature_channels, # no oidc_enabled guard
|
||||
```
|
||||
- Add page title handling in `updatePageTitle()` in `app.js`
|
||||
- Add i18n keys to `en.json` (including `entities.channels`, `entities.channel`) and update `docs/i18n.md`
|
||||
- Tests in `tests/test_web/`
|
||||
|
||||
### Phase 7: Seeding, Config Cleanup & Docs
|
||||
|
||||
- Add `channels.yaml` support to seed importer in `cli.py`
|
||||
- Shorthand format: `name: HEX` — value is a hex string, treated as the channel key
|
||||
- Expanded format: `name: { key: HEX, enabled: true }` — value is a dict
|
||||
- The parser distinguishes by type: `str` → shorthand (treat value as `key_hex`), `dict` → expanded (read `key` and optional `enabled` fields)
|
||||
- No `visibility` field in seed format — always defaults to `public`
|
||||
- This is the primary configuration path when OIDC is disabled
|
||||
- Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from:
|
||||
- `CollectorSettings` in `config.py`
|
||||
- `collector_channel_keys_list` property
|
||||
- `_parse_decoder_key_entries()` in `web/app.py`
|
||||
- `_run_collector_service()` in `cli.py`
|
||||
- `Subscriber.__init__`, `create_subscriber()`, `run_collector()` signatures
|
||||
- `AGENTS.md` env var list
|
||||
- `.env.example` (lines 204 and 208)
|
||||
- Update `docs/seeding.md` with channels.yaml documentation (emphasize: visibility always `public`, admin-only channels require OIDC + API/CLI)
|
||||
- Update `docs/upgrading.md` with migration guide:
|
||||
- Run `meshcore-hub db upgrade`
|
||||
- Convert any `COLLECTOR_CHANNEL_KEYS` values to `channels.yaml` seed file or DB rows via CLI
|
||||
- Remove env var from `.env`
|
||||
- Note: existing seeded channels will be `public` visibility
|
||||
- Update `AGENTS.md` with new model, page, feature flag, and API routes
|
||||
- Update `SCHEMAS.md` if channel event schemas are affected
|
||||
|
||||
## Review
|
||||
|
||||
**Status**: Approved with Changes
|
||||
|
||||
**Reviewed**: 2026-05-19
|
||||
|
||||
### Resolutions
|
||||
|
||||
- **Nav ordering**: All navigation surfaces use `Messages → Channels → Members → Map` (not `Messages → Channels → Map → Members`). Updated TR-8 and Phase 6.
|
||||
- **`key_hex` column size**: Changed from `String(32)` to `String(64)` to support both AES-128 and AES-256 keys. Updated FR-1 and Phase 1 model.
|
||||
- **Frontend config format**: `channel_labels` stays as `{str(idx): label}` — unchanged format. Channels page fetches visibility from `/api/v1/channels` directly. Updated TR-10.
|
||||
- **`_build_channel_labels()` sync approach**: Uses a synchronous SQLAlchemy engine for a one-time startup query against the shared SQLite database (safe because SQLite allows concurrent readers). Updated Phase 4.
|
||||
- **Dashboard channel filtering**: Yes, dashboard channel activity is filtered by role visibility (consistent with `/messages`). Added FR-4b and updated Phase 4.
|
||||
- **Thread safety**: Specified `threading.Lock` (`_state_lock`) on decoder for atomic swap between MQTT callback and refresh threads. Updated Phase 2.
|
||||
- **`CHANNEL_REFRESH_INTERVAL_SECONDS`**: Added as `CollectorSettings` field (not `WebSettings`). Added TR-14 and updated Phase 3.
|
||||
- **Message filtering logic**: Changed from simple `IN (...)` to three-clause filter: direct messages always visible, known channels filtered by visibility, unknown channels treated as `public`. Updated TR-6 and Phase 4.
|
||||
- **Seed format**: Parser distinguishes shorthand (`str` → key_hex) vs expanded (`dict` → read `key` and optional `enabled`). No `visibility` field. Clarified in Phase 7.
|
||||
|
||||
### Remaining Action Items
|
||||
|
||||
- **QR code URL format**: The `meshcore://channel/add?name=...&key=...` scheme is proposed by analogy with the existing contact QR. Confirm with MeshCore app devs whether this scheme is supported or planned. (Baked into plan as-is; QR codes work if/when app supports them.)
|
||||
- **Test channel excluded by default**: `test` built-in key always loaded into decoder (for decryption), but normalizer discards test messages unless a `Channel` row with `name="test"` and `enabled=true` exists in DB. NOT created automatically — admin must explicitly add it. Replaces `COLLECTOR_INCLUDE_TEST_CHANNEL`. Updated FR-2 and Phase 3.
|
||||
- **Existing test updates**: Tests that reference `channel_labels["17"] == "Public"` etc. must continue to pass. Since format is unchanged, they should, but verify during Phase 4.
|
||||
|
||||
## References
|
||||
|
||||
- `src/meshcore_hub/collector/letsmesh_decoder.py` -- decoder with static key init, `BUILTIN_CHANNEL_KEYS`, `channel_labels_by_index()`
|
||||
- `src/meshcore_hub/collector/subscriber.py` -- subscriber with cleanup scheduler pattern (thread + async session) to follow
|
||||
- `src/meshcore_hub/common/models/node_tag.py` -- model pattern (UUIDMixin, TimestampMixin)
|
||||
- `src/meshcore_hub/common/config.py:141-193` -- current `COLLECTOR_CHANNEL_KEYS` config (to be removed)
|
||||
- `src/meshcore_hub/web/app.py:68-161` -- `_build_endpoint_access()` and `check_api_access()` for proxy auth guards
|
||||
- `src/meshcore_hub/web/app.py:164-185` -- `_build_channel_labels()` (to be updated to query DB)
|
||||
- `src/meshcore_hub/web/static/js/spa/pages/node-detail.js:361-374` -- QR code pattern using `QRCode` library and `meshcore://` scheme
|
||||
- `src/meshcore_hub/web/static/js/spa/pages/node-detail.js:25-71` -- modal dialog patterns for tag edit/delete
|
||||
- `src/meshcore_hub/api/auth.py` -- auth dependencies (`RequireRead`, `RequireAdmin`, `require_operator_or_admin`)
|
||||
- `src/meshcore_hub/web/static/js/spa/components.js` -- `hasRole()`, `getChannelLabelsMap()`, `resolveChannelLabel()`
|
||||
@@ -0,0 +1,256 @@
|
||||
# Tasks: Channel Model — Database-Backed Decrypt Keys with Permission-Based Visibility
|
||||
|
||||
> Generated from `plan.md` on 2026-05-19
|
||||
|
||||
## 1. Database Schema & Migration
|
||||
|
||||
- [ ] 1.1 Create `Channel` SQLAlchemy model
|
||||
- [ ] 1.1.1 Define `ChannelVisibility` enum (`public`, `member`, `operator`, `admin`)
|
||||
- [ ] 1.1.2 Create `Channel` class in `src/meshcore_hub/common/models/channel.py` (fields: `id`, `name`, `key_hex`, `channel_hash`, `visibility`, `enabled`, `created_at`, `updated_at`)
|
||||
- [ ] 1.1.3 `key_hex` must be `String(64)` (supports AES-128 and AES-256 keys)
|
||||
- [ ] 1.1.4 `channel_hash` must be `String(2)` (first byte of SHA-256 of `key_hex`, uppercase hex)
|
||||
- [ ] 1.1.5 `name` must be `String(100)`, unique, non-nullable
|
||||
- [ ] 1.1.6 Export `Channel`, `ChannelVisibility` from `models/__init__.py`
|
||||
|
||||
- [ ] 1.2 Generate Alembic migration
|
||||
- [ ] 1.2.1 Run `meshcore-hub db revision --autogenerate -m "add channels table"`
|
||||
- [ ] 1.2.2 Review generated migration for correctness (unique constraints on `name` and `key_hex`)
|
||||
- [ ] 1.2.3 Test migration: `meshcore-hub db upgrade` and verify table creation
|
||||
|
||||
- [ ] 1.3 Create Pydantic schemas
|
||||
- [ ] 1.3.1 Create `src/meshcore_hub/common/schemas/channels.py` with `ChannelCreate`, `ChannelRead`, `ChannelUpdate`, `ChannelList`
|
||||
- [ ] 1.3.2 `ChannelCreate`: validate `name`, `key_hex` (uppercase hex, 32 or 64 chars), optional `visibility` (default `public`), optional `enabled` (default `true`)
|
||||
- [ ] 1.3.3 `ChannelRead`: include `id`, `name`, `channel_hash`, `visibility`, `enabled`, `created_at`, `updated_at`, but NOT `key_hex` (mask first/last 4 chars for read)
|
||||
- [ ] 1.3.4 `ChannelUpdate`: all fields optional except `name` immutable
|
||||
- [ ] 1.3.5 Add `masked_key` computed property on `ChannelRead` (e.g. `"ABCD...EF01"`)
|
||||
|
||||
- [ ] 1.4 Write unit tests for the model and schemas
|
||||
- [ ] 1.4.1 Test `Channel` model instantiation and defaults
|
||||
- [ ] 1.4.2 Test unique constraint enforcement on `name` and `key_hex`
|
||||
- [ ] 1.4.3 Test `ChannelCreate` schema validation (valid keys, invalid keys, name length)
|
||||
- [ ] 1.4.4 Test `ChannelRead.masked_key` formatting
|
||||
|
||||
## 2. Decoder Reload Support
|
||||
|
||||
- [ ] 2.1 Add `reload_keys()` method to `LetsMeshPacketDecoder`
|
||||
- [ ] 2.1.1 Add `threading.Lock` (`_state_lock`) to the decoder class
|
||||
- [ ] 2.1.2 Implement `reload_keys(channel_keys: list[str])` — normalize new keys, rebuild `MeshCoreKeyStore`, update `_channel_names_by_hash`
|
||||
- [ ] 2.1.3 Preserve the decode cache (`_decode_cache`) across reloads
|
||||
- [ ] 2.1.4 Use `_state_lock` for atomic swap of `_key_store` and `_channel_names_by_hash` (hold lock only during swap, not during key normalization/KeyStore construction)
|
||||
- [ ] 2.1.5 Update `channel_labels_by_index()` and `resolve_channel_name()` to use `_state_lock` when reading shared state
|
||||
|
||||
- [ ] 2.2 Write unit tests for reload behavior
|
||||
- [ ] 2.2.1 Test that reload with new keys enables decryption of messages on the new channel
|
||||
- [ ] 2.2.2 Test that decode cache persists across reloads
|
||||
- [ ] 2.2.3 Test thread safety: concurrent decode reads while reload is in progress (mock/threading test)
|
||||
|
||||
## 3. Collector DB Key Loading & Refresh
|
||||
|
||||
- [ ] 3.1 Update `Subscriber` to load keys from database on startup
|
||||
- [ ] 3.1.1 Query all `enabled=true` channels from DB via `self.db.session_scope()`
|
||||
- [ ] 3.1.2 Merge DB channels with hardcoded built-in keys (`Public`, `test` — always loaded into decoder)
|
||||
- [ ] 3.1.3 Move `_include_test_channel` from env var to DB query: check if `Channel(name="test", enabled=True)` row exists
|
||||
- [ ] 3.1.4 Pass merged key list to decoder (replacing old `channel_keys` constructor param)
|
||||
|
||||
- [ ] 3.2 Update `letsmesh_normalizer.py` test channel filter
|
||||
- [ ] 3.2.1 Replace env-var-based `_include_test_channel` check with DB lookup in the normalizer
|
||||
- [ ] 3.2.2 Test messages are always decrypted (key in decoder), but discarded by normalizer unless DB row exists
|
||||
|
||||
- [ ] 3.3 Add `_start_channel_refresh_scheduler()` to `Subscriber`
|
||||
- [ ] 3.3.1 Follow the cleanup scheduler pattern (`subscriber.py:245-357`): daemon thread + async session
|
||||
- [ ] 3.3.2 Query enabled channels from DB on each cycle
|
||||
- [ ] 3.3.3 Call `decoder.reload_keys()` with updated key list
|
||||
- [ ] 3.3.4 Handle graceful shutdown (stop event)
|
||||
|
||||
- [ ] 3.4 Add `CHANNEL_REFRESH_INTERVAL_SECONDS` to `CollectorSettings`
|
||||
- [ ] 3.4.1 Add field to `CollectorSettings` in `config.py` (default `300`, env var `CHANNEL_REFRESH_INTERVAL_SECONDS`)
|
||||
- [ ] 3.4.2 Pass interval to `Subscriber.__init__` alongside other scheduler params
|
||||
|
||||
- [ ] 3.5 Remove `channel_keys` from collector plumbing
|
||||
- [ ] 3.5.1 Remove `channel_keys` param from `Subscriber.__init__`, `create_subscriber()`, `run_collector()`
|
||||
- [ ] 3.5.2 Remove `COLLECTOR_CHANNEL_KEYS` from `CollectorSettings`
|
||||
- [ ] 3.5.3 Remove `collector_channel_keys_list` property from `CollectorSettings`
|
||||
- [ ] 3.5.4 Remove `COLLECTOR_INCLUDE_TEST_CHANNEL` from `CollectorSettings`
|
||||
|
||||
- [ ] 3.6 Write integration tests
|
||||
- [ ] 3.6.1 Test that collector loads keys from DB on startup
|
||||
- [ ] 3.6.2 Test that collector refreshes keys on schedule
|
||||
- [ ] 3.6.3 Test that test channel messages are discarded when no DB row exists
|
||||
- [ ] 3.6.4 Test that test channel messages are stored when DB row with `enabled=true` exists
|
||||
|
||||
## 4. API Endpoints & Message Filtering
|
||||
|
||||
- [ ] 4.1 Create API routes for channels
|
||||
- [ ] 4.1.1 Create `src/meshcore_hub/api/routes/channels.py`
|
||||
- [ ] 4.1.2 Implement `GET /channels` — list channels filtered by user role visibility; when no OIDC roles, return only `public` channels
|
||||
- [ ] 4.1.3 Implement `POST /channels` — create channel (admin only)
|
||||
- [ ] 4.1.4 Implement `PUT /channels/{id}` — update channel (admin only, `name` immutable)
|
||||
- [ ] 4.1.5 Implement `DELETE /channels/{id}` — delete channel (admin only)
|
||||
- [ ] 4.1.6 Register router in `api/routes/__init__.py`
|
||||
|
||||
- [ ] 4.2 Add role-aware message filtering to `GET /messages`
|
||||
- [ ] 4.2.1 Resolve user's highest role from auth context (X-User-Roles header or API key)
|
||||
- [ ] 4.2.2 When no roles available (OIDC disabled): no filtering (all channels treated as public)
|
||||
- [ ] 4.2.3 When OIDC enabled: query visible channel hashes from `channels` table based on role hierarchy
|
||||
- [ ] 4.2.4 Build visibility set: compute `channel_idx = int(channel_hash, 16)` for each visible channel
|
||||
- [ ] 4.2.5 Build full known set: all `channel_idx` values from all channels in DB (for "unknown = public" clause)
|
||||
- [ ] 4.2.6 Apply three-clause filter: `(message_type != 'channel') OR (channel_idx IN (visible_idxs)) OR (channel_idx NOT IN (all_known_idxs))`
|
||||
|
||||
- [ ] 4.3 Add role-aware channel filtering to dashboard endpoints
|
||||
- [ ] 4.3.1 Resolve user's highest role from auth context (same logic as `/messages`)
|
||||
- [ ] 4.3.2 Filter `GET /dashboard/stats` channel message counts by visible channels
|
||||
- [ ] 4.3.3 Filter `GET /dashboard/message-activity` channel activity lists by visible channels
|
||||
- [ ] 4.3.4 When OIDC disabled: show all channels (no filtering)
|
||||
|
||||
- [ ] 4.4 Update `_build_channel_labels()` in `web/app.py`
|
||||
- [ ] 4.4.1 Replace env-var parsing with database query using a synchronous SQLAlchemy engine
|
||||
- [ ] 4.4.2 Maintain existing format: `{str(channel_idx): label}`
|
||||
- [ ] 4.4.3 Include both built-in `Public` and all `enabled=true` channels from DB
|
||||
- [ ] 4.4.4 Remove `_parse_decoder_key_entries()` helper function
|
||||
|
||||
- [ ] 4.5 Update web proxy `_build_endpoint_access()`
|
||||
- [ ] 4.5.1 Add `"v1/channels": { "GET": _OPEN }` — anyone can list (filtering is server-side)
|
||||
- [ ] 4.5.2 Add `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }` — admin-only mutations
|
||||
- [ ] 4.5.3 Verify longest-prefix matching: `v1/channels/` takes precedence for POST/PUT/DELETE over `v1/channels`
|
||||
|
||||
- [ ] 4.6 Write tests for API endpoints and filtering
|
||||
- [ ] 4.6.1 Test `GET /channels` returns only public channels when no auth
|
||||
- [ ] 4.6.2 Test `GET /channels` returns appropriate channels per role
|
||||
- [ ] 4.6.3 Test `POST/PUT/DELETE /channels` restricted to admin only
|
||||
- [ ] 4.6.4 Test message filtering: only visible channel messages returned per role
|
||||
- [ ] 4.6.5 Test message filtering: unknown channels pass through (treated as public)
|
||||
- [ ] 4.6.6 Test message filtering: direct messages always visible
|
||||
- [ ] 4.6.7 Test dashboard channel counts filtered by role
|
||||
- [ ] 4.6.8 Verify existing tests referencing `channel_labels["17"] == "Public"` still pass
|
||||
|
||||
## 5. CLI Commands
|
||||
|
||||
- [ ] 5.1 Add `channel` subgroup to collector CLI
|
||||
- [ ] 5.1.1 Create `channel` Click group in `src/meshcore_hub/collector/cli.py`
|
||||
- [ ] 5.1.2 Implement `meshcore-hub collector channel list` — table output: name, masked key, hash, visibility, enabled
|
||||
- [ ] 5.1.3 Implement `meshcore-hub collector channel add --name NAME --key HEX --visibility public` — create channel row
|
||||
- [ ] 5.1.4 Implement `meshcore-hub collector channel remove --name NAME` — delete channel by name
|
||||
- [ ] 5.1.5 Implement `meshcore-hub collector channel enable --name NAME` — set `enabled=true`
|
||||
- [ ] 5.1.6 Implement `meshcore-hub collector channel disable --name NAME` — set `enabled=false`
|
||||
- [ ] 5.1.7 Remove `channel_keys` and `include_test_channel` params from `_run_collector_service()`
|
||||
|
||||
- [ ] 5.2 Write tests for CLI commands
|
||||
- [ ] 5.2.1 Test `channel list` output format
|
||||
- [ ] 5.2.2 Test `channel add` creates row with correct fields and `channel_hash` computed
|
||||
- [ ] 5.2.3 Test `channel add` rejects invalid keys (non-hex, wrong length)
|
||||
- [ ] 5.2.4 Test `channel add` rejects duplicate names
|
||||
- [ ] 5.2.5 Test `channel remove` deletes row
|
||||
- [ ] 5.2.6 Test `channel enable/disable` toggles `enabled` flag
|
||||
- [ ] 5.2.7 Test that old `--channel-keys` option is removed (CLI help output)
|
||||
|
||||
## 6. Web Dashboard — Channels Page
|
||||
|
||||
- [ ] 6.1 Add `FEATURE_CHANNELS` feature flag
|
||||
- [ ] 6.1.1 Add `feature_channels: bool` to `WebSettings` (default `true`, env var `FEATURE_CHANNELS`)
|
||||
- [ ] 6.1.2 Add `"channels": self.feature_channels` to `features` property (NO `oidc_enabled` guard)
|
||||
- [ ] 6.1.3 Expose in `/config` endpoint response
|
||||
|
||||
- [ ] 6.2 Create Channels page module
|
||||
- [ ] 6.2.1 Create `src/meshcore_hub/web/static/js/spa/pages/channels.js`
|
||||
- [ ] 6.2.2 Implement `render(container, params, router)` — fetch `/api/v1/channels`, render card grid
|
||||
- [ ] 6.2.3 Return a cleanup function if any resources are created
|
||||
- [ ] 6.2.4 Responsive card layout: DaisyUI `card` component, grid adapts to screen width
|
||||
|
||||
- [ ] 6.3 Channel card UI
|
||||
- [ ] 6.3.1 Card content: channel name, channel hash badge, visibility badge (OIDC only), QR code, masked key
|
||||
- [ ] 6.3.2 QR code generation: `new QRCode(canvas, { text: "meshcore://channel/add?name=<encoded>&key=<hex>" })` using existing `qrcodejs` library
|
||||
- [ ] 6.3.3 Masked key display: `{first4}...{last4}` with reveal toggle for admin users
|
||||
- [ ] 6.3.4 Visibility badge: colored badge (e.g., green=public, yellow=member, orange=operator, red=admin)
|
||||
- [ ] 6.3.5 Use `getConfig().oidc_enabled` to conditionally show admin controls and visibility badges
|
||||
|
||||
- [ ] 6.4 Admin inline channel management modals
|
||||
- [ ] 6.4.1 Add Channel modal (follow tag editor pattern from `node-detail.js:25-71`)
|
||||
- [ ] 6.4.2 Fields: name (text), key_hex (text, validated as hex), visibility (select: public/member/operator/admin), enabled (toggle)
|
||||
- [ ] 6.4.3 Edit Channel modal: pre-populate fields, name read-only
|
||||
- [ ] 6.4.4 Delete confirmation modal (follow `tagDeleteModal` pattern)
|
||||
- [ ] 6.4.5 All modal actions gated behind `hasRole('admin')` — only rendered when admin
|
||||
|
||||
- [ ] 6.5 Conditional rendering modes
|
||||
- [ ] 6.5.1 OIDC disabled: read-only cards, all channels public, no visibility badges, no add/edit/delete UI
|
||||
- [ ] 6.5.2 OIDC enabled, not logged in: read-only cards, only public channels shown
|
||||
- [ ] 6.5.3 OIDC enabled, non-admin: read-only cards, channels filtered by role, visibility badges shown
|
||||
- [ ] 6.5.4 OIDC enabled, admin: full management — "Add Channel" button, edit/delete per card, visibility select in forms
|
||||
|
||||
- [ ] 6.6 Update channel filter dropdowns across the SPA
|
||||
- [ ] 6.6.1 Dashboard channel filter: only show channels visible to user's role
|
||||
- [ ] 6.6.2 Messages page channel filter: only show channels visible to user's role
|
||||
- [ ] 6.6.3 When OIDC disabled, all channels appear in both dropdowns
|
||||
|
||||
- [ ] 6.7 Navigation placement — Channels between Messages and Members on all surfaces
|
||||
- [ ] 6.7.1 `spa.html` desktop sidebar: insert `<li>` for `/channels` with `iconChannel()` between Messages and Members
|
||||
- [ ] 6.7.2 `spa.html` mobile menu: insert `<li>` for `/channels` between Messages and Members
|
||||
- [ ] 6.7.3 `app.js` dynamic nav: insert `if (features.channels !== false)` block between Messages and Members blocks
|
||||
- [ ] 6.7.4 `app.js` route registration: `router.addRoute('/channels', pageHandler(pages.channels))` (no OIDC gate)
|
||||
- [ ] 6.7.5 `app.js` `updatePageTitle()`: add 'channels' case
|
||||
- [ ] 6.7.6 `home.js` hero card grid: insert `renderNavCard()` for Channels between Messages and Members
|
||||
- [ ] 6.7.7 Add `--color-channels` CSS custom property in `app.css` for hero card accent color
|
||||
- [ ] 6.7.8 Import `iconChannel` from `icons.js` in `channels.js`, `home.js`, `app.js`; use inline in `spa.html`
|
||||
|
||||
- [ ] 6.8 i18n
|
||||
- [ ] 6.8.1 Add channel-related keys to `src/meshcore_hub/web/static/locales/en.json`:
|
||||
- `entities.channel`, `entities.channels`
|
||||
- `channels.title`, `channels.add_channel`, `channels.edit_channel`, `channels.delete_channel`
|
||||
- `channels.name_label`, `channels.key_label`, `channels.visibility_label`, `channels.enabled_label`
|
||||
- `channels.channel_hash_label`, `channels.qr_code_label`
|
||||
- `channels.visibility_public`, `channels.visibility_member`, `channels.visibility_operator`, `channels.visibility_admin`
|
||||
- `common.no_entity_found` for channels (composed pattern)
|
||||
- [ ] 6.8.2 Add tests for new i18n keys in `tests/test_common/test_i18n.py`
|
||||
- [ ] 6.8.3 Update `docs/i18n.md` with new keys and usage context
|
||||
|
||||
## 7. Seeding, Config Cleanup & Docs
|
||||
|
||||
- [ ] 7.1 Add `channels.yaml` seed support
|
||||
- [ ] 7.1.1 Add channels seeding to `seed` command in `src/meshcore_hub/collector/cli.py`
|
||||
- [ ] 7.1.2 Read `${SEED_HOME}/channels.yaml`
|
||||
- [ ] 7.1.3 Parse shorthand format: `name: HEX` — value is `str`, treated as `key_hex`
|
||||
- [ ] 7.1.4 Parse expanded format: `name: { key: HEX, enabled: true }` — value is `dict`
|
||||
- [ ] 7.1.5 Parser distinguishes by type: `isinstance(value, str)` vs `isinstance(value, dict)`
|
||||
- [ ] 7.1.6 No `visibility` field — always defaults to `public`
|
||||
- [ ] 7.1.7 Upsert logic: update existing channel by `name`, insert new ones; never delete
|
||||
|
||||
- [ ] 7.2 Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from all files
|
||||
- [ ] 7.2.1 `src/meshcore_hub/common/config.py` — `CollectorSettings`
|
||||
- [ ] 7.2.2 `src/meshcore_hub/web/app.py` — `_parse_decoder_key_entries()`, `_build_channel_labels()`
|
||||
- [ ] 7.2.3 `src/meshcore_hub/collector/cli.py` — `_run_collector_service()`
|
||||
- [ ] 7.2.4 `src/meshcore_hub/collector/subscriber.py` — `Subscriber.__init__`, `create_subscriber()`, `run_collector()`
|
||||
- [ ] 7.2.5 `.env.example` — lines 204, 208
|
||||
- [ ] 7.2.6 `AGENTS.md` — env var list
|
||||
|
||||
- [ ] 7.3 Update documentation
|
||||
- [ ] 7.3.1 Update `docs/seeding.md` with `channels.yaml` format, examples, and note that visibility is always `public`
|
||||
- [ ] 7.3.2 Update `docs/upgrading.md` with migration guide:
|
||||
- Run `meshcore-hub db upgrade`
|
||||
- Convert `COLLECTOR_CHANNEL_KEYS` values to `channels.yaml` or DB rows via CLI
|
||||
- Remove env var from `.env`
|
||||
- Note all seeded channels are `public`
|
||||
- [ ] 7.3.3 Update `AGENTS.md`: add `Channel` model to model list, `FEATURE_CHANNELS` to feature flags, `/channels` to API routes
|
||||
- [ ] 7.3.4 Update `SCHEMAS.md` if channel event schemas are affected
|
||||
- [ ] 7.3.5 Create example `channels.yaml` in `example/seed/channels.yaml`
|
||||
|
||||
## 8. Verification
|
||||
|
||||
- [ ] 8.1 Code quality
|
||||
- [ ] 8.1.1 Run `pre-commit run --all-files` and fix all issues
|
||||
- [ ] 8.1.2 Ensure no `except ValueError, TypeError:` patterns (use parenthesized tuples)
|
||||
|
||||
- [ ] 8.2 Component tests
|
||||
- [ ] 8.2.1 Run `pytest tests/test_collector/` for collector-side changes
|
||||
- [ ] 8.2.2 Run `pytest tests/test_api/` for API endpoints and message filtering
|
||||
- [ ] 8.2.3 Run `pytest tests/test_web/` for web dashboard changes
|
||||
- [ ] 8.2.4 Run `pytest tests/test_common/` for model and schema changes
|
||||
- [ ] 8.2.5 Run `pytest tests/test_common/test_i18n.py` for i18n keys
|
||||
- [ ] 8.2.6 Run full `pytest` to verify no regressions
|
||||
|
||||
- [ ] 8.3 Manual verification
|
||||
- [ ] 8.3.1 Start collector with empty channels table — verify only `Public` channel messages decrypted and stored
|
||||
- [ ] 8.3.2 Add a channel via CLI — verify collector picks it up at next refresh without restart
|
||||
- [ ] 8.3.3 Seed channels from `channels.yaml` — verify rows created with `visibility=public`
|
||||
- [ ] 8.3.4 Verify dashboard loads without errors and no features are lost
|
||||
- [ ] 8.3.5 Verify Channels page renders correctly in all modes (OIDC disabled, logged out, admin)
|
||||
- [ ] 8.3.6 Verify message filtering by role (different roles see different channel messages)
|
||||
- [ ] 8.3.7 Verify QR code renders on channel cards
|
||||
+48
-1
@@ -13,12 +13,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile seed up
|
||||
This imports data from the following files (if they exist):
|
||||
|
||||
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
|
||||
- `{SEED_HOME}/channels.yaml` - Channel decryption keys
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
seed/ # SEED_HOME (seed data files)
|
||||
└── node_tags.yaml # Node tags for import
|
||||
├── node_tags.yaml # Node tags for import
|
||||
└── channels.yaml # Channel keys for import
|
||||
|
||||
data/ # DATA_HOME (runtime data)
|
||||
└── collector/
|
||||
@@ -59,3 +61,48 @@ Tag values can be:
|
||||
```
|
||||
|
||||
Supported types: `string`, `number`, `boolean`
|
||||
|
||||
## Channels
|
||||
|
||||
Channel keys are used to decrypt encrypted mesh messages. They are stored in the database and loaded by the collector at startup, with periodic refresh.
|
||||
|
||||
### Channels YAML Format
|
||||
|
||||
Channels support two formats:
|
||||
|
||||
**Shorthand** (name → hex key string):
|
||||
|
||||
```yaml
|
||||
MyChannel: AABBCCDD11223344AABBCCDD11223344
|
||||
```
|
||||
|
||||
**Expanded** (name → dict with options):
|
||||
|
||||
```yaml
|
||||
MyChannel:
|
||||
key: AABBCCDD11223344AABBCCDD11223344
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Keys must be uppercase hex, 32 characters (AES-128) or 64 characters (AES-256)
|
||||
- Seeded channels always have `visibility: public` — to set member/operator/admin visibility, use the CLI or API
|
||||
- The `Public` and `test` built-in keys are always loaded into the decoder regardless of database contents
|
||||
- Test channel messages are only stored when a `test` channel row exists in the database with `enabled: true`
|
||||
|
||||
### Managing Channels via CLI
|
||||
|
||||
```bash
|
||||
# List all channels
|
||||
meshcore-hub collector channel list
|
||||
|
||||
# Add a channel
|
||||
meshcore-hub collector channel add --name MyChannel --key AABBCCDD11223344AABBCCDD11223344
|
||||
|
||||
# Enable/disable a channel
|
||||
meshcore-hub collector channel enable --name MyChannel
|
||||
meshcore-hub collector channel disable --name MyChannel
|
||||
|
||||
# Remove a channel
|
||||
meshcore-hub collector channel remove --name MyChannel
|
||||
```
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
|
||||
|
||||
## v0.13.0
|
||||
|
||||
### Database-Backed Channel Keys
|
||||
|
||||
Channel decryption keys are now managed via the `channels` database table instead of the `COLLECTOR_CHANNEL_KEYS` environment variable. This enables runtime key management, permission-based visibility, and a Channels dashboard page.
|
||||
|
||||
**New database table: `channels`**
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | `VARCHAR(36), PK` | UUID primary key |
|
||||
| `name` | `VARCHAR(100), UNIQUE` | Channel display name |
|
||||
| `key_hex` | `VARCHAR(64), UNIQUE` | Uppercase hex key (32 or 64 chars) |
|
||||
| `channel_hash` | `VARCHAR(2)` | First byte of SHA-256 of key |
|
||||
| `visibility` | `VARCHAR(20)` | `public`, `member`, `operator`, or `admin` |
|
||||
| `enabled` | `BOOLEAN` | Whether the channel is active |
|
||||
| `created_at`, `updated_at` | `DATETIME` | Timestamps |
|
||||
|
||||
**Removed environment variables:**
|
||||
- `COLLECTOR_CHANNEL_KEYS` — replaced by database channels table
|
||||
- `COLLECTOR_INCLUDE_TEST_CHANNEL` — replaced by presence of a `test` channel row in the database
|
||||
|
||||
**New environment variables:**
|
||||
- `CHANNEL_REFRESH_INTERVAL_SECONDS` — seconds between key refresh (default: `300`)
|
||||
- `FEATURE_CHANNELS` — enable/disable the /channels page (default: `true`)
|
||||
|
||||
**Migration steps:**
|
||||
|
||||
1. Run `meshcore-hub db upgrade` to create the `channels` table
|
||||
2. Convert any `COLLECTOR_CHANNEL_KEYS` values to either:
|
||||
- A `channels.yaml` seed file in `SEED_HOME` (see `docs/seeding.md`)
|
||||
- Database rows via CLI: `meshcore-hub collector channel add --name X --key HEX`
|
||||
3. Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from your `.env`
|
||||
4. If you previously relied on test channel messages, add a test channel: `meshcore-hub collector channel add --name test --key 9CD8FCF22A47333B591D96A2B848B73F`
|
||||
|
||||
**Test channel behavior change:** Test channel messages (channel_idx 217) are now discarded by default unless a `test` channel row exists in the database with `enabled=true`. Previously this was controlled by `COLLECTOR_INCLUDE_TEST_CHANNEL`.
|
||||
|
||||
## v0.12.0
|
||||
|
||||
### Advertisement Route Type & Deduplication Improvements
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Channel seed data for MeshCore Hub
|
||||
#
|
||||
# Format options:
|
||||
# Shorthand: name: HEX_KEY
|
||||
# Expanded: name: { key: HEX_KEY, enabled: true }
|
||||
#
|
||||
# Visibility is always 'public' for seeded channels.
|
||||
# To set member/operator/admin visibility, use the CLI or API.
|
||||
#
|
||||
# Example:
|
||||
# MyChannel: AABBCCDD11223344AABBCCDD11223344
|
||||
# PrivateChannel:
|
||||
# key: 11223344AABBCCDD11223344AABBCCDD11223344AABBCCDD11223344AABBCCDD
|
||||
# enabled: true
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Shared channel visibility helpers for API routes.
|
||||
|
||||
Resolves user roles from proxy-injected headers and determines which
|
||||
channel indices are visible based on channel visibility levels.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
VISIBILITY_LEVELS = {"public": 0, "member": 1, "operator": 2, "admin": 3}
|
||||
|
||||
|
||||
def resolve_user_role(request: Request) -> str | None:
|
||||
"""Resolve the user's highest role from X-User-Roles header."""
|
||||
roles_header = request.headers.get("x-user-roles", "")
|
||||
if not roles_header:
|
||||
return None
|
||||
roles = {r.strip() for r in roles_header.split(",") if r.strip()}
|
||||
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
|
||||
operator_role = getattr(request.app.state, "oidc_role_operator", "operator")
|
||||
member_role = getattr(request.app.state, "oidc_role_member", "member")
|
||||
if admin_role in roles:
|
||||
return "admin"
|
||||
if operator_role in roles:
|
||||
return "operator"
|
||||
if member_role in roles:
|
||||
return "member"
|
||||
return None
|
||||
|
||||
|
||||
def get_max_visibility_level(role: str | None) -> int:
|
||||
"""Get the maximum visibility level for a given role.
|
||||
|
||||
Returns 0 for anonymous users (public only).
|
||||
"""
|
||||
if role is None:
|
||||
return 0
|
||||
return VISIBILITY_LEVELS.get(role, 0)
|
||||
|
||||
|
||||
def get_visible_channel_indices(
|
||||
session: DbSession,
|
||||
max_level: int,
|
||||
) -> set[int]:
|
||||
"""Get set of visible channel_idx values based on visibility level.
|
||||
|
||||
Only returns indices for channels whose visibility level is at most
|
||||
max_level (lower number = more permissive). The built-in Public
|
||||
channel (idx 17) is always included.
|
||||
"""
|
||||
channels = session.execute(select(Channel)).scalars().all()
|
||||
visible: set[int] = set()
|
||||
for ch in channels:
|
||||
level = VISIBILITY_LEVELS.get(ch.visibility, 0)
|
||||
if level <= max_level:
|
||||
idx = int(ch.channel_hash, 16)
|
||||
visible.add(idx)
|
||||
visible.add(17) # Built-in Public channel is always visible
|
||||
return visible
|
||||
|
||||
|
||||
def get_all_known_channel_indices(session: DbSession) -> set[int]:
|
||||
"""Get set of all known channel_idx values from DB."""
|
||||
channels = session.execute(select(Channel)).scalars().all()
|
||||
return {int(ch.channel_hash, 16) for ch in channels}
|
||||
@@ -11,6 +11,7 @@ from meshcore_hub.api.routes.telemetry import router as telemetry_router
|
||||
from meshcore_hub.api.routes.dashboard import router as dashboard_router
|
||||
from meshcore_hub.api.routes.user_profiles import router as user_profiles_router
|
||||
from meshcore_hub.api.routes.adoptions import router as adoptions_router
|
||||
from meshcore_hub.api.routes.channels import router as channels_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -28,3 +29,4 @@ api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetr
|
||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
|
||||
api_router.include_router(user_profiles_router, prefix="/user", tags=["User"])
|
||||
api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"])
|
||||
api_router.include_router(channels_router, prefix="/channels", tags=["Channels"])
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Channel API routes."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||
from meshcore_hub.api.channel_visibility import (
|
||||
VISIBILITY_LEVELS,
|
||||
get_max_visibility_level,
|
||||
resolve_user_role,
|
||||
)
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
from meshcore_hub.common.schemas.channels import (
|
||||
ChannelCreate,
|
||||
ChannelList,
|
||||
ChannelRead,
|
||||
ChannelUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead:
|
||||
"""Convert a Channel model to ChannelRead schema."""
|
||||
|
||||
return ChannelRead(
|
||||
id=channel.id,
|
||||
name=channel.name,
|
||||
channel_hash=channel.channel_hash,
|
||||
visibility=channel.visibility,
|
||||
enabled=channel.enabled,
|
||||
masked_key=channel.masked_key,
|
||||
key_hex=channel.key_hex if include_key else None,
|
||||
created_at=channel.created_at,
|
||||
updated_at=channel.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ChannelList)
|
||||
async def list_channels(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
) -> ChannelList:
|
||||
"""List channels, filtered by user role visibility.
|
||||
|
||||
When no OIDC roles are present (OIDC disabled or not logged in),
|
||||
returns only public channels.
|
||||
"""
|
||||
role = resolve_user_role(request)
|
||||
|
||||
query = select(Channel).order_by(Channel.name)
|
||||
channels = session.execute(query).scalars().all()
|
||||
|
||||
max_level = get_max_visibility_level(role)
|
||||
filtered = []
|
||||
for ch in channels:
|
||||
level = VISIBILITY_LEVELS.get(ch.visibility, 0)
|
||||
if level <= max_level:
|
||||
filtered.append(_channel_to_read(ch, include_key=True))
|
||||
|
||||
return ChannelList(items=filtered, total=len(filtered))
|
||||
|
||||
|
||||
@router.post("", response_model=ChannelRead, status_code=201)
|
||||
async def create_channel(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
body: ChannelCreate,
|
||||
) -> ChannelRead:
|
||||
"""Create a new channel (admin only)."""
|
||||
existing = session.execute(
|
||||
select(Channel).where(Channel.name == body.name)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"Channel '{body.name}' already exists"
|
||||
)
|
||||
|
||||
existing_key = session.execute(
|
||||
select(Channel).where(Channel.key_hex == body.key_hex)
|
||||
).scalar_one_or_none()
|
||||
if existing_key:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Key already in use by another channel"
|
||||
)
|
||||
|
||||
channel_hash = Channel.compute_channel_hash(body.key_hex)
|
||||
|
||||
channel = Channel(
|
||||
name=body.name,
|
||||
key_hex=body.key_hex,
|
||||
channel_hash=channel_hash,
|
||||
visibility=body.visibility,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
session.add(channel)
|
||||
session.commit()
|
||||
session.refresh(channel)
|
||||
|
||||
return _channel_to_read(channel, include_key=True)
|
||||
|
||||
|
||||
@router.put("/{channel_id}", response_model=ChannelRead)
|
||||
async def update_channel(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
channel_id: str,
|
||||
body: ChannelUpdate,
|
||||
) -> ChannelRead:
|
||||
"""Update a channel (admin only, name is immutable)."""
|
||||
channel = session.execute(
|
||||
select(Channel).where(Channel.id == channel_id)
|
||||
).scalar_one_or_none()
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
if body.key_hex is not None:
|
||||
existing_key = session.execute(
|
||||
select(Channel).where(
|
||||
Channel.key_hex == body.key_hex, Channel.id != channel_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_key:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Key already in use by another channel"
|
||||
)
|
||||
channel.key_hex = body.key_hex
|
||||
channel.channel_hash = Channel.compute_channel_hash(body.key_hex)
|
||||
|
||||
if body.visibility is not None:
|
||||
channel.visibility = body.visibility
|
||||
|
||||
if body.enabled is not None:
|
||||
channel.enabled = body.enabled
|
||||
|
||||
session.commit()
|
||||
session.refresh(channel)
|
||||
|
||||
return _channel_to_read(channel, include_key=True)
|
||||
|
||||
|
||||
@router.delete("/{channel_id}", status_code=204)
|
||||
async def delete_channel(
|
||||
__: RequireAdmin,
|
||||
session: DbSession,
|
||||
channel_id: str,
|
||||
) -> None:
|
||||
"""Delete a channel (admin only)."""
|
||||
channel = session.execute(
|
||||
select(Channel).where(Channel.id == channel_id)
|
||||
).scalar_one_or_none()
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
session.delete(channel)
|
||||
session.commit()
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.channel_visibility import (
|
||||
get_max_visibility_level,
|
||||
get_visible_channel_indices,
|
||||
resolve_user_role,
|
||||
)
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
@@ -47,6 +52,7 @@ def _flood_only_filter(
|
||||
async def get_stats(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
) -> DashboardStats:
|
||||
"""Get dashboard statistics."""
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -54,6 +60,21 @@ async def get_stats(
|
||||
yesterday = now - timedelta(days=1)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
|
||||
# Resolve channel visibility
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
visible_indices = get_visible_channel_indices(session, max_level)
|
||||
|
||||
# Build channel message visibility filter
|
||||
def _channel_visible_filter(
|
||||
model: type[Message] = Message,
|
||||
) -> ColumnElement[bool]:
|
||||
return or_(
|
||||
model.message_type != "channel",
|
||||
model.channel_idx.is_(None),
|
||||
model.channel_idx.in_(visible_indices),
|
||||
)
|
||||
|
||||
# Total nodes
|
||||
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
|
||||
|
||||
@@ -67,7 +88,10 @@ async def get_stats(
|
||||
|
||||
# Total messages
|
||||
total_messages = (
|
||||
session.execute(select(func.count()).select_from(Message)).scalar() or 0
|
||||
session.execute(
|
||||
select(func.count()).select_from(Message).where(_channel_visible_filter())
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Messages today
|
||||
@@ -76,6 +100,7 @@ async def get_stats(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= today_start)
|
||||
.where(_channel_visible_filter())
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -118,6 +143,7 @@ async def get_stats(
|
||||
select(func.count())
|
||||
.select_from(Message)
|
||||
.where(Message.received_at >= seven_days_ago)
|
||||
.where(_channel_visible_filter())
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
@@ -171,11 +197,12 @@ async def get_stats(
|
||||
for ad in recent_ads
|
||||
]
|
||||
|
||||
# Channel message counts
|
||||
# Channel message counts (only visible channels)
|
||||
channel_counts_query = (
|
||||
select(Message.channel_idx, func.count())
|
||||
.where(Message.message_type == "channel")
|
||||
.where(Message.channel_idx.isnot(None))
|
||||
.where(Message.channel_idx.in_(visible_indices))
|
||||
.group_by(Message.channel_idx)
|
||||
)
|
||||
channel_results = session.execute(channel_counts_query).all()
|
||||
@@ -336,6 +363,7 @@ async def get_activity(
|
||||
async def get_message_activity(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
days: int = 30,
|
||||
) -> MessageActivity:
|
||||
"""Get daily message activity for the specified period.
|
||||
@@ -349,11 +377,13 @@ async def get_message_activity(
|
||||
days = min(days, 90)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# End at start of today (exclude today's incomplete data)
|
||||
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Query message counts grouped by date
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
visible_indices = get_visible_channel_indices(session, max_level)
|
||||
|
||||
date_expr = func.date(Message.received_at)
|
||||
|
||||
query = (
|
||||
@@ -363,6 +393,13 @@ async def get_message_activity(
|
||||
)
|
||||
.where(Message.received_at >= start_date)
|
||||
.where(Message.received_at < end_date)
|
||||
.where(
|
||||
or_(
|
||||
Message.message_type != "channel",
|
||||
Message.channel_idx.is_(None),
|
||||
Message.channel_idx.in_(visible_indices),
|
||||
)
|
||||
)
|
||||
.group_by(date_expr)
|
||||
.order_by(date_expr)
|
||||
)
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.channel_visibility import (
|
||||
get_max_visibility_level,
|
||||
get_visible_channel_indices,
|
||||
resolve_user_role,
|
||||
)
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.api.observer_utils import fetch_observers_for_events
|
||||
from meshcore_hub.common.models import Message, Node, NodeTag
|
||||
@@ -32,6 +37,7 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]:
|
||||
async def list_messages(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
message_type: Optional[str] = Query(None, description="Filter by message type"),
|
||||
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
|
||||
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
|
||||
@@ -79,6 +85,18 @@ async def list_messages(
|
||||
if search:
|
||||
query = query.where(Message.text.ilike(f"%{search}%"))
|
||||
|
||||
# Apply channel visibility filtering
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
visible_indices = get_visible_channel_indices(session, max_level)
|
||||
query = query.where(
|
||||
or_(
|
||||
Message.message_type != "channel",
|
||||
Message.channel_idx.is_(None),
|
||||
Message.channel_idx.in_(visible_indices),
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
@@ -211,6 +229,7 @@ async def list_messages(
|
||||
async def get_message(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
message_id: str,
|
||||
) -> MessageRead:
|
||||
"""Get a single message by ID."""
|
||||
@@ -227,6 +246,14 @@ async def get_message(
|
||||
|
||||
message, observer_pk = result
|
||||
|
||||
# Apply channel visibility filter
|
||||
if message.message_type == "channel" and message.channel_idx is not None:
|
||||
role = resolve_user_role(request)
|
||||
max_level = get_max_visibility_level(role)
|
||||
visible_indices = get_visible_channel_indices(session, max_level)
|
||||
if message.channel_idx not in visible_indices:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Fetch observers for this message
|
||||
observers = []
|
||||
if message.event_hash:
|
||||
|
||||
@@ -260,8 +260,7 @@ def _run_collector_service(
|
||||
|
||||
click.echo("")
|
||||
builtin_keys = len(LetsMeshPacketDecoder.BUILTIN_CHANNEL_KEYS)
|
||||
env_keys = len(settings.collector_channel_keys_list)
|
||||
click.echo(f"Packet decoder: {builtin_keys} built-in keys, {env_keys} from .env")
|
||||
click.echo(f"Packet decoder: {builtin_keys} built-in keys, loading from database")
|
||||
|
||||
click.echo("")
|
||||
click.echo("Starting MQTT subscriber...")
|
||||
@@ -281,8 +280,7 @@ def _run_collector_service(
|
||||
cleanup_interval_hours=settings.data_retention_interval_hours,
|
||||
node_cleanup_enabled=settings.node_cleanup_enabled,
|
||||
node_cleanup_days=settings.node_cleanup_days,
|
||||
channel_keys=settings.collector_channel_keys_list,
|
||||
include_test_channel=settings.collector_include_test_channel,
|
||||
channel_refresh_interval_seconds=settings.channel_refresh_interval_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -309,6 +307,150 @@ def run_cmd(ctx: click.Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@collector.group("channel")
|
||||
@click.pass_context
|
||||
def channel_group(ctx: click.Context) -> None:
|
||||
"""Manage decryption channels in the database."""
|
||||
pass
|
||||
|
||||
|
||||
@channel_group.command("list")
|
||||
@click.pass_context
|
||||
def channel_list_cmd(ctx: click.Context) -> None:
|
||||
"""List all channels in the database."""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
with db.session_scope() as session:
|
||||
channels = session.query(Channel).order_by(Channel.name).all()
|
||||
if not channels:
|
||||
click.echo("No channels found.")
|
||||
else:
|
||||
click.echo(
|
||||
f"{'Name':<20} {'Key':<16} {'Hash':<6} {'Visibility':<12} {'Enabled'}"
|
||||
)
|
||||
click.echo("-" * 70)
|
||||
for ch in channels:
|
||||
click.echo(
|
||||
f"{ch.name:<20} {ch.masked_key:<16} {ch.channel_hash:<6} "
|
||||
f"{ch.visibility:<12} {'Yes' if ch.enabled else 'No'}"
|
||||
)
|
||||
db.dispose()
|
||||
|
||||
|
||||
@channel_group.command("add")
|
||||
@click.option("--name", required=True, help="Channel display name")
|
||||
@click.option(
|
||||
"--key", "key_hex", required=True, help="Channel key as hex (32 or 64 chars)"
|
||||
)
|
||||
@click.option(
|
||||
"--visibility",
|
||||
type=click.Choice(["public", "member", "operator", "admin"]),
|
||||
default="public",
|
||||
help="Channel visibility level (default: public)",
|
||||
)
|
||||
@click.pass_context
|
||||
def channel_add_cmd(
|
||||
ctx: click.Context,
|
||||
name: str,
|
||||
key_hex: str,
|
||||
visibility: str,
|
||||
) -> None:
|
||||
"""Add a new channel to the database."""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
with db.session_scope() as session:
|
||||
existing = session.query(Channel).filter(Channel.name == name).first()
|
||||
if existing:
|
||||
click.echo(f"Error: Channel '{name}' already exists.", err=True)
|
||||
db.dispose()
|
||||
return
|
||||
|
||||
channel = Channel(
|
||||
name=name,
|
||||
key_hex=key_hex.upper(),
|
||||
channel_hash=Channel.compute_channel_hash(key_hex.upper()),
|
||||
visibility=visibility,
|
||||
enabled=True,
|
||||
)
|
||||
session.add(channel)
|
||||
click.echo(f"Channel '{name}' added (hash={channel.channel_hash})")
|
||||
db.dispose()
|
||||
|
||||
|
||||
@channel_group.command("remove")
|
||||
@click.option("--name", required=True, help="Channel name to remove")
|
||||
@click.pass_context
|
||||
def channel_remove_cmd(ctx: click.Context, name: str) -> None:
|
||||
"""Remove a channel from the database."""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
with db.session_scope() as session:
|
||||
channel = session.query(Channel).filter(Channel.name == name).first()
|
||||
if not channel:
|
||||
click.echo(f"Error: Channel '{name}' not found.", err=True)
|
||||
db.dispose()
|
||||
return
|
||||
session.delete(channel)
|
||||
click.echo(f"Channel '{name}' removed.")
|
||||
db.dispose()
|
||||
|
||||
|
||||
@channel_group.command("enable")
|
||||
@click.option("--name", required=True, help="Channel name to enable")
|
||||
@click.pass_context
|
||||
def channel_enable_cmd(ctx: click.Context, name: str) -> None:
|
||||
"""Enable a channel."""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
with db.session_scope() as session:
|
||||
channel = session.query(Channel).filter(Channel.name == name).first()
|
||||
if not channel:
|
||||
click.echo(f"Error: Channel '{name}' not found.", err=True)
|
||||
db.dispose()
|
||||
return
|
||||
channel.enabled = True
|
||||
click.echo(f"Channel '{name}' enabled.")
|
||||
db.dispose()
|
||||
|
||||
|
||||
@channel_group.command("disable")
|
||||
@click.option("--name", required=True, help="Channel name to disable")
|
||||
@click.pass_context
|
||||
def channel_disable_cmd(ctx: click.Context, name: str) -> None:
|
||||
"""Disable a channel."""
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
db = DatabaseManager(ctx.obj["database_url"])
|
||||
with db.session_scope() as session:
|
||||
channel = session.query(Channel).filter(Channel.name == name).first()
|
||||
if not channel:
|
||||
click.echo(f"Error: Channel '{name}' not found.", err=True)
|
||||
db.dispose()
|
||||
return
|
||||
channel.enabled = False
|
||||
click.echo(f"Channel '{name}' disabled.")
|
||||
db.dispose()
|
||||
|
||||
|
||||
@collector.command("seed")
|
||||
@click.option(
|
||||
"--no-create-nodes",
|
||||
@@ -408,9 +550,100 @@ def _run_seed_import(
|
||||
elif verbose:
|
||||
click.echo(f"\nNo node_tags.yaml found in {seed_home}")
|
||||
|
||||
# Import channels if file exists
|
||||
channels_file = Path(seed_home) / "channels.yaml"
|
||||
if channels_file.exists():
|
||||
if verbose:
|
||||
click.echo(f"\nImporting channels from: {channels_file}")
|
||||
channel_stats = _import_channels(
|
||||
file_path=str(channels_file),
|
||||
db=db,
|
||||
verbose=verbose,
|
||||
)
|
||||
if verbose:
|
||||
click.echo(
|
||||
f" Channels: {channel_stats['created']} created, "
|
||||
f"{channel_stats['updated']} updated"
|
||||
)
|
||||
if channel_stats["errors"]:
|
||||
for error in channel_stats["errors"]: # type: ignore[union-attr]
|
||||
click.echo(f" Error: {error}", err=True)
|
||||
imported_any = True
|
||||
elif verbose:
|
||||
click.echo(f"\nNo channels.yaml found in {seed_home}")
|
||||
|
||||
return imported_any
|
||||
|
||||
|
||||
def _import_channels(
|
||||
file_path: str,
|
||||
db: "DatabaseManager",
|
||||
verbose: bool = False,
|
||||
) -> dict[str, int | list[str]]:
|
||||
"""Import channels from a YAML file.
|
||||
|
||||
Supports two formats:
|
||||
- Shorthand: name: HEX (value is string, treated as key_hex)
|
||||
- Expanded: name: { key: HEX, enabled: true } (value is dict)
|
||||
|
||||
Visibility is always 'public' for seeded channels.
|
||||
|
||||
Returns:
|
||||
Dict with 'created', 'updated', and 'errors' counts.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
with open(file_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not data or not isinstance(data, dict):
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
with db.session_scope() as session:
|
||||
for name, value in data.items():
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
key_hex = value.strip().upper()
|
||||
enabled = True
|
||||
elif isinstance(value, dict):
|
||||
key_hex = value.get("key", "").strip().upper()
|
||||
enabled = value.get("enabled", True)
|
||||
else:
|
||||
errors.append(f"Invalid format for channel '{name}'")
|
||||
continue
|
||||
|
||||
if not key_hex:
|
||||
errors.append(f"Empty key for channel '{name}'")
|
||||
continue
|
||||
|
||||
existing = session.query(Channel).filter(Channel.name == name).first()
|
||||
if existing:
|
||||
existing.key_hex = key_hex
|
||||
existing.channel_hash = Channel.compute_channel_hash(key_hex)
|
||||
existing.enabled = enabled
|
||||
updated += 1
|
||||
else:
|
||||
channel = Channel(
|
||||
name=name,
|
||||
key_hex=key_hex,
|
||||
channel_hash=Channel.compute_channel_hash(key_hex),
|
||||
visibility="public",
|
||||
enabled=enabled,
|
||||
)
|
||||
session.add(channel)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Channel '{name}': {e}")
|
||||
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@collector.command("import-tags")
|
||||
@click.argument("file", type=click.Path(), required=False, default=None)
|
||||
@click.option(
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import string
|
||||
import threading
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from meshcoredecoder import MeshCoreDecoder
|
||||
@@ -41,6 +42,7 @@ class LetsMeshPacketDecoder:
|
||||
self,
|
||||
channel_keys: list[str] | None = None,
|
||||
) -> None:
|
||||
self._state_lock = threading.Lock()
|
||||
self._channel_key_infos = self._normalize_channel_keys(channel_keys or [])
|
||||
self._channel_keys = [info.key_hex for info in self._channel_key_infos]
|
||||
self._channel_names_by_hash = {
|
||||
@@ -67,6 +69,39 @@ class LetsMeshPacketDecoder:
|
||||
key_store.add_channel_secrets(self._channel_keys)
|
||||
return key_store
|
||||
|
||||
def reload_keys(self, channel_keys: list[str]) -> None:
|
||||
"""Reload channel keys from a new key list (thread-safe).
|
||||
|
||||
Rebuilds the key store and channel name map without discarding the
|
||||
decode cache. The state lock is held only during the atomic swap
|
||||
of ``_key_store`` and ``_channel_names_by_hash``, not during key
|
||||
normalization or KeyStore construction.
|
||||
|
||||
Args:
|
||||
channel_keys: New list of channel key entries to load.
|
||||
"""
|
||||
new_infos = self._normalize_channel_keys(channel_keys)
|
||||
new_keys = [info.key_hex for info in new_infos]
|
||||
new_names = {info.channel_hash: info.label for info in new_infos if info.label}
|
||||
new_store = MeshCoreKeyStore()
|
||||
if new_keys:
|
||||
new_store.add_channel_secrets(new_keys)
|
||||
|
||||
with self._state_lock:
|
||||
self._channel_key_infos = new_infos
|
||||
self._channel_keys = new_keys
|
||||
self._channel_names_by_hash = new_names
|
||||
self._key_store = new_store
|
||||
|
||||
logger.debug(
|
||||
"LetsMesh decoder reloaded: %d channel keys (%s)",
|
||||
len(new_infos),
|
||||
", ".join(
|
||||
f"{info.label or 'unlabeled'}=0x{info.channel_hash}"
|
||||
for info in new_infos
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_channel_keys(cls, values: list[str]) -> list[ChannelKey]:
|
||||
"""Normalize key list (labels + key + channel hash, deduplicated)."""
|
||||
@@ -160,12 +195,15 @@ class LetsMeshPacketDecoder:
|
||||
if not isinstance(channel_hash, str):
|
||||
return None
|
||||
|
||||
return self._channel_names_by_hash.get(channel_hash.upper())
|
||||
with self._state_lock:
|
||||
return self._channel_names_by_hash.get(channel_hash.upper())
|
||||
|
||||
def channel_labels_by_index(self) -> dict[int, str]:
|
||||
"""Return channel labels keyed by numeric channel index (0-255)."""
|
||||
labels: dict[int, str] = {}
|
||||
for info in self._channel_key_infos:
|
||||
with self._state_lock:
|
||||
infos = list(self._channel_key_infos)
|
||||
for info in infos:
|
||||
if not info.label:
|
||||
continue
|
||||
|
||||
@@ -202,8 +240,10 @@ class LetsMeshPacketDecoder:
|
||||
def _decode_raw(self, raw_hex: str) -> dict[str, Any] | None:
|
||||
"""Decode raw packet hex with native Python decoder (cached per packet hex)."""
|
||||
try:
|
||||
with self._state_lock:
|
||||
key_store = self._key_store
|
||||
options = DecryptionOptions(
|
||||
key_store=self._key_store,
|
||||
key_store=key_store,
|
||||
attempt_decryption=True,
|
||||
)
|
||||
result = MeshCoreDecoder.decode(raw_hex, options)
|
||||
|
||||
@@ -47,8 +47,7 @@ class Subscriber(LetsMeshNormalizer):
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
channel_refresh_interval_seconds: int = 300,
|
||||
):
|
||||
"""Initialize subscriber.
|
||||
|
||||
@@ -61,8 +60,7 @@ class Subscriber(LetsMeshNormalizer):
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
channel_refresh_interval_seconds: Seconds between channel key refresh
|
||||
"""
|
||||
self.mqtt = mqtt_client
|
||||
self.db = db_manager
|
||||
@@ -85,10 +83,14 @@ class Subscriber(LetsMeshNormalizer):
|
||||
self._node_cleanup_days = node_cleanup_days
|
||||
self._cleanup_thread: Optional[threading.Thread] = None
|
||||
self._last_cleanup: Optional[datetime] = None
|
||||
# Channel key refresh
|
||||
self._channel_refresh_interval_seconds = channel_refresh_interval_seconds
|
||||
self._channel_refresh_thread: Optional[threading.Thread] = None
|
||||
# Load initial channel keys from database
|
||||
self._include_test_channel = self._load_channel_keys_from_db()
|
||||
self._letsmesh_decoder = LetsMeshPacketDecoder(
|
||||
channel_keys=channel_keys,
|
||||
channel_keys=self._db_channel_keys,
|
||||
)
|
||||
self._include_test_channel = include_test_channel
|
||||
|
||||
@property
|
||||
def is_healthy(self) -> bool:
|
||||
@@ -99,6 +101,68 @@ class Subscriber(LetsMeshNormalizer):
|
||||
"""
|
||||
return self._running and self._mqtt_connected and self._db_connected
|
||||
|
||||
def _load_channel_keys_from_db(self) -> bool:
|
||||
"""Load channel keys from the database (synchronous).
|
||||
|
||||
Queries enabled channels, merges with built-in keys, and
|
||||
determines whether the test channel should be included.
|
||||
|
||||
Returns:
|
||||
True if test channel should be included (DB row exists with enabled=True).
|
||||
"""
|
||||
self._db_channel_keys: list[str] = []
|
||||
include_test = False
|
||||
try:
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
with self.db.session_scope() as session:
|
||||
channels = (
|
||||
session.query(Channel)
|
||||
.filter(Channel.enabled == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for ch in channels:
|
||||
self._db_channel_keys.append(f"{ch.name}={ch.key_hex}")
|
||||
if ch.name.lower() == "test":
|
||||
include_test = True
|
||||
logger.info(
|
||||
"Loaded %d channel keys from database (include_test=%s)",
|
||||
len(self._db_channel_keys),
|
||||
include_test,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load channel keys from database: %s", e)
|
||||
self._db_channel_keys = []
|
||||
return include_test
|
||||
|
||||
def _refresh_channel_keys_from_db(self) -> None:
|
||||
"""Refresh channel keys from the database and reload the decoder."""
|
||||
new_keys: list[str] = []
|
||||
include_test = False
|
||||
try:
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
with self.db.session_scope() as session:
|
||||
channels = (
|
||||
session.query(Channel)
|
||||
.filter(Channel.enabled == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for ch in channels:
|
||||
new_keys.append(f"{ch.name}={ch.key_hex}")
|
||||
if ch.name.lower() == "test":
|
||||
include_test = True
|
||||
self._db_channel_keys = new_keys
|
||||
self._include_test_channel = include_test
|
||||
self._letsmesh_decoder.reload_keys(new_keys)
|
||||
logger.info(
|
||||
"Refreshed %d channel keys from database (include_test=%s)",
|
||||
len(new_keys),
|
||||
include_test,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to refresh channel keys from database: %s", e)
|
||||
|
||||
def get_health_status(self) -> dict[str, Any]:
|
||||
"""Get detailed health status.
|
||||
|
||||
@@ -364,6 +428,40 @@ class Subscriber(LetsMeshNormalizer):
|
||||
if self._cleanup_thread.is_alive():
|
||||
logger.warning("Cleanup scheduler thread did not stop cleanly")
|
||||
|
||||
def _start_channel_refresh_scheduler(self) -> None:
|
||||
"""Start background thread for periodic channel key refresh."""
|
||||
interval = self._channel_refresh_interval_seconds
|
||||
if interval <= 0:
|
||||
logger.info("Channel key refresh is disabled (interval=0)")
|
||||
return
|
||||
|
||||
logger.info("Starting channel refresh scheduler (interval=%ds)", interval)
|
||||
|
||||
def run_refresh_loop() -> None:
|
||||
"""Periodically refresh channel keys from database."""
|
||||
while self._running:
|
||||
for _ in range(interval):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(1)
|
||||
if self._running:
|
||||
try:
|
||||
self._refresh_channel_keys_from_db()
|
||||
except Exception as e:
|
||||
logger.error("Channel refresh error: %s", e, exc_info=True)
|
||||
|
||||
self._channel_refresh_thread = threading.Thread(
|
||||
target=run_refresh_loop, daemon=True, name="channel-refresh"
|
||||
)
|
||||
self._channel_refresh_thread.start()
|
||||
|
||||
def _stop_channel_refresh_scheduler(self) -> None:
|
||||
"""Stop the channel refresh scheduler thread."""
|
||||
if self._channel_refresh_thread and self._channel_refresh_thread.is_alive():
|
||||
self._channel_refresh_thread.join(timeout=5.0)
|
||||
if self._channel_refresh_thread.is_alive():
|
||||
logger.warning("Channel refresh thread did not stop cleanly")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the subscriber."""
|
||||
logger.info("Starting collector subscriber")
|
||||
@@ -428,6 +526,9 @@ class Subscriber(LetsMeshNormalizer):
|
||||
# Start cleanup scheduler if configured
|
||||
self._start_cleanup_scheduler()
|
||||
|
||||
# Start channel key refresh scheduler
|
||||
self._start_channel_refresh_scheduler()
|
||||
|
||||
# Start health reporter for Docker health checks
|
||||
self._health_reporter = HealthReporter(
|
||||
component="collector",
|
||||
@@ -463,6 +564,9 @@ class Subscriber(LetsMeshNormalizer):
|
||||
# Stop cleanup scheduler
|
||||
self._stop_cleanup_scheduler()
|
||||
|
||||
# Stop channel refresh scheduler
|
||||
self._stop_channel_refresh_scheduler()
|
||||
|
||||
# Stop webhook processor
|
||||
self._stop_webhook_processor()
|
||||
|
||||
@@ -495,8 +599,7 @@ def create_subscriber(
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
channel_refresh_interval_seconds: int = 300,
|
||||
) -> Subscriber:
|
||||
"""Create a configured subscriber instance.
|
||||
|
||||
@@ -516,8 +619,7 @@ def create_subscriber(
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
channel_refresh_interval_seconds: Seconds between channel key refresh
|
||||
|
||||
Returns:
|
||||
Configured Subscriber instance
|
||||
@@ -550,8 +652,7 @@ def create_subscriber(
|
||||
cleanup_interval_hours=cleanup_interval_hours,
|
||||
node_cleanup_enabled=node_cleanup_enabled,
|
||||
node_cleanup_days=node_cleanup_days,
|
||||
channel_keys=channel_keys,
|
||||
include_test_channel=include_test_channel,
|
||||
channel_refresh_interval_seconds=channel_refresh_interval_seconds,
|
||||
)
|
||||
|
||||
# Register handlers
|
||||
@@ -578,8 +679,7 @@ def run_collector(
|
||||
cleanup_interval_hours: int = 24,
|
||||
node_cleanup_enabled: bool = False,
|
||||
node_cleanup_days: int = 90,
|
||||
channel_keys: list[str] | None = None,
|
||||
include_test_channel: bool = False,
|
||||
channel_refresh_interval_seconds: int = 300,
|
||||
) -> None:
|
||||
"""Run the collector (blocking).
|
||||
|
||||
@@ -599,8 +699,7 @@ def run_collector(
|
||||
cleanup_interval_hours: Hours between cleanup runs
|
||||
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
|
||||
node_cleanup_days: Remove nodes not seen for this many days
|
||||
channel_keys: Optional channel keys for decrypting group text
|
||||
include_test_channel: Include built-in test channel messages
|
||||
channel_refresh_interval_seconds: Seconds between channel key refresh
|
||||
"""
|
||||
subscriber = create_subscriber(
|
||||
mqtt_host=mqtt_host,
|
||||
@@ -618,8 +717,7 @@ def run_collector(
|
||||
cleanup_interval_hours=cleanup_interval_hours,
|
||||
node_cleanup_enabled=node_cleanup_enabled,
|
||||
node_cleanup_days=node_cleanup_days,
|
||||
channel_keys=channel_keys,
|
||||
include_test_channel=include_test_channel,
|
||||
channel_refresh_interval_seconds=channel_refresh_interval_seconds,
|
||||
)
|
||||
|
||||
# Set up signal handlers
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Pydantic Settings for MeshCore Hub configuration."""
|
||||
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
@@ -138,16 +137,10 @@ class CollectorSettings(CommonSettings):
|
||||
description="Remove nodes not seen for this many days (last_seen)",
|
||||
ge=1,
|
||||
)
|
||||
collector_channel_keys: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional channel secret keys for message decryption. "
|
||||
"Provide as comma/space separated hex values."
|
||||
),
|
||||
)
|
||||
collector_include_test_channel: bool = Field(
|
||||
default=False,
|
||||
description="Include built-in 'test' channel messages (channel_idx 217).",
|
||||
channel_refresh_interval_seconds: int = Field(
|
||||
default=300,
|
||||
description="Seconds between channel key refresh from database",
|
||||
ge=10,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -182,15 +175,11 @@ class CollectorSettings(CommonSettings):
|
||||
return str(Path(self.effective_seed_home) / "node_tags.yaml")
|
||||
|
||||
@property
|
||||
def collector_channel_keys_list(self) -> list[str]:
|
||||
"""Parse configured channel keys into a normalized list."""
|
||||
if not self.collector_channel_keys:
|
||||
return []
|
||||
return [
|
||||
part.strip()
|
||||
for part in re.split(r"[,\s]+", self.collector_channel_keys)
|
||||
if part.strip()
|
||||
]
|
||||
def channels_file(self) -> str:
|
||||
"""Get the path to channels.yaml in seed_home."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.effective_seed_home) / "channels.yaml")
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
@@ -383,6 +372,9 @@ class WebSettings(CommonSettings):
|
||||
default=True, description="Enable the /map page and /map/data endpoint"
|
||||
)
|
||||
feature_members: bool = Field(default=True, description="Enable the /members page")
|
||||
feature_channels: bool = Field(
|
||||
default=True, description="Enable the /channels page"
|
||||
)
|
||||
feature_pages: bool = Field(
|
||||
default=True, description="Enable custom markdown pages"
|
||||
)
|
||||
@@ -412,6 +404,7 @@ class WebSettings(CommonSettings):
|
||||
"messages": self.feature_messages,
|
||||
"map": self.feature_map and self.feature_nodes,
|
||||
"members": self.feature_members and self.oidc_enabled,
|
||||
"channels": self.feature_channels,
|
||||
"pages": self.feature_pages,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from meshcore_hub.common.models.event_log import EventLog
|
||||
from meshcore_hub.common.models.user_profile import UserProfile
|
||||
from meshcore_hub.common.models.user_profile_node import UserProfileNode
|
||||
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
|
||||
from meshcore_hub.common.models.channel import Channel, ChannelVisibility
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -26,4 +27,6 @@ __all__ = [
|
||||
"UserProfileNode",
|
||||
"EventObserver",
|
||||
"add_event_observer",
|
||||
"Channel",
|
||||
"ChannelVisibility",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Channel model for database-backed decrypt keys."""
|
||||
|
||||
import hashlib
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class ChannelVisibility(str, Enum):
|
||||
"""Channel visibility/permission levels."""
|
||||
|
||||
PUBLIC = "public"
|
||||
MEMBER = "member"
|
||||
OPERATOR = "operator"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class Channel(Base, UUIDMixin, TimestampMixin):
|
||||
"""Channel model for database-backed decrypt keys with permission-based visibility.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key
|
||||
name: Channel display name (unique, non-empty)
|
||||
key_hex: Secret key as uppercase hex (supports AES-128 and AES-256)
|
||||
channel_hash: First byte of SHA-256 of key_hex (2-char uppercase hex)
|
||||
visibility: Permission level (public, member, operator, admin)
|
||||
enabled: Whether the channel is active
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = "channels"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, nullable=False, index=True
|
||||
)
|
||||
key_hex: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
channel_hash: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20), default=ChannelVisibility.PUBLIC.value, nullable=False
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Channel(name={self.name}, hash={self.channel_hash}, visibility={self.visibility})>"
|
||||
|
||||
@staticmethod
|
||||
def compute_channel_hash(key_hex: str) -> str:
|
||||
"""Compute channel hash (first byte of SHA-256 of key_hex)."""
|
||||
return hashlib.sha256(bytes.fromhex(key_hex)).digest()[:1].hex().upper()
|
||||
|
||||
@property
|
||||
def masked_key(self) -> str:
|
||||
"""Return masked key showing first/last 4 chars."""
|
||||
if len(self.key_hex) <= 8:
|
||||
return self.key_hex
|
||||
return f"{self.key_hex[:4]}...{self.key_hex[-4:]}"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pydantic schemas for channel API endpoints."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ChannelCreate(BaseModel):
|
||||
"""Schema for creating a channel."""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Channel display name",
|
||||
)
|
||||
key_hex: str = Field(
|
||||
...,
|
||||
min_length=32,
|
||||
max_length=64,
|
||||
description="Channel secret key as uppercase hex (32 or 64 chars)",
|
||||
)
|
||||
visibility: Literal["public", "member", "operator", "admin"] = Field(
|
||||
default="public",
|
||||
description="Channel visibility/permission level",
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether the channel is active",
|
||||
)
|
||||
|
||||
@field_validator("key_hex")
|
||||
@classmethod
|
||||
def validate_key_hex(cls, v: str) -> str:
|
||||
"""Validate key is uppercase hex and correct length."""
|
||||
v = v.strip().upper()
|
||||
if not re.fullmatch(r"[0-9A-F]+", v):
|
||||
raise ValueError("key_hex must contain only hexadecimal characters")
|
||||
if len(v) not in (32, 64):
|
||||
raise ValueError("key_hex must be 32 or 64 hex characters")
|
||||
return v
|
||||
|
||||
|
||||
class ChannelUpdate(BaseModel):
|
||||
"""Schema for updating a channel."""
|
||||
|
||||
key_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=32,
|
||||
max_length=64,
|
||||
description="Channel secret key as uppercase hex",
|
||||
)
|
||||
visibility: Optional[Literal["public", "member", "operator", "admin"]] = Field(
|
||||
default=None,
|
||||
description="Channel visibility/permission level",
|
||||
)
|
||||
enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Whether the channel is active",
|
||||
)
|
||||
|
||||
@field_validator("key_hex")
|
||||
@classmethod
|
||||
def validate_key_hex(cls, v: str | None) -> str | None:
|
||||
"""Validate key is uppercase hex and correct length."""
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip().upper()
|
||||
if not re.fullmatch(r"[0-9A-F]+", v):
|
||||
raise ValueError("key_hex must contain only hexadecimal characters")
|
||||
if len(v) not in (32, 64):
|
||||
raise ValueError("key_hex must be 32 or 64 hex characters")
|
||||
return v
|
||||
|
||||
|
||||
class ChannelRead(BaseModel):
|
||||
"""Schema for reading a channel."""
|
||||
|
||||
id: str = Field(..., description="Channel UUID")
|
||||
name: str = Field(..., description="Channel display name")
|
||||
channel_hash: str = Field(..., description="Channel hash (2-char hex)")
|
||||
visibility: str = Field(..., description="Visibility level")
|
||||
enabled: bool = Field(..., description="Whether the channel is active")
|
||||
masked_key: str = Field(..., description="Masked key (first/last 4 chars)")
|
||||
key_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Full key hex (visible to users with channel access)",
|
||||
)
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChannelList(BaseModel):
|
||||
"""Schema for paginated channel list response."""
|
||||
|
||||
items: list[ChannelRead] = Field(..., description="List of channels")
|
||||
total: int = Field(..., description="Total number of channels")
|
||||
+40
-21
@@ -2,8 +2,6 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -124,6 +122,15 @@ def _build_endpoint_access(
|
||||
"GET": _OPEN,
|
||||
"PUT": _AUTHENTICATED,
|
||||
},
|
||||
"v1/channels": {
|
||||
"GET": _OPEN,
|
||||
"POST": frozenset({role_admin}),
|
||||
},
|
||||
"v1/channels/": {
|
||||
"POST": frozenset({role_admin}),
|
||||
"PUT": frozenset({role_admin}),
|
||||
"DELETE": frozenset({role_admin}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -161,27 +168,31 @@ def check_api_access(
|
||||
return False
|
||||
|
||||
|
||||
def _parse_decoder_key_entries(raw: str | None) -> list[str]:
|
||||
"""Parse COLLECTOR_CHANNEL_KEYS into key entries."""
|
||||
if not raw:
|
||||
return []
|
||||
return [part.strip() for part in re.split(r"[,\s]+", raw) if part.strip()]
|
||||
|
||||
|
||||
def _build_channel_labels() -> dict[str, str]:
|
||||
"""Build UI channel labels from built-in + configured decoder keys."""
|
||||
raw_keys = os.getenv("COLLECTOR_CHANNEL_KEYS")
|
||||
include_test = os.getenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
decoder = LetsMeshPacketDecoder(
|
||||
channel_keys=_parse_decoder_key_entries(raw_keys),
|
||||
)
|
||||
"""Build UI channel labels from built-in + database channel keys."""
|
||||
decoder = LetsMeshPacketDecoder(channel_keys=[])
|
||||
labels = decoder.channel_labels_by_index()
|
||||
if not include_test:
|
||||
labels.pop(LetsMeshPacketDecoder.TEST_CHANNEL_IDX, None)
|
||||
|
||||
try:
|
||||
from meshcore_hub.common.config import get_collector_settings
|
||||
|
||||
settings = get_collector_settings()
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
|
||||
db = DatabaseManager(settings.effective_database_url)
|
||||
from meshcore_hub.common.models.channel import Channel
|
||||
|
||||
with db.session_scope() as session:
|
||||
channels = session.query(Channel).filter(Channel.enabled.is_(True)).all()
|
||||
db_decoder = LetsMeshPacketDecoder(
|
||||
channel_keys=[f"{ch.name}={ch.key_hex}" for ch in channels]
|
||||
)
|
||||
db_labels = db_decoder.channel_labels_by_index()
|
||||
labels.update(db_labels)
|
||||
db.dispose()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load channel labels from database: %s", e)
|
||||
|
||||
return {str(idx): label for idx, label in sorted(labels.items())}
|
||||
|
||||
|
||||
@@ -683,6 +694,13 @@ def create_app(
|
||||
):
|
||||
resp_headers[k] = v
|
||||
|
||||
if (
|
||||
response.status_code < 300
|
||||
and path.startswith("v1/channels")
|
||||
and request.method in ("POST", "PUT", "DELETE")
|
||||
):
|
||||
request.app.state.channel_labels = _build_channel_labels()
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
@@ -933,6 +951,7 @@ def create_app(
|
||||
("/dashboard", "hourly", "0.9", "dashboard"),
|
||||
("/nodes", "hourly", "0.9", "nodes"),
|
||||
("/advertisements", "hourly", "0.8", "advertisements"),
|
||||
("/channels", "daily", "0.7", "channels"),
|
||||
("/map", "daily", "0.7", "map"),
|
||||
("/members", "weekly", "0.6", "members"),
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
--color-nodes: oklch(0.65 0.24 265); /* violet */
|
||||
--color-adverts: oklch(0.7 0.17 330); /* magenta */
|
||||
--color-messages: oklch(0.75 0.18 180); /* teal */
|
||||
--color-channels: oklch(0.72 0.15 300); /* purple */
|
||||
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
|
||||
--color-members: oklch(0.72 0.17 50); /* orange */
|
||||
--color-neutral: oklch(0.3 0.01 250); /* subtle dark grey */
|
||||
@@ -35,6 +36,7 @@
|
||||
--color-nodes: oklch(0.50 0.24 265);
|
||||
--color-adverts: oklch(0.55 0.17 330);
|
||||
--color-messages: oklch(0.55 0.18 180);
|
||||
--color-channels: oklch(0.55 0.15 300);
|
||||
--color-map: oklch(0.58 0.16 45);
|
||||
--color-members: oklch(0.55 0.18 25);
|
||||
--color-neutral: oklch(0.85 0.01 250);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Router } from './router.js';
|
||||
import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js';
|
||||
import { loadLocale, t } from './i18n.js';
|
||||
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, iconMembers, iconPage } from './icons.js';
|
||||
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, iconMembers, iconPage, iconChannel } from './icons.js';
|
||||
|
||||
// Page modules (lazy-loaded)
|
||||
const pages = {
|
||||
@@ -20,6 +20,7 @@ const pages = {
|
||||
advertisements: () => import('./pages/advertisements.js'),
|
||||
map: () => import('./pages/map.js'),
|
||||
members: () => import('./pages/members.js'),
|
||||
channels: () => import('./pages/channels.js'),
|
||||
customPage: () => import('./pages/custom-page.js'),
|
||||
notFound: () => import('./pages/not-found.js'),
|
||||
profile: () => import('./pages/profile.js'),
|
||||
@@ -73,6 +74,9 @@ if (features.nodes !== false) {
|
||||
if (features.messages !== false) {
|
||||
router.addRoute('/messages', pageHandler(pages.messages));
|
||||
}
|
||||
if (features.channels !== false) {
|
||||
router.addRoute('/channels', pageHandler(pages.channels));
|
||||
}
|
||||
if (features.advertisements !== false) {
|
||||
router.addRoute('/advertisements', pageHandler(pages.advertisements));
|
||||
}
|
||||
@@ -150,6 +154,7 @@ function updatePageTitle(pathname) {
|
||||
if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
|
||||
if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
|
||||
if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
|
||||
if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels');
|
||||
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
|
||||
if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
|
||||
if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
|
||||
@@ -201,6 +206,9 @@ function renderMobileNav(config) {
|
||||
if (features.messages !== false) {
|
||||
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
|
||||
}
|
||||
if (features.channels !== false) {
|
||||
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5')} ${t('entities.channels')}</a></li>`);
|
||||
}
|
||||
if (features.map !== false) {
|
||||
items.push(html`<li><a href="/map" data-nav-link>${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}</a></li>`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
|
||||
import { iconChannel, iconPlus, iconEdit, iconTrash, iconLock } from '../icons.js';
|
||||
|
||||
const VISIBILITY_COLORS = {
|
||||
public: 'badge-success',
|
||||
member: 'badge-warning',
|
||||
operator: 'badge-orange',
|
||||
admin: 'badge-error',
|
||||
};
|
||||
|
||||
function renderVisibilityBadge(visibility, oidcEnabled) {
|
||||
if (!oidcEnabled) return nothing;
|
||||
const colorClass = VISIBILITY_COLORS[visibility] || 'badge-ghost';
|
||||
return html`<span class="badge ${colorClass} badge-sm">${visibility}</span>`;
|
||||
}
|
||||
|
||||
function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) {
|
||||
const visibilityBadge = renderVisibilityBadge(channel.visibility, oidcEnabled);
|
||||
const enabledBadge = !channel.enabled
|
||||
? html`<span class="badge badge-ghost badge-sm">${t('channels.disabled')}</span>`
|
||||
: nothing;
|
||||
|
||||
const qrId = `qr-${channel.id}`;
|
||||
const channelIdx = parseInt(channel.channel_hash, 16);
|
||||
|
||||
const adminButtons = isAdmin
|
||||
? html`<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-outline" @click=${(e) => { e.stopPropagation(); onEdit(channel); }}>
|
||||
${iconEdit('h-3 w-3')} ${t('common.edit')}
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline btn-error" @click=${(e) => { e.stopPropagation(); onDelete(channel); }}>
|
||||
${iconTrash('h-3 w-3')} ${t('common.delete')}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const keyDisplay = channel.key_hex
|
||||
? html`<div class="font-mono text-xs opacity-70 mt-1 break-all select-all">${channel.key_hex.toLowerCase()}</div>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl cursor-pointer" @click=${() => onNavigate(channelIdx)}>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title flex items-center gap-2">
|
||||
${iconChannel('h-5 w-5')}
|
||||
${channel.name}
|
||||
${visibilityBadge}
|
||||
${enabledBadge}
|
||||
</h2>
|
||||
${keyDisplay}
|
||||
<div id="${qrId}" class="qr-container mt-2"></div>
|
||||
${adminButtons}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAddButton(onAdd) {
|
||||
return html`<button class="btn btn-primary btn-sm" @click=${onAdd}>
|
||||
${iconPlus('h-4 w-4')} ${t('channels.add_channel')}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderChannelModal({ channel, isEdit, onSave, onCancel }) {
|
||||
const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel');
|
||||
|
||||
return html`<dialog open class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">${title}</h3>
|
||||
<form @submit=${(e) => { e.preventDefault(); onSave(); }}>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center mb-4">
|
||||
<label class="label-text text-right">${t('channels.name_label')}</label>
|
||||
<input type="text" id="channel-modal-name" class="input input-bordered input-sm"
|
||||
.value=${isEdit ? channel.name : ''}
|
||||
?disabled=${isEdit}
|
||||
placeholder="${t('channels.name_label')}"
|
||||
required maxlength="100" />
|
||||
${!isEdit ? html`
|
||||
<label class="label-text text-right">${t('channels.key_label')}</label>
|
||||
<input type="text" id="channel-modal-key" class="input input-bordered input-sm font-mono"
|
||||
placeholder="e.g. ABCDEF0123456789..."
|
||||
required minlength="32" maxlength="64"
|
||||
pattern="[0-9A-Fa-f]{32,64}" />` : nothing}
|
||||
<label class="label-text text-right">${t('channels.visibility_label')}</label>
|
||||
<select id="channel-modal-visibility" class="select select-bordered select-sm">
|
||||
<option value="public" .selected=${channel?.visibility === 'public' || !channel}>public</option>
|
||||
<option value="member" .selected=${channel?.visibility === 'member'}>member</option>
|
||||
<option value="operator" .selected=${channel?.visibility === 'operator'}>operator</option>
|
||||
<option value="admin" .selected=${channel?.visibility === 'admin'}>admin</option>
|
||||
</select>
|
||||
<div></div>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" id="channel-modal-enabled" class="checkbox checkbox-sm"
|
||||
.checked=${channel?.enabled !== false} />
|
||||
<span class="label-text">${t('channels.enabled_label')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
|
||||
</dialog>`;
|
||||
}
|
||||
|
||||
function renderDeleteModal({ channel, onConfirm, onCancel }) {
|
||||
return html`<dialog open class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">${t('channels.delete_channel')}</h3>
|
||||
<p>${t('channels.delete_confirm', { name: channel.name })}</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
|
||||
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
|
||||
</dialog>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const oidcEnabled = config.oidc_enabled;
|
||||
const isAdmin = hasRole('admin');
|
||||
|
||||
const data = await apiGet('/api/v1/channels');
|
||||
const channels = data.items || [];
|
||||
|
||||
let modalState = null;
|
||||
|
||||
async function refresh() {
|
||||
const newData = await apiGet('/api/v1/channels');
|
||||
renderPage(newData.items || []);
|
||||
}
|
||||
|
||||
function renderPage(channelsList) {
|
||||
const adminHeader = isAdmin
|
||||
? html`<div class="flex justify-end mb-4">${renderAddButton(handleAdd)}</div>`
|
||||
: nothing;
|
||||
|
||||
const emptyMessage = channelsList.length === 0
|
||||
? html`<div class="text-center py-10 opacity-60">
|
||||
${t('common.no_entity_found', { entity: t('entities.channels').toLowerCase() })}
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
let modalHtml = nothing;
|
||||
if (modalState?.type === 'add' || modalState?.type === 'edit') {
|
||||
modalHtml = renderChannelModal({
|
||||
channel: modalState.channel,
|
||||
isEdit: modalState.type === 'edit',
|
||||
onSave: handleSave,
|
||||
onCancel: () => { modalState = null; renderPage(channelsList); },
|
||||
});
|
||||
} else if (modalState?.type === 'delete') {
|
||||
modalHtml = renderDeleteModal({
|
||||
channel: modalState.channel,
|
||||
onConfirm: handleDeleteConfirm,
|
||||
onCancel: () => { modalState = null; renderPage(channelsList); },
|
||||
});
|
||||
}
|
||||
|
||||
litRender(html`
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
${iconChannel('h-7 w-7')}
|
||||
${t('channels.title')}
|
||||
</h1>
|
||||
</div>
|
||||
${adminHeader}
|
||||
${emptyMessage}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${channelsList.map(ch => renderChannelCard(ch, {
|
||||
oidcEnabled,
|
||||
isAdmin,
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: handleEditClick,
|
||||
onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`),
|
||||
}))}
|
||||
</div>
|
||||
${modalHtml}
|
||||
`, container);
|
||||
|
||||
channelsList.forEach(ch => {
|
||||
const qrEl = document.getElementById(`qr-${ch.id}`);
|
||||
if (qrEl && !qrEl.hasChildNodes() && ch.key_hex) {
|
||||
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(ch.name)}&secret=${ch.key_hex.toLowerCase()}`;
|
||||
new QRCode(qrEl, {
|
||||
text: qrUrl,
|
||||
width: 128,
|
||||
height: 128,
|
||||
correctLevel: QRCode.CorrectLevel.M,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
modalState = { type: 'add', channel: { visibility: 'public', enabled: true } };
|
||||
renderPage(channels);
|
||||
}
|
||||
|
||||
function handleEditClick(channel) {
|
||||
modalState = { type: 'edit', channel };
|
||||
renderPage(channels);
|
||||
}
|
||||
|
||||
function handleDeleteClick(channel) {
|
||||
modalState = { type: 'delete', channel };
|
||||
renderPage(channels);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const nameEl = document.getElementById('channel-modal-name');
|
||||
const keyEl = document.getElementById('channel-modal-key');
|
||||
const visEl = document.getElementById('channel-modal-visibility');
|
||||
const enabledEl = document.getElementById('channel-modal-enabled');
|
||||
|
||||
const isEdit = modalState.type === 'edit';
|
||||
const body = {
|
||||
visibility: visEl.value,
|
||||
enabled: enabledEl.checked,
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
body.name = nameEl.value.trim();
|
||||
body.key_hex = keyEl.value.trim().toUpperCase();
|
||||
} else {
|
||||
if (keyEl && keyEl.value) {
|
||||
body.key_hex = keyEl.value.trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiPut(`/api/v1/channels/${modalState.channel.id}`, body);
|
||||
} else {
|
||||
await apiPost('/api/v1/channels', body);
|
||||
}
|
||||
modalState = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to save channel');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
try {
|
||||
await apiDelete(`/api/v1/channels/${modalState.channel.id}`);
|
||||
modalState = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to delete channel');
|
||||
}
|
||||
}
|
||||
|
||||
renderPage(channels);
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
@@ -160,17 +160,24 @@ function renderChartCards({ showNodes, showAdverts, showMessages }) {
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const channelLabels = getChannelLabelsMap(config);
|
||||
let channelLabels = new Map();
|
||||
const features = config.features || {};
|
||||
const showNodes = features.nodes !== false;
|
||||
const showAdverts = features.advertisements !== false;
|
||||
const showMessages = features.messages !== false;
|
||||
|
||||
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
|
||||
const [stats, advertActivity, messageActivity, nodeCount, channelsData] = await Promise.all([
|
||||
apiGet('/api/v1/dashboard/stats'),
|
||||
apiGet('/api/v1/dashboard/activity', { days: 7 }),
|
||||
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
|
||||
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
|
||||
apiGet('/api/v1/channels'),
|
||||
]);
|
||||
channelLabels = new Map([
|
||||
...getChannelLabelsMap(config),
|
||||
...(channelsData.items || [])
|
||||
.map(ch => [parseInt(ch.channel_hash, 16), ch.name])
|
||||
.filter(([idx]) => Number.isInteger(idx)),
|
||||
]);
|
||||
|
||||
// Top section: stats + charts
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMembers, iconMap,
|
||||
iconPage, iconInfo, iconChart, iconAntenna, iconUsers,
|
||||
iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel,
|
||||
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
|
||||
} from '../icons.js';
|
||||
|
||||
@@ -99,6 +99,12 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity,
|
||||
label: t('entities.messages'),
|
||||
colorVar: '--color-messages',
|
||||
}) : nothing}
|
||||
${features.channels !== false ? renderNavCard({
|
||||
href: '/channels',
|
||||
icon: iconChannel('w-full h-full'),
|
||||
label: t('entities.channels'),
|
||||
colorVar: '--color-channels',
|
||||
}) : nothing}
|
||||
${features.members !== false ? renderNavCard({
|
||||
href: '/members',
|
||||
icon: iconMembers('w-full h-full'),
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function render(container, params, router) {
|
||||
const order = query.order || 'desc';
|
||||
|
||||
const config = getConfig();
|
||||
const channelLabels = getChannelLabelsMap(config);
|
||||
let channelLabels = new Map();
|
||||
const tz = config.timezone || '';
|
||||
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
|
||||
const navigate = (url) => router.navigate(url);
|
||||
@@ -206,9 +206,16 @@ ${displayContent}`, container);
|
||||
try {
|
||||
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
|
||||
if (observed_by.length > 0) apiParams.observed_by = observed_by;
|
||||
const [data, nodesData] = await Promise.all([
|
||||
const [data, nodesData, channelsData] = await Promise.all([
|
||||
apiGet('/api/v1/messages', apiParams),
|
||||
apiGet('/api/v1/nodes', { limit: 500, observer: true }),
|
||||
apiGet('/api/v1/channels'),
|
||||
]);
|
||||
channelLabels = new Map([
|
||||
...getChannelLabelsMap(config),
|
||||
...(channelsData.items || [])
|
||||
.map(ch => [parseInt(ch.channel_hash, 16), ch.name])
|
||||
.filter(([idx]) => Number.isInteger(idx)),
|
||||
]);
|
||||
const messages = dedupeBySignature(data.items || []);
|
||||
const allNodes = nodesData.items || [];
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"member": "Member",
|
||||
"tags": "Tags",
|
||||
"tag": "Tag",
|
||||
"channel": "Channel"
|
||||
"channel": "Channel",
|
||||
"channels": "Channels"
|
||||
},
|
||||
"common": {
|
||||
"filter": "Filter",
|
||||
@@ -223,6 +224,19 @@
|
||||
"empty_state_description": "No members yet.",
|
||||
"empty_description": "Members will appear here once users log in and adopt nodes."
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"add_channel": "Add Channel",
|
||||
"edit_channel": "Edit Channel",
|
||||
"delete_channel": "Delete Channel",
|
||||
"delete_confirm": "Are you sure you want to delete channel {{name}}?",
|
||||
"name_label": "Channel Name",
|
||||
"key_label": "Channel Key (hex)",
|
||||
"visibility_label": "Visibility",
|
||||
"enabled_label": "Enabled",
|
||||
"channel_hash_label": "Hash",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"not_found": {
|
||||
"description": "The page you're looking for doesn't exist or has been moved."
|
||||
},
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
{% if features.messages %}
|
||||
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.channels %}
|
||||
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
|
||||
{% endif %}
|
||||
{% if features.map %}
|
||||
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
|
||||
{% endif %}
|
||||
|
||||
@@ -311,7 +311,7 @@ def sample_message_with_receiver(api_db_session, receiver_node):
|
||||
"""Create a message with a receiver node."""
|
||||
message = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
channel_idx=17,
|
||||
pubkey_prefix="xyz789",
|
||||
text="Channel message with receiver",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestListMessages:
|
||||
"""Messages include observers list in response."""
|
||||
msg = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
channel_idx=17,
|
||||
text="Msg with observer",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
@@ -158,7 +158,7 @@ class TestGetMessage:
|
||||
"""Get message includes observers list."""
|
||||
msg = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
channel_idx=17,
|
||||
text="Msg for get observer test",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
@@ -204,11 +204,11 @@ class TestListMessagesFilters:
|
||||
):
|
||||
"""Test filtering messages by channel_idx."""
|
||||
# Channel 1 should match sample_message_with_receiver
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=1")
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=17")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["channel_idx"] == 1
|
||||
assert data["items"][0]["channel_idx"] == 17
|
||||
|
||||
# Channel 0 should return no results
|
||||
response = client_no_auth.get("/api/v1/messages?channel_idx=0")
|
||||
@@ -251,14 +251,14 @@ class TestListMessagesFilters:
|
||||
# Create two messages, each observed by a different receiver
|
||||
msg1 = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
channel_idx=17,
|
||||
text="Msg from receiver A",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=receiver_node.id,
|
||||
)
|
||||
msg2 = Message(
|
||||
message_type="channel",
|
||||
channel_idx=2,
|
||||
channel_idx=17,
|
||||
text="Msg from receiver B",
|
||||
received_at=datetime.now(timezone.utc),
|
||||
observer_node_id=second_receiver.id,
|
||||
@@ -381,7 +381,7 @@ class TestMessageSort:
|
||||
now = datetime.now(timezone.utc)
|
||||
msg_ch = Message(
|
||||
message_type="channel",
|
||||
channel_idx=1,
|
||||
channel_idx=17,
|
||||
text="Channel msg",
|
||||
received_at=now,
|
||||
)
|
||||
|
||||
@@ -402,8 +402,8 @@ class TestSubscriber:
|
||||
subscriber = Subscriber(
|
||||
mock_mqtt_client,
|
||||
db_manager,
|
||||
include_test_channel=True,
|
||||
)
|
||||
subscriber._include_test_channel = True
|
||||
handler = MagicMock()
|
||||
subscriber.register_handler("channel_msg_recv", handler)
|
||||
subscriber.start()
|
||||
|
||||
@@ -59,18 +59,20 @@ class TestCollectorSettings:
|
||||
assert settings.effective_seed_home == "/seed/data"
|
||||
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
|
||||
|
||||
def test_collector_channel_keys_list(self) -> None:
|
||||
"""Channel keys are parsed from comma/space-separated env values."""
|
||||
def test_channel_refresh_interval_seconds(self) -> None:
|
||||
"""Channel refresh interval defaults to 300."""
|
||||
settings = CollectorSettings(_env_file=None)
|
||||
|
||||
assert settings.channel_refresh_interval_seconds == 300
|
||||
|
||||
def test_channel_refresh_interval_seconds_custom(self) -> None:
|
||||
"""Channel refresh interval can be overridden."""
|
||||
settings = CollectorSettings(
|
||||
_env_file=None,
|
||||
collector_channel_keys="aa11, bb22 cc33",
|
||||
channel_refresh_interval_seconds=60,
|
||||
)
|
||||
|
||||
assert settings.collector_channel_keys_list == [
|
||||
"aa11",
|
||||
"bb22",
|
||||
"cc33",
|
||||
]
|
||||
assert settings.channel_refresh_interval_seconds == 60
|
||||
|
||||
|
||||
class TestAPISettings:
|
||||
|
||||
@@ -320,7 +320,6 @@ def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -
|
||||
"""Create a web app with mocked HTTP client."""
|
||||
# Ensure tests use a consistent locale regardless of local .env
|
||||
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
|
||||
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
|
||||
monkeypatch.setenv("OIDC_ENABLED", "false")
|
||||
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
|
||||
app = create_app(
|
||||
@@ -366,7 +365,6 @@ def web_app_with_oidc(
|
||||
)
|
||||
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
|
||||
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
|
||||
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
|
||||
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
|
||||
|
||||
app = create_app(
|
||||
|
||||
Reference in New Issue
Block a user