diff --git a/AGENTS.md b/AGENTS.md index 8ff41df..eef8bcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -370,10 +370,10 @@ Separate from private MQTT, the community publisher (`app/community_mqtt.py`) sh - Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS. - Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry. -- Broker address field supports `host:port` format (default port 443 if omitted). +- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`. - Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code. - JWT `email` claim enables node claiming on the community aggregator. -- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email` in `app_settings`. +- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`. ### Server-Side Decryption @@ -416,7 +416,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) | | `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location | -**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints). +**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints). Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. diff --git a/app/AGENTS.md b/app/AGENTS.md index 5273650..7405080 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -122,13 +122,13 @@ app/ - Independent from the private `MqttPublisher` — different broker, authentication, and topic structure. - Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS. - Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours. -- Broker address supports `host:port` format; defaults to port 443 if omitted. +- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`. - JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator). - Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default). - Only raw packets are published — never decrypted messages. - Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds. - Health endpoint includes `community_mqtt_status` field. -- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email`. +- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`. ## API Surface (all under `/api`) @@ -238,7 +238,7 @@ Main tables: - `bots` - `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password` - `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets` -- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email` +- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` ## Security Posture (intentional) diff --git a/app/community_mqtt.py b/app/community_mqtt.py index 63a85e4..8da1165 100644 --- a/app/community_mqtt.py +++ b/app/community_mqtt.py @@ -43,17 +43,6 @@ _IATA_RE = re.compile(r"^[A-Z]{3}$") _ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"} -def _parse_broker_address(broker_str: str) -> tuple[str, int]: - """Parse 'host' or 'host:port' into (host, port). Defaults to _DEFAULT_PORT.""" - if ":" in broker_str: - host, port_str = broker_str.rsplit(":", 1) - try: - return host, int(port_str) - except ValueError: - pass - return broker_str, _DEFAULT_PORT - - def _base64url_encode(data: bytes) -> str: """Base64url encode without padding.""" return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -308,8 +297,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): assert private_key is not None and public_key is not None # guaranteed by _pre_connect pubkey_hex = public_key.hex().upper() - broker_raw = settings.community_mqtt_broker or _DEFAULT_BROKER - broker_host, broker_port = _parse_broker_address(broker_raw) + broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER + broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT jwt_token = _generate_jwt_token( private_key, public_key, @@ -330,8 +319,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): } def _on_connected(self, settings: AppSettings) -> tuple[str, str]: - broker_raw = settings.community_mqtt_broker or _DEFAULT_BROKER - broker_host, broker_port = _parse_broker_address(broker_raw) + broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER + broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT return ("Community MQTT connected", f"{broker_host}:{broker_port}") def _on_error(self) -> tuple[str, str]: diff --git a/app/migrations.py b/app/migrations.py index 57e9473..cb78940 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1916,7 +1916,8 @@ async def _migrate_032_add_community_mqtt_columns(conn: aiosqlite.Connection) -> new_columns = [ ("community_mqtt_enabled", "INTEGER DEFAULT 0"), ("community_mqtt_iata", "TEXT DEFAULT ''"), - ("community_mqtt_broker", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"), + ("community_mqtt_broker_host", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"), + ("community_mqtt_broker_port", "INTEGER DEFAULT 443"), ("community_mqtt_email", "TEXT DEFAULT ''"), ] diff --git a/app/models.py b/app/models.py index 5f1557b..9a46fce 100644 --- a/app/models.py +++ b/app/models.py @@ -471,10 +471,14 @@ class AppSettings(BaseModel): default="", description="IATA region code for community MQTT topic routing (3 alpha chars)", ) - community_mqtt_broker: str = Field( + community_mqtt_broker_host: str = Field( default="mqtt-us-v1.letsmesh.net", description="Community MQTT broker hostname", ) + community_mqtt_broker_port: int = Field( + default=443, + description="Community MQTT broker port", + ) community_mqtt_email: str = Field( default="", description="Email address for node claiming on the community aggregator (optional)", diff --git a/app/repository/settings.py b/app/repository/settings.py index 7cb9540..ef489fd 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -31,7 +31,8 @@ class AppSettingsRepository: mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix, mqtt_publish_messages, mqtt_publish_raw_packets, community_mqtt_enabled, community_mqtt_iata, - community_mqtt_broker, community_mqtt_email + community_mqtt_broker_host, community_mqtt_broker_port, + community_mqtt_email FROM app_settings WHERE id = 1 """ ) @@ -107,7 +108,9 @@ class AppSettingsRepository: mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]), community_mqtt_enabled=bool(row["community_mqtt_enabled"]), community_mqtt_iata=row["community_mqtt_iata"] or "", - community_mqtt_broker=row["community_mqtt_broker"] or "mqtt-us-v1.letsmesh.net", + community_mqtt_broker_host=row["community_mqtt_broker_host"] + or "mqtt-us-v1.letsmesh.net", + community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443, community_mqtt_email=row["community_mqtt_email"] or "", ) @@ -133,7 +136,8 @@ class AppSettingsRepository: mqtt_publish_raw_packets: bool | None = None, community_mqtt_enabled: bool | None = None, community_mqtt_iata: str | None = None, - community_mqtt_broker: str | None = None, + community_mqtt_broker_host: str | None = None, + community_mqtt_broker_port: int | None = None, community_mqtt_email: str | None = None, ) -> AppSettings: """Update app settings. Only provided fields are updated.""" @@ -222,9 +226,13 @@ class AppSettingsRepository: updates.append("community_mqtt_iata = ?") params.append(community_mqtt_iata) - if community_mqtt_broker is not None: - updates.append("community_mqtt_broker = ?") - params.append(community_mqtt_broker) + if community_mqtt_broker_host is not None: + updates.append("community_mqtt_broker_host = ?") + params.append(community_mqtt_broker_host) + + if community_mqtt_broker_port is not None: + updates.append("community_mqtt_broker_port = ?") + params.append(community_mqtt_broker_port) if community_mqtt_email is not None: updates.append("community_mqtt_email = ?") diff --git a/app/routers/settings.py b/app/routers/settings.py index d6af18f..373cd23 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -106,10 +106,16 @@ class AppSettingsUpdate(BaseModel): default=None, description="IATA region code for community MQTT topic routing (3 alpha chars)", ) - community_mqtt_broker: str | None = Field( + community_mqtt_broker_host: str | None = Field( default=None, description="Community MQTT broker hostname", ) + community_mqtt_broker_port: int | None = Field( + default=None, + ge=1, + le=65535, + description="Community MQTT broker port", + ) community_mqtt_email: str | None = Field( default=None, description="Email address for node claiming on the community aggregator", @@ -214,8 +220,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: kwargs["community_mqtt_iata"] = iata community_mqtt_changed = True - if update.community_mqtt_broker is not None: - kwargs["community_mqtt_broker"] = update.community_mqtt_broker + if update.community_mqtt_broker_host is not None: + kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host + community_mqtt_changed = True + + if update.community_mqtt_broker_port is not None: + kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port community_mqtt_changed = True if update.community_mqtt_email is not None: diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx index 85b3f9c..f2a32c0 100644 --- a/frontend/src/components/settings/SettingsMqttSection.tsx +++ b/frontend/src/components/settings/SettingsMqttSection.tsx @@ -30,7 +30,8 @@ export function SettingsMqttSection({ // Community MQTT state const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false); const [communityMqttIata, setCommunityMqttIata] = useState(''); - const [communityMqttBroker, setCommunityMqttBroker] = useState('mqtt-us-v1.letsmesh.net'); + const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net'); + const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443'); const [communityMqttEmail, setCommunityMqttEmail] = useState(''); const [busy, setBusy] = useState(false); @@ -48,7 +49,8 @@ export function SettingsMqttSection({ setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false); setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false); setCommunityMqttIata(appSettings.community_mqtt_iata ?? ''); - setCommunityMqttBroker(appSettings.community_mqtt_broker ?? 'mqtt-us-v1.letsmesh.net'); + setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net'); + setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443)); setCommunityMqttEmail(appSettings.community_mqtt_email ?? ''); }, [appSettings]); @@ -69,7 +71,8 @@ export function SettingsMqttSection({ mqtt_publish_raw_packets: mqttPublishRawPackets, community_mqtt_enabled: communityMqttEnabled, community_mqtt_iata: communityMqttIata, - community_mqtt_broker: communityMqttBroker || 'mqtt-us-v1.letsmesh.net', + community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net', + community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443, community_mqtt_email: communityMqttEmail, }; await onSaveAppSettings(update); @@ -113,49 +116,53 @@ export function SettingsMqttSection({ -
- - setMqttBrokerHost(e.target.value)} - /> +
+
+ + setMqttBrokerHost(e.target.value)} + /> +
+ +
+ + setMqttBrokerPort(e.target.value)} + /> +
-
- - setMqttBrokerPort(e.target.value)} - /> -
+
+
+ + setMqttUsername(e.target.value)} + /> +
-
- - setMqttUsername(e.target.value)} - /> -
- -
- - setMqttPassword(e.target.value)} - /> +
+ + setMqttPassword(e.target.value)} + /> +