// mdns-sync.js // A single-file mDNS sync tool acting as client or server // Uses mdns-server for mDNS and Express for HTTP /********************************* * * 1. LIBRARY FUNCTIONS * *********************************/ const os = require('os'); const mysql = require('mysql2/promise'); // 0. HELPER FUNCTIONS // Extract password from ~/.ssh/id_rsa.pub (last 6 characters of base64) function getDbPassword() { const pubKeyContent = fs.readFileSync(`${os.homedir()}/.ssh/id_rsa.pub`, "utf8"); const base64Part = pubKeyContent.split(" ")[1]; return base64Part.slice(-7,-1); } // Insert data into MariaDB async function insertToMariaDB(record) { if (!['A', 'PTR', 'SRV'].includes(record.Type)) return; const dataStr = typeof record.Data === 'object' ? JSON.stringify(record.Data) : record.Data; try { await dbPool.execute( `REPLACE INTO ${DB_TABLE} (Host, Type, Name, Data) VALUES (?, ?, ?, ?);`, [DB_USER, record.Type, record.Name, dataStr] ); } catch (err) { console.error("DB Insert Error:", err); } } // 1. Database functions function saveDbToFile(data, filename) { return fs.writeFileSync(filename,typeof data === 'object'?JSON.stringify(data):data); } function loadDbFromFile(filename) {if (!fs.existsSync(filename)) return {local:{},remote:{}}; else return JSON.parse(fs.readFileSync(filename));} // 2. Network functions function getLocalNetworkAddressesIPs() { const interfaces = os.networkInterfaces(); const addresses = []; for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name]) { if (iface.family === 'IPv4' && !iface.internal) { addresses.push(iface.address); } } } return addresses; } function sniffmDNSLocalPackets() { // 1. Listen for responses mdns.on('response', async function(response) { // console.log("got a new mDNS response.",response); for (let k in response.answers) { let answer = response.answers[k]; // 1. Handle DEVICES for a given SERVICE if (answer.type == "PTR") { // 1.1 Filter by RELEVANT SERVICE TYPES if (!["in-addr.arpa","_googlecast._tcp.local","_tcp.local"].some(suffix => answer.name.endsWith(suffix))) continue; // 1.2 Initialize POINTERS (services) sub-database if (!db.local.PTR) db.local.PTR = {}; // 1.3 Initialize this SERVICE TYPE (e.g. _googlecast._tcp.local), if needed if (!db.local.PTR[answer.name]) { db.local.PTR[answer.name] = [answer.data]; } else { // Add a new DEVICE to this SERVICE TYPE if needed if (!db.local.PTR[answer.name].includes(answer.data)) db.local.PTR[answer.name].push(answer.data); } } // 2. Handle DEVICE IP resolution if (answer.type == "A") { // 2.1 Initialize ADDRESSES sub-database if (!db.local.A) db.local.A = {}; db.local.A[answer.name] = answer.data; } if (["TXT","SRV"].includes(answer.type)) { // Initialize this device / entry, if needed if (db.local[answer.name] === undefined) db.local[answer.name] = {"TXT":null,"SRV":null}; db.local[answer.name][answer.type] = answer.data; } await insertToMariaDB({ Type: answer.type, Name: answer.name, Data: answer.data }); saveDbToFile(db,DB_FILENAME); } }) // 2. Handle the server being destroyed mdns.on('destroyed', function () {console.log('Server destroyed.');process.exit(0);}); // 3. Handle the onReady event mdns.on('ready', function () { console.log("mDNS server is ready..."); // mdns.query({questions:[{ name: '_:googlecast._tcp.local', type: 'PTR', class: 'IN'}]} ); }) } function injectmDNSPacketLocally(type,name,data,flush = true,_class = 'IN',ttl = 120) { console.log(`sending packet ${name} of ${type} with data: ${data}`); mdns.respond({answers:[ { name: name, type: type, ttl: ttl, class: _class, flush: flush, data: data } ]});} function replyLocallyWithRemoteDevicesData(name,type,query) { const answers = []; if (!db.remote) db.remote = {}; if (!db.remote.PTR) db.remote.PTR = {}; if (!db.remote.A) db.remote.A = {}; // 1. If the query is asking for a PTR (pointer to a service) if (type === 'PTR' && db.remote.PTR && db.remote.PTR[name]) { for (let ptrData of db.remote.PTR[name]) { answers.push({ name, // The service name being queried type: 'PTR', // Type of DNS record class: 'IN', // Internet class ttl: 120, // Time-to-live (how long to cache) data: ptrData // The actual pointer data (e.g. device instance name) }); } } // 2. If the query is for an A record (IPv4 address of a device) if (type === 'A' && db.remote.A && db.remote.A[name]) { answers.push({ name, type: 'A', class: 'IN', ttl: 120, data: db.remote.A[name] // The IPv4 address }); } // 3. If the query is for an SRV record (hostname + port of a service) if (type === 'SRV' && db.remote[name] && db.remote[name].SRV !== null) { answers.push({ name, type: 'SRV', class: 'IN', ttl: 120, data: db.remote[name].SRV // Must be an object like { port, target, priority, weight } }); } // 4. If the query is for a TXT record (extra metadata) if (type === 'TXT' && db.remote[name] && db.remote[name].TXT !== null) { answers.push({ name, type: 'TXT', class: 'IN', ttl: 120, // Convert plain string to buffer; required by mdns-server data: Buffer.from(db.remote[name].TXT, 'utf8') }); } // 5. Many mDNS tools (like Avahi or Bonjour) send type: 'ANY' queries to discover all records for a name. if (type === 'ANY') { // Respond with everything you know about this name if (db.remote.PTR && db.remote.PTR[name]) { for (let ptrData of db.remote.PTR[name]) { answers.push({ name, type: 'PTR', class: 'IN', ttl: 120, data: ptrData }); } } if (db.remote.A && db.remote.A[name]) { answers.push({ name, type: 'A', class: 'IN', ttl: 120, data: db.remote.A[name] }); } if (db.remote.services[name]) { const r = db.remote.services[name]; if (r.SRV) answers.push({ name, type: 'SRV', class: 'IN', ttl: 120, data: r.SRV }); if (r.TXT) answers.push({ name, type: 'TXT', class: 'IN', ttl: 120, data: Buffer.from(r.TXT, 'utf8') }); } } // 6. If we prepared any answers, respond to the query if (answers.length > 0) { console.log(`Responding with ${answers.length} answer(s) for ${name}`); mdns.respond({ answers }); } } async function fetchRemoteDevicesData() { try { const [rows] = await dbPool.execute( `SELECT Type, Name, Data FROM ${DB_TABLE} WHERE Host <> ?`, [DB_USER] ); db.remote = { A: {}, PTR: {}, services: {} }; rows.forEach(row => { const { Type, Name, Data } = row; if (Type === 'A') { db.remote.A[Name] = Data; } else if (Type === 'PTR') { if (!db.remote.PTR[Name]) db.remote.PTR[Name] = []; if (!db.remote.PTR[Name].includes(Data)) db.remote.PTR[Name].push(Data); } else if (['TXT', 'SRV'].includes(Type)) { if (!db.remote.services[Name]) db.remote.services[Name] = { TXT: null, SRV: null }; db.remote.services[Name][Type] = Data; } }); console.log(`Remote DB updated. A records: ${Object.keys(db.remote.A).length} PTR records: ${Object.keys(db.remote.PTR).length}`); } catch (err) { console.error('Error fetching remote DB:', err); } } /********************************* * * 100. MAIN / IMPERATIVE CODE * *********************************/ const fs = require("fs"); const DB_FILENAME = 'database.json'; const db = loadDbFromFile(DB_FILENAME); const LOCAL_IP_ADDR = getLocalNetworkAddressesIPs()[0]; const mdns = require('mdns-server')({interface: LOCAL_IP_ADDR,reuseAddr: true,loopback: false,noInit: true}); // MariaDB Configuration const DB_HOST = "10.10.8.1"; const DB_NAME = "NETWORK"; const DB_TABLE = "mDNS"; const DB_USER = os.hostname(); // Initialize MariaDB connection pool const dbPool = mysql.createPool({ host: DB_HOST, user: DB_USER, password: getDbPassword(), database: DB_NAME, connectionLimit: 5 }); sniffmDNSLocalPackets(); mdns.on('query', function (query) { // Loop through each question in the mDNS query query.questions.forEach(q => { const { name, type } = q; console.log(`Received mDNS query for ${name} (${type})`); replyLocallyWithRemoteDevicesData(name,type,q); }); }); // every 15 seconds setInterval(fetchRemoteDevicesData, 15000); fetchRemoteDevicesData(); // initial immediate call // initialize the server now that we are watching for events mdns.initServer()