Files
kestrelos/server/utils/cotParser.js
Madison Grubb b0e8dd7ad9
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
make kestrel a tak server, so that it can send and receive pois as cots data
2026-02-17 10:42:53 -05:00

143 lines
5.2 KiB
JavaScript

import { XMLParser } from 'fast-xml-parser'
import { MAX_PAYLOAD_BYTES } from './constants.js'
const TAK_MAGIC = 0xBF
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
/**
* @param {Buffer} buf
* @param {number} offset
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
*/
function readVarint(buf, offset) {
let value = 0
let shift = 0
let bytesRead = 0
while (offset + bytesRead < buf.length) {
const b = buf[offset + bytesRead]
bytesRead += 1
value += (b & 0x7F) << shift
if ((b & 0x80) === 0) return { value, bytesRead }
shift += 7
if (shift > 28) return { value: 0, bytesRead: 0 }
}
return { value, bytesRead }
}
/**
* TAK stream frame: 0xBF, varint length, payload.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
*/
export function parseTakStreamFrame(buf) {
if (!buf || buf.length < 2 || buf[0] !== TAK_MAGIC) return null
const { value: length, bytesRead } = readVarint(buf, 1)
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
const bytesConsumed = 1 + bytesRead + length
if (buf.length < bytesConsumed) return null
return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed }
}
/**
* Traditional CoT: one XML message delimited by </event>.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
*/
export function parseTraditionalXmlFrame(buf) {
if (!buf || buf.length < 8 || buf[0] !== 0x3C) return null
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
if (idx === -1) return null
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
if (bytesConsumed > MAX_PAYLOAD_BYTES) return null
return { payload: buf.subarray(0, bytesConsumed), bytesConsumed }
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
ignoreDeclaration: true,
ignorePiTags: true,
processEntities: false, // Disable entity expansion to prevent XML bomb attacks
maxAttributes: 100,
parseAttributeValue: false,
trimValues: true,
parseTrueNumberOnly: false,
arrayMode: false,
stopNodes: [], // Could add depth limit here if needed
})
/**
* Case-insensitive key lookup in nested object.
* @returns {unknown} Found value or undefined.
*/
function findInObject(obj, key) {
if (!obj || typeof obj !== 'object') return undefined
const k = key.toLowerCase()
for (const [name, val] of Object.entries(obj)) {
if (name.toLowerCase() === k) return val
if (typeof val === 'object' && val !== null) {
const found = findInObject(val, key)
if (found !== undefined) return found
}
}
return undefined
}
/**
* Extract { username, password } from detail.auth (or __auth / credentials).
* @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid.
*/
function extractAuth(parsed) {
const detail = findInObject(parsed, 'detail')
if (!detail || typeof detail !== 'object') return null
const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials')
if (!auth || typeof auth !== 'object') return null
const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username
const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password
if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null
return { username: username.trim(), password }
}
/**
* Parse CoT XML payload into auth or position. Does not mutate payload.
* @param {Buffer} payload - UTF-8 XML
* @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null.
*/
export function parseCotPayload(payload) {
if (!payload?.length) return null
const str = payload.toString('utf8').trim()
if (!str.startsWith('<')) return null
try {
const parsed = xmlParser.parse(str)
const event = findInObject(parsed, 'event')
if (!event || typeof event !== 'object') return null
const auth = extractAuth(parsed)
if (auth) return { type: 'auth', username: auth.username, password: auth.password }
const uid = String(event['@_uid'] ?? event.uid ?? '')
const eventType = String(event['@_type'] ?? event.type ?? '')
const point = findInObject(parsed, 'point') ?? findInObject(event, 'point')
let lat = Number.NaN
let lng = Number.NaN
if (point && typeof point === 'object') {
lat = Number(point['@_lat'] ?? point.lat)
lng = Number(point['@_lon'] ?? point.lon ?? point['@_lng'] ?? point.lng)
}
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const detail = findInObject(parsed, 'detail')
const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null
const callsign = contact && typeof contact === 'object'
? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign'])
: ''
const label = typeof callsign === 'string' ? callsign.trim() || uid : uid
return { type: 'cot', id: uid, lat, lng, label, eventType }
}
catch {
return null
}
}