diff --git a/.agents/skills/docs-sync/references/config-source-guide.md b/.agents/skills/docs-sync/references/config-source-guide.md index e2e7076..f98546e 100644 --- a/.agents/skills/docs-sync/references/config-source-guide.md +++ b/.agents/skills/docs-sync/references/config-source-guide.md @@ -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 | diff --git a/.agents/skills/docs-sync/references/docker-source-guide.md b/.agents/skills/docs-sync/references/docker-source-guide.md index 638471c..895dc9f 100644 --- a/.agents/skills/docs-sync/references/docker-source-guide.md +++ b/.agents/skills/docs-sync/references/docker-source-guide.md @@ -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 diff --git a/.agents/skills/docs-sync/references/documentation-checklist.md b/.agents/skills/docs-sync/references/documentation-checklist.md index eea2952..284e2fb 100644 --- a/.agents/skills/docs-sync/references/documentation-checklist.md +++ b/.agents/skills/docs-sync/references/documentation-checklist.md @@ -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 diff --git a/.env.example b/.env.example index 06e5b16..57881f0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6a44ed2..fcf69a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index 77b1b7d..68a4ea2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SCHEMAS.md b/SCHEMAS.md index e535687..e270a0b 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -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)**: diff --git a/alembic/versions/20260519_2125_82dff87d6576_add_channels_table.py b/alembic/versions/20260519_2125_82dff87d6576_add_channels_table.py new file mode 100644 index 0000000..313d717 --- /dev/null +++ b/alembic/versions/20260519_2125_82dff87d6576_add_channels_table.py @@ -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 ### diff --git a/docker-compose.yml b/docker-compose.yml index 4759500..1652bc6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/letsmesh.md b/docs/letsmesh.md index d08e5ae..1900bd8 100644 --- a/docs/letsmesh.md +++ b/docs/letsmesh.md @@ -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=`). -- 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 diff --git a/docs/plans/20260519-2051-channel-model-db-decrypt/plan.md b/docs/plans/20260519-2051-channel-model-db-decrypt/plan.md new file mode 100644 index 0000000..bd1b151 --- /dev/null +++ b/docs/plans/20260519-2051-channel-model-db-decrypt/plan.md @@ -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=&key=` + - 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 `
  • ` 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=&key=` 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 `
  • ` 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()` diff --git a/docs/plans/20260519-2051-channel-model-db-decrypt/tasks.md b/docs/plans/20260519-2051-channel-model-db-decrypt/tasks.md new file mode 100644 index 0000000..5aeb1bd --- /dev/null +++ b/docs/plans/20260519-2051-channel-model-db-decrypt/tasks.md @@ -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=&key=" })` 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 `
  • ` for `/channels` with `iconChannel()` between Messages and Members + - [ ] 6.7.2 `spa.html` mobile menu: insert `
  • ` 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 diff --git a/docs/seeding.md b/docs/seeding.md index 09429a8..5e19a68 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -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 +``` diff --git a/docs/upgrading.md b/docs/upgrading.md index 3270e7b..9368347 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -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 diff --git a/example/seed/channels.yaml b/example/seed/channels.yaml new file mode 100644 index 0000000..2843cf8 --- /dev/null +++ b/example/seed/channels.yaml @@ -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 diff --git a/src/meshcore_hub/api/channel_visibility.py b/src/meshcore_hub/api/channel_visibility.py new file mode 100644 index 0000000..022a5b1 --- /dev/null +++ b/src/meshcore_hub/api/channel_visibility.py @@ -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} diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index 8008d26..fd997d9 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -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"]) diff --git a/src/meshcore_hub/api/routes/channels.py b/src/meshcore_hub/api/routes/channels.py new file mode 100644 index 0000000..9f79b49 --- /dev/null +++ b/src/meshcore_hub/api/routes/channels.py @@ -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() diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 44b9297..44374e6 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -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) ) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 4ea4caa..4f9bdcf 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -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: diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index 7a4eb7b..c7f2478 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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( diff --git a/src/meshcore_hub/collector/letsmesh_decoder.py b/src/meshcore_hub/collector/letsmesh_decoder.py index 237b91d..51533e3 100644 --- a/src/meshcore_hub/collector/letsmesh_decoder.py +++ b/src/meshcore_hub/collector/letsmesh_decoder.py @@ -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) diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py index 5d13a92..ad0a2ce 100644 --- a/src/meshcore_hub/collector/subscriber.py +++ b/src/meshcore_hub/collector/subscriber.py @@ -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 diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index bd2455a..aeae051 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -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, } diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index 9e9cb54..7b340c2 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -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", ] diff --git a/src/meshcore_hub/common/models/channel.py b/src/meshcore_hub/common/models/channel.py new file mode 100644 index 0000000..1347e7c --- /dev/null +++ b/src/meshcore_hub/common/models/channel.py @@ -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"" + + @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:]}" diff --git a/src/meshcore_hub/common/schemas/channels.py b/src/meshcore_hub/common/schemas/channels.py new file mode 100644 index 0000000..d428e55 --- /dev/null +++ b/src/meshcore_hub/common/schemas/channels.py @@ -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") diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 301d0b3..6905eb3 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -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"), ] diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index 4a125ee..71782a6 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -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); diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index e2bc39f..9b9855d 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -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`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); } + if (features.channels !== false) { + items.push(html`
  • ${iconChannel('h-5 w-5')} ${t('entities.channels')}
  • `); + } if (features.map !== false) { items.push(html`
  • ${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}
  • `); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js new file mode 100644 index 0000000..3e95523 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa/pages/channels.js @@ -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`${visibility}`; +} + +function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) { + const visibilityBadge = renderVisibilityBadge(channel.visibility, oidcEnabled); + const enabledBadge = !channel.enabled + ? html`${t('channels.disabled')}` + : nothing; + + const qrId = `qr-${channel.id}`; + const channelIdx = parseInt(channel.channel_hash, 16); + + const adminButtons = isAdmin + ? html`
    + + +
    ` + : nothing; + + const keyDisplay = channel.key_hex + ? html`
    ${channel.key_hex.toLowerCase()}
    ` + : nothing; + + return html`
    onNavigate(channelIdx)}> +
    +

    + ${iconChannel('h-5 w-5')} + ${channel.name} + ${visibilityBadge} + ${enabledBadge} +

    + ${keyDisplay} +
    + ${adminButtons} +
    +
    `; +} + +function renderAddButton(onAdd) { + return html``; +} + +function renderChannelModal({ channel, isEdit, onSave, onCancel }) { + const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel'); + + return html` + + + `; +} + +function renderDeleteModal({ channel, onConfirm, onCancel }) { + return html` + + + `; +} + +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`
    ${renderAddButton(handleAdd)}
    ` + : nothing; + + const emptyMessage = channelsList.length === 0 + ? html`
    + ${t('common.no_entity_found', { entity: t('entities.channels').toLowerCase() })} +
    ` + : 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` +
    +

    + ${iconChannel('h-7 w-7')} + ${t('channels.title')} +

    +
    + ${adminHeader} + ${emptyMessage} +
    + ${channelsList.map(ch => renderChannelCard(ch, { + oidcEnabled, + isAdmin, + onDelete: handleDeleteClick, + onEdit: handleEditClick, + onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`), + }))} +
    + ${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); + } +} diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index 47e1435..c43deb2 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -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 diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index 0a1ad12..7d22b7e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -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'), diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 4c62bb4..c710ec8 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -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`${tz}` : 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 || []; diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index d1f6fcd..749eda2 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -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." }, diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 3eecf07..31af969 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -70,6 +70,9 @@ {% if features.messages %}
  • {{ t('entities.messages') }}
  • {% endif %} + {% if features.channels %} +
  • {{ t('entities.channels') }}
  • + {% endif %} {% if features.map %}
  • {{ t('entities.map') }}
  • {% endif %} diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 0e85fcc..641a130 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -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), diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py index 6be6af8..5a8f197 100644 --- a/tests/test_api/test_messages.py +++ b/tests/test_api/test_messages.py @@ -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, ) diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index 885c166..3aed9e6 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -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() diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index 5f05106..7516c1e 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -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: diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index b0e15e2..fd794b1 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -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(