Split up community broker fields and reformat MQTT config page

This commit is contained in:
Jack Kingsman
2026-03-02 12:35:15 -08:00
parent 2581cc6af7
commit 95bacc4caf
14 changed files with 135 additions and 122 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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]:

View File

@@ -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 ''"),
]

View File

@@ -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)",

View File

@@ -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 = ?")

View File

@@ -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:

View File

@@ -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({
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
@@ -278,15 +285,29 @@ export function SettingsMqttSection({
{communityMqttEnabled && (
<div className="space-y-2">
<Label htmlFor="community-broker">Broker Address</Label>
<Input
id="community-broker"
type="text"
placeholder="mqtt-us-v1.letsmesh.net:443"
value={communityMqttBroker}
onChange={(e) => setCommunityMqttBroker(e.target.value)}
/>
<p className="text-xs text-muted-foreground">host or host:port (default port 443)</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="community-broker-host">Broker Host</Label>
<Input
id="community-broker-host"
type="text"
placeholder="mqtt-us-v1.letsmesh.net"
value={communityMqttBrokerHost}
onChange={(e) => setCommunityMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="community-broker-port">Broker Port</Label>
<Input
id="community-broker-port"
type="number"
min="1"
max="65535"
value={communityMqttBrokerPort}
onChange={(e) => setCommunityMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<Label htmlFor="community-iata">Region Code (IATA)</Label>
<Input
id="community-iata"

View File

@@ -63,7 +63,8 @@ const baseSettings: AppSettings = {
mqtt_publish_raw_packets: false,
community_mqtt_enabled: false,
community_mqtt_iata: '',
community_mqtt_broker: 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_host: 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port: 443,
community_mqtt_email: '',
};

View File

@@ -195,7 +195,8 @@ export interface AppSettings {
mqtt_publish_raw_packets: boolean;
community_mqtt_enabled: boolean;
community_mqtt_iata: string;
community_mqtt_broker: string;
community_mqtt_broker_host: string;
community_mqtt_broker_port: number;
community_mqtt_email: string;
}
@@ -216,7 +217,8 @@ export interface AppSettingsUpdate {
mqtt_publish_raw_packets?: boolean;
community_mqtt_enabled?: boolean;
community_mqtt_iata?: string;
community_mqtt_broker?: string;
community_mqtt_broker_host?: string;
community_mqtt_broker_port?: number;
community_mqtt_email?: string;
}

View File

@@ -9,14 +9,12 @@ import pytest
from app.community_mqtt import (
_CLIENT_ID,
_DEFAULT_BROKER,
_DEFAULT_PORT,
CommunityMqttPublisher,
_base64url_encode,
_calculate_packet_hash,
_ed25519_sign_expanded,
_format_raw_packet,
_generate_jwt_token,
_parse_broker_address,
community_mqtt_broadcast,
)
from app.models import AppSettings
@@ -423,33 +421,6 @@ class TestCommunityMqttBroadcast:
mock_task.assert_not_called()
class TestParseBrokerAddress:
def test_hostname_only_uses_default_port(self):
host, port = _parse_broker_address("mqtt-us-v1.letsmesh.net")
assert host == "mqtt-us-v1.letsmesh.net"
assert port == _DEFAULT_PORT
def test_hostname_with_port(self):
host, port = _parse_broker_address("mqtt-us-v1.letsmesh.net:8883")
assert host == "mqtt-us-v1.letsmesh.net"
assert port == 8883
def test_hostname_with_port_443(self):
host, port = _parse_broker_address("broker.example.com:443")
assert host == "broker.example.com"
assert port == 443
def test_invalid_port_uses_default(self):
host, port = _parse_broker_address("broker.example.com:abc")
assert host == "broker.example.com:abc"
assert port == _DEFAULT_PORT
def test_empty_string(self):
host, port = _parse_broker_address("")
assert host == ""
assert port == _DEFAULT_PORT
class TestPublishFailureSetsDisconnected:
@pytest.mark.asyncio
async def test_publish_error_sets_connected_false(self):

View File

@@ -929,13 +929,15 @@ class TestMigration032:
# Verify all columns exist with correct defaults
cursor = await conn.execute(
"""SELECT 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"""
)
row = await cursor.fetchone()
assert row["community_mqtt_enabled"] == 0
assert row["community_mqtt_iata"] == ""
assert row["community_mqtt_broker"] == "mqtt-us-v1.letsmesh.net"
assert row["community_mqtt_broker_host"] == "mqtt-us-v1.letsmesh.net"
assert row["community_mqtt_broker_port"] == 443
assert row["community_mqtt_email"] == ""
finally:
await conn.close()

View File

@@ -504,7 +504,8 @@ class TestAppSettingsRepository:
"mqtt_publish_raw_packets": 0,
"community_mqtt_enabled": 0,
"community_mqtt_iata": "",
"community_mqtt_broker": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_host": "mqtt-us-v1.letsmesh.net",
"community_mqtt_broker_port": 443,
"community_mqtt_email": "",
}
)

View File

@@ -126,21 +126,24 @@ class TestUpdateSettings:
AppSettingsUpdate(
community_mqtt_enabled=True,
community_mqtt_iata="DEN",
community_mqtt_broker="custom-broker.example.com",
community_mqtt_broker_host="custom-broker.example.com",
community_mqtt_broker_port=8883,
community_mqtt_email="test@example.com",
)
)
assert result.community_mqtt_enabled is True
assert result.community_mqtt_iata == "DEN"
assert result.community_mqtt_broker == "custom-broker.example.com"
assert result.community_mqtt_broker_host == "custom-broker.example.com"
assert result.community_mqtt_broker_port == 8883
assert result.community_mqtt_email == "test@example.com"
# Verify persistence
fresh = await AppSettingsRepository.get()
assert fresh.community_mqtt_enabled is True
assert fresh.community_mqtt_iata == "DEN"
assert fresh.community_mqtt_broker == "custom-broker.example.com"
assert fresh.community_mqtt_broker_host == "custom-broker.example.com"
assert fresh.community_mqtt_broker_port == 8883
assert fresh.community_mqtt_email == "test@example.com"
# Verify restart was called