All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added - CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity - Support for TAK stream protocol and traditional XML CoT messages - TLS/SSL support with automatic fallback to plain TCP - Username/password authentication for CoT connections - Real-time device position tracking with TTL-based expiration (90s default) - API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password` - TAK Server section in Settings with QR code for iTAK setup - ATAK password management in Account page for OIDC users - CoT device markers on map showing real-time positions - Comprehensive documentation in `docs/` directory - Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG` - Dependencies: `fast-xml-parser`, `jszip`, `qrcode` ## Changed - Authentication system supports CoT password management for OIDC users - Database schema includes `cot_password_hash` field - Test suite refactored to follow functional design principles ## Removed - Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js` ## Security - XML entity expansion protection in CoT parser - Enhanced input validation and SQL injection prevention - Authentication timeout to prevent hanging connections ## Breaking Changes - Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations. ## Migration Notes - OIDC users must set ATAK password via Account settings before connecting - Docker: expose port 8089 (`-p 8089:8089`) - Kubernetes: update Helm values to expose port 8089 Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #6
152 lines
5.8 KiB
JavaScript
152 lines
5.8 KiB
JavaScript
import { XMLParser } from 'fast-xml-parser'
|
|
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
|
|
|
// CoT protocol detection constants
|
|
export const COT_FIRST_BYTE_TAK = 0xBF
|
|
export const COT_FIRST_BYTE_XML = 0x3C
|
|
|
|
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
|
export function isCotFirstByte(byte) {
|
|
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
|
}
|
|
|
|
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
|
|
|
/**
|
|
* @param {Buffer} buf
|
|
* @param {number} offset
|
|
* @param {number} value - Accumulated value
|
|
* @param {number} shift - Current bit shift
|
|
* @param {number} bytesRead - Bytes consumed so far
|
|
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
|
|
*/
|
|
function readVarint(buf, offset, value = 0, shift = 0, bytesRead = 0) {
|
|
if (offset + bytesRead >= buf.length) return { value, bytesRead }
|
|
const b = buf[offset + bytesRead]
|
|
const newValue = value + ((b & 0x7F) << shift)
|
|
const newBytesRead = bytesRead + 1
|
|
if ((b & 0x80) === 0) return { value: newValue, bytesRead: newBytesRead }
|
|
const newShift = shift + 7
|
|
if (newShift > 28) return { value: 0, bytesRead: 0 }
|
|
return readVarint(buf, offset, newValue, newShift, newBytesRead)
|
|
}
|
|
|
|
/**
|
|
* 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] !== COT_FIRST_BYTE_TAK) 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] !== COT_FIRST_BYTE_XML) 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')
|
|
const extractCoords = (pt) => {
|
|
if (!pt || typeof pt !== 'object') return { lat: Number.NaN, lng: Number.NaN }
|
|
return {
|
|
lat: Number(pt['@_lat'] ?? pt.lat),
|
|
lng: Number(pt['@_lon'] ?? pt.lon ?? pt['@_lng'] ?? pt.lng),
|
|
}
|
|
}
|
|
const { lat, lng } = extractCoords(point)
|
|
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
|
|
}
|
|
}
|