Files
Joachim Hummel 31c0e1f572 Fix MQTT topic pattern for OwnTracks and implement privacy isolation
CRITICAL FIX: The OwnTracks app publishes to owntracks/<username>/<device_id>,
not owntracks/owntrack/<device_id>. This was causing data delivery failures
and privacy violations.

Changes:
- Fix ACL topic pattern: owntracks/<username>/# (was: owntracks/owntrack/<device_id>)
- Backend now uses MQTT_ADMIN_USERNAME for global subscription
- Update UI forms and placeholders with correct pattern
- Update email template with correct topic format
- Enable Mosquitto ACL file for user isolation
- Add migration script for existing ACL rules
- Update documentation (README, GEMINI.md)

Privacy & Security:
- Each user isolated at MQTT broker level via ACL
- Backend subscribes with admin credentials to owntracks/+/+
- Web UI filters data by parent_user_id for additional security
- GDPR compliant multi-layer defense in depth

Files changed:
- lib/mqtt-db.ts - Updated createDefaultRule() to use username
- app/api/mqtt/credentials/route.ts - Pass username to ACL creation
- app/admin/mqtt/page.tsx - UI forms and state management
- emails/mqtt-credentials.tsx - Email template topic pattern
- lib/mqtt-subscriber.ts - Use admin credentials from env
- mosquitto/config/mosquitto.conf - Enable ACL enforcement
- README.md, GEMINI.md - Documentation updates
- scripts/fix-acl-topic-patterns.js - Migration script
- MQTT_TOPIC_FIX.md - Detailed implementation guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 21:49:31 +00:00

362 lines
8.7 KiB
TypeScript

