Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ 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.
# This broker only speaks MQTT-over-WS, so a human who opens the URL is sent to
# 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
Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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([
Expand All @@ -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),
};
}

Expand Down
31 changes: 26 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading