diff --git a/.env.example b/.env.example index 7d98b77..1cb7232 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ MQTT_HOST=0.0.0.0 # Authentication Settings # Expected audience claim in JWT tokens (e.g., mqtt.yourdomain.com) # Leave empty to skip audience validation +# NOTE: a browser-based publisher sets the JWT audience to the hostname it +# connects to, so this MUST equal that hostname or every such publisher is +# rejected while the broker looks healthy. AUTH_EXPECTED_AUDIENCE=mqtt.yourdomain.com # Where a plain (non-WebSocket) browser request to the broker is 302-redirected. @@ -15,6 +18,15 @@ AUTH_EXPECTED_AUDIENCE=mqtt.yourdomain.com # the front-end. Defaults to https://observers.dutchmeshcore.nl/. HTTP_REDIRECT_URL=https://observers.dutchmeshcore.nl/ +# Non-IATA stream-region labels publishers may use in the topic region slot +# (meshcore/{REGION}/{PUBKEY}/...), in addition to real IATA codes and "test". +# Comma-separated, lowercase. The wardrive collector publishes under the +# "wardriver" (and "hunter") stream label to keep that traffic separable. +# Unset defaults to "wardriver,hunter"; set explicitly (even empty) to override. +# These streams are gated as sensitive on the subscribe side (LIMITED role never +# receives /wardriver/* topics). +PUBLISH_EXTRA_REGIONS=wardriver,hunter + # Subscribe-Only Users (one per line, format: username:password:role:maxConnections) # Role: 1=admin (full access + can delete retained), 2=full_access (no hidden data), 3=limited (filtered data) # Default role is 3 (limited) if not specified diff --git a/src/config.ts b/src/config.ts index e401080..64aa2ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,16 @@ function validateRequiredEnvVars(vars: string[]): void { } } +// parseRegionList parses PUBLISH_EXTRA_REGIONS (comma-separated) into a lowercase +// Set of non-IATA stream-region labels accepted in the topic region slot, in +// addition to real IATA codes and "test". When the var is unset it defaults to +// wardriver,hunter (this is the wardrive-enabled build); set it explicitly (even +// to empty) to override. +function parseRegionList(v: string | undefined): Set { + const raw = v === undefined ? 'wardriver,hunter' : v; + return new Set(raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)); +} + // Validate and load MQTT configuration export function loadMqttConfig() { validateRequiredEnvVars([ @@ -27,6 +37,9 @@ export function loadMqttConfig() { wsPort: parseInt(process.env.MQTT_WS_PORT!), host: process.env.MQTT_HOST!, expectedAudience: process.env.AUTH_EXPECTED_AUDIENCE!, + // Non-IATA stream-region labels (e.g. wardriver, hunter) publishers may use in + // the topic region slot. Gated as sensitive on the subscribe side. + extraPublishRegions: parseRegionList(process.env.PUBLISH_EXTRA_REGIONS), }; } diff --git a/src/server.ts b/src/server.ts index ace8689..4ead0e7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,9 @@ const EXPECTED_AUDIENCE = mqttConfig.expectedAudience; // broker only speaks MQTT-over-WS, so a human hitting the URL is sent to the // front-end. Configurable; defaults to the DMC observers site. const HTTP_REDIRECT_URL = process.env.HTTP_REDIRECT_URL || 'https://observers.dutchmeshcore.nl/'; +// Non-IATA stream-region labels (e.g. wardriver, hunter) accepted in the topic +// region slot alongside real IATA codes and "test". Kept lowercase. +const EXTRA_PUBLISH_REGIONS = mqttConfig.extraPublishRegions; // Helper function to validate IATA airport codes function isValidIATACode(code: string): boolean { @@ -487,9 +490,14 @@ aedes.authorizePublish = (client, packet, callback) => { // Check if this is the special "test" region and normalize it to lowercase const isTestRegion = locationCode.toLowerCase() === 'test'; - - if (isTestRegion) { - console.log(`${logPrefix} [AUTHZ] ✓ Using test region -> ${packet.topic}`); + // Non-IATA stream-region labels (wardriver/hunter) are valid publish regions + // too: they carry sensitive purpose-collected streams that must stay separable + // from regional observer traffic, so they skip IATA validation but keep every + // other check (pubkey match, normalization). Kept lowercase. + const isStreamRegion = !isTestRegion && EXTRA_PUBLISH_REGIONS.has(locationCode.toLowerCase()); + + if (isTestRegion || isStreamRegion) { + console.log(`${logPrefix} [AUTHZ] ✓ Using ${isTestRegion ? 'test' : 'stream'} region -> ${packet.topic}`); // Continue to validation, don't return here } else { // First check format (must be 3 uppercase letters, no normalization) @@ -547,7 +555,11 @@ aedes.authorizePublish = (client, packet, callback) => { // Normalize the topic to UPPERCASE for IATA codes and public key component // This prevents duplicate topics with different casing (e.g., 7553b337... vs 7553B337...) // For the test region, always normalize to lowercase "test" - const normalizedLocation = isTestRegion ? 'test' : locationCode.toUpperCase(); + const normalizedLocation = isTestRegion + ? 'test' + : isStreamRegion + ? locationCode.toLowerCase() + : locationCode.toUpperCase(); const normalizedTopic = `meshcore/${normalizedLocation}/${clientPublicKey}/${topicParts.slice(3).join('/')}`; // Update the packet topic to the normalized version @@ -781,7 +793,16 @@ aedes.authorizeForward = (client, packet) => { return null; // Block delivery of this message } } - + + // Block /wardriver/* topics for LIMITED subscribers. The wardriver stream is + // realtime location of identified operators (sensitive), so only FULL_ACCESS and + // ADMIN receive it; a LIMITED subscriber never does. + if (clientType === ClientType.SUBSCRIBER && role === SubscriberRole.LIMITED) { + if (packet.topic.includes('/wardriver/')) { + return null; // Block delivery of this message + } + } + // Prevent stale status messages from overwriting newer ones (LWT race condition) if (packet.topic.endsWith('/status') && packet.payload && packet.payload.length > 0) { try {