// MQTT credentials and ACL database operations
import { getDb } from './db';
export interface MqttCredential {
id: number;
device_id: string;
mqtt_username: string;
mqtt_password_hash: string;
enabled: number;
created_at: string;
updated_at: string;
}
export interface MqttAclRule {
id: number;
device_id: string;
topic_pattern: string;
permission: 'read' | 'write' | 'readwrite';
created_at: string;
}
export interface MqttSyncStatus {
id: number;
pending_changes: number;
last_sync_at: string | null;
last_sync_status: string;
created_at: string;
updated_at: string;
}
export const mqttCredentialDb = {
/**
* Finde alle MQTT Credentials
*/
findAll: (): MqttCredential[] => {
const db = getDb();
const credentials = db.prepare('SELECT * FROM mqtt_credentials').all() as MqttCredential[];
db.close();
return credentials;
},
/**
* Finde MQTT Credential für ein Device
*/
findByDeviceId: (deviceId: string): MqttCredential | null => {
const db = getDb();
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
.get(deviceId) as MqttCredential | undefined;
db.close();
return credential || null;
},
/**
* Finde MQTT Credential nach Username
*/
findByUsername: (username: string): MqttCredential | null => {
const db = getDb();
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE mqtt_username = ?')
.get(username) as MqttCredential | undefined;
db.close();
return credential || null;
},
/**
* Erstelle neue MQTT Credentials für ein Device
*/
create: (data: {
device_id: string;
mqtt_username: string;
mqtt_password_hash: string;
enabled?: number;
}): MqttCredential => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO mqtt_credentials (device_id, mqtt_username, mqtt_password_hash, enabled)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(
data.device_id,
data.mqtt_username,
data.mqtt_password_hash,
data.enabled ?? 1
);
const created = db.prepare('SELECT * FROM mqtt_credentials WHERE id = ?')
.get(result.lastInsertRowid) as MqttCredential;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return created;
},
/**
* Aktualisiere MQTT Credentials
*/
update: (deviceId: string, data: {
mqtt_password_hash?: string;
enabled?: number;
}): MqttCredential | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.mqtt_password_hash !== undefined) {
updates.push('mqtt_password_hash = ?');
values.push(data.mqtt_password_hash);
}
if (data.enabled !== undefined) {
updates.push('enabled = ?');
values.push(data.enabled);
}
if (updates.length === 0) {
db.close();
return mqttCredentialDb.findByDeviceId(deviceId);
}
updates.push('updated_at = datetime(\'now\')');
values.push(deviceId);
const sql = `UPDATE mqtt_credentials SET ${updates.join(', ')} WHERE device_id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
.get(deviceId) as MqttCredential | undefined;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return updated || null;
},
/**
* Lösche MQTT Credentials für ein Device
*/
delete: (deviceId: string): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_credentials WHERE device_id = ?').run(deviceId);
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return result.changes > 0;
},
/**
* Finde alle aktiven MQTT Credentials (für Mosquitto Sync)
*/
findAllActive: (): MqttCredential[] => {
const db = getDb();
const credentials = db.prepare('SELECT * FROM mqtt_credentials WHERE enabled = 1')
.all() as MqttCredential[];
db.close();
return credentials;
},
};
export const mqttAclRuleDb = {
/**
* Finde alle ACL Regeln für ein Device
*/
findByDeviceId: (deviceId: string): MqttAclRule[] => {
const db = getDb();
const rules = db.prepare('SELECT * FROM mqtt_acl_rules WHERE device_id = ?')
.all(deviceId) as MqttAclRule[];
db.close();
return rules;
},
/**
* Erstelle eine neue ACL Regel
*/
create: (data: {
device_id: string;
topic_pattern: string;
permission: 'read' | 'write' | 'readwrite';
}): MqttAclRule => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO mqtt_acl_rules (device_id, topic_pattern, permission)
VALUES (?, ?, ?)
`);
const result = stmt.run(
data.device_id,
data.topic_pattern,
data.permission
);
const created = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(result.lastInsertRowid) as MqttAclRule;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return created;
},
/**
* Erstelle Default ACL Regel für ein Device (owntracks/[username]/#)
*/
createDefaultRule: (deviceId: string, username: string): MqttAclRule => {
return mqttAclRuleDb.create({
device_id: deviceId,
topic_pattern: `owntracks/${username}/#`,
permission: 'readwrite'
});
},
/**
* Aktualisiere eine ACL Regel
*/
update: (id: number, data: {
topic_pattern?: string;
permission?: 'read' | 'write' | 'readwrite';
}): MqttAclRule | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.topic_pattern !== undefined) {
updates.push('topic_pattern = ?');
values.push(data.topic_pattern);
}
if (data.permission !== undefined) {
updates.push('permission = ?');
values.push(data.permission);
}
if (updates.length === 0) {
db.close();
const existing = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(id) as MqttAclRule | undefined;
return existing || null;
}
values.push(id);
const sql = `UPDATE mqtt_acl_rules SET ${updates.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(id) as MqttAclRule | undefined;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return updated || null;
},
/**
* Lösche eine ACL Regel
*/
delete: (id: number): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE id = ?').run(id);
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return result.changes > 0;
},
/**
* Lösche alle ACL Regeln für ein Device
*/
deleteByDeviceId: (deviceId: string): number => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE device_id = ?').run(deviceId);
// Mark pending changes
if (result.changes > 0) {
mqttSyncStatusDb.markPendingChanges();
}
db.close();
return result.changes;
},
/**
* Finde alle ACL Regeln (für Mosquitto Sync)
*/
findAll: (): MqttAclRule[] => {
const db = getDb();
const rules = db.prepare(`
SELECT acl.* FROM mqtt_acl_rules acl
INNER JOIN mqtt_credentials cred ON acl.device_id = cred.device_id
WHERE cred.enabled = 1
`).all() as MqttAclRule[];
db.close();
return rules;
},
};
export const mqttSyncStatusDb = {
/**
* Hole den aktuellen Sync Status
*/
get: (): MqttSyncStatus | null => {
const db = getDb();
const status = db.prepare('SELECT * FROM mqtt_sync_status WHERE id = 1')
.get() as MqttSyncStatus | undefined;
db.close();
return status || null;
},
/**
* Markiere dass es ausstehende Änderungen gibt
*/
markPendingChanges: (): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET pending_changes = pending_changes + 1,
updated_at = datetime('now')
WHERE id = 1
`).run();
db.close();
},
/**
* Markiere erfolgreichen Sync
*/
markSynced: (): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET pending_changes = 0,
last_sync_at = datetime('now'),
last_sync_status = 'success',
updated_at = datetime('now')
WHERE id = 1
`).run();
db.close();
},
/**
* Markiere fehlgeschlagenen Sync
*/
markSyncFailed: (error: string): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET last_sync_at = datetime('now'),
last_sync_status = ?,
updated_at = datetime('now')
WHERE id = 1
`).run(`error: ${error}`);
db.close();
},
};