Files
kestrelos/app/composables/useWebRTC.js
Keli Grubb e61e6bc7e3
All checks were successful
ci/woodpecker/push/push Pipeline was successful
major: kestrel is now a tak server (#6)
## 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
2026-02-17 16:41:41 +00:00

206 lines
6.5 KiB
JavaScript

/** WebRTC/Mediasoup client utilities. */
import { logError, logWarn } from '../utils/logger.js'
const FETCH_OPTS = { credentials: 'include' }
export async function createMediasoupDevice(rtpCapabilities) {
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser')
const { Device } = await import('mediasoup-client')
const device = new Device()
await device.load({ routerRtpCapabilities: rtpCapabilities })
return device
}
export function createWebSocketConnection(url) {
return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl)
ws.onopen = () => resolve(ws)
ws.onerror = () => reject(new Error('WebSocket connection failed'))
})
}
export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not open'))
return
}
const messageId = `${Date.now()}-${Math.random()}`
const message = { sessionId, type, data, messageId }
const timeout = setTimeout(() => {
ws.removeEventListener('message', handler)
reject(new Error('WebSocket message timeout'))
}, 10000)
const handler = (event) => {
try {
const response = JSON.parse(event.data)
if (response.messageId === messageId || response.type) {
clearTimeout(timeout)
ws.removeEventListener('message', handler)
if (response.error) {
reject(new Error(response.error))
}
else {
resolve(response)
}
}
}
catch {
// Not our message, continue waiting
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify(message))
})
}
function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) {
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters },
...FETCH_OPTS,
})
onConnectSuccess?.()
callback()
}
catch (err) {
logError(`useWebRTC: ${label} transport connect failed`, {
err: err?.message ?? String(err),
transportId: transportParams.id,
connectionState: transport.connectionState,
sessionId,
})
onConnectFailure?.(err)
errback(err)
}
})
transport.on('connectionstatechange', () => {
const state = transport.connectionState
if (['failed', 'disconnected', 'closed'].includes(state)) {
logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
}
})
}
export async function createSendTransport(device, sessionId, options = {}) {
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
...FETCH_OPTS,
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
attachTransportHandlers(transport, transportParams, sessionId, 'Send', options)
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
try {
const { id } = await $fetch('/api/live/webrtc/create-producer', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
...FETCH_OPTS,
})
callback({ id })
}
catch (err) {
logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) })
errback(err)
}
})
return transport
}
export async function createRecvTransport(device, sessionId) {
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: false },
...FETCH_OPTS,
})
const transport = device.createRecvTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
return transport
}
export async function consumeProducer(transport, device, sessionId) {
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
method: 'POST',
body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
...FETCH_OPTS,
})
const consumer = await transport.consume({
id: consumerParams.id,
producerId: consumerParams.producerId,
kind: consumerParams.kind,
rtpParameters: consumerParams.rtpParameters,
})
if (!consumer.track) {
logWarn('useWebRTC: Consumer created but no track immediately', { consumerId: consumer.id })
await waitForCondition(() => consumer.track, 3000, 100)
if (!consumer.track) {
logError('useWebRTC: Track did not become available after 3s', { consumerId: consumer.id })
}
}
return consumer
}
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
clearInterval(intervalId)
resolve()
}, timeoutMs)
const intervalId = setInterval(() => {
if (condition()) {
clearTimeout(timeoutId)
clearInterval(intervalId)
resolve()
}
}, intervalMs)
if (condition()) {
clearTimeout(timeoutId)
clearInterval(intervalId)
resolve()
}
})
}
export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => {
const tid = ref(null)
const handler = () => {
const state = transport.connectionState
if (terminal.includes(state)) {
transport.off('connectionstatechange', handler)
if (tid.value) clearTimeout(tid.value)
resolve(state)
}
}
transport.on('connectionstatechange', handler)
handler()
tid.value = setTimeout(() => {
transport.off('connectionstatechange', handler)
resolve(transport.connectionState)
}, timeoutMs)
})
}