Files
kestrelos/app/components/LiveSessionPanel.vue
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

325 lines
10 KiB
Vue

<template>
<aside
class="kestrel-panel-base"
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog"
aria-label="Live feed"
>
<div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ session?.label ?? 'Live' }}
</h2>
<button
type="button"
class="kestrel-close-btn"
aria-label="Close panel"
@click="$emit('close')"
>
<span class="text-xl leading-none">&times;</span>
</button>
</div>
<div class="flex flex-1 flex-col overflow-auto p-4">
<p class="mb-3 text-xs text-kestrel-muted">
Live camera feed (WebRTC)
</p>
<div class="kestrel-video-frame">
<video
ref="videoRef"
autoplay
playsinline
class="h-full w-full object-contain"
/>
<div
v-if="!hasStream && !error"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-xs uppercase tracking-wider text-kestrel-muted"
>
<span>Waiting for stream</span>
<span
v-if="connectionState === 'connecting'"
class="normal-case"
>WebRTC: connecting</span>
<template v-if="connectionState === 'failed'">
<span class="normal-case font-medium text-red-400">WebRTC: failed</span>
<p
v-if="failureReason?.wrongHost"
class="normal-case text-left text-amber-400"
>
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP 40000-49999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use.</li>
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
</ul>
</template>
</div>
<div
v-if="error"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-auto p-4 text-center text-xs uppercase tracking-wider text-red-400"
>
<span>Feed unavailable: {{ error }}</span>
<template v-if="connectionState === 'failed' && failureReason">
<p
v-if="failureReason.wrongHost"
class="normal-case text-left text-amber-400"
>
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
</p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li>Firewall: open ports 40000-49999.</li>
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
<li>Restrictive NAT: TURN may be required.</li>
</ul>
</template>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { createMediasoupDevice, createRecvTransport, consumeProducer, waitForConnectionState } from '~/composables/useWebRTC.js'
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
import { initLogger, logError, logWarn } from '~/utils/logger.js'
import { useUser } from '~/composables/useUser.js'
const { user } = useUser()
const props = defineProps({
session: {
type: Object,
default: null,
},
/** When true, render inline (e.g. on Cameras page) instead of overlay panel */
inline: {
type: Boolean,
default: false,
},
})
defineEmits(['close'])
const videoRef = ref(null)
const hasStream = ref(false)
const error = ref('')
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
const device = ref(null)
const recvTransport = ref(null)
const consumer = ref(null)
async function runFailureReasonCheck() {
failureReason.value = await getWebRTCFailureReason()
}
async function setupWebRTC() {
if (!props.session?.id || !props.session?.hasStream) {
logWarn('LiveSessionPanel: Cannot setup WebRTC', {
hasSession: !!props.session,
sessionId: props.session?.id,
hasStream: props.session?.hasStream,
})
error.value = 'No stream available'
return
}
// Initialize logger with session and user context
initLogger(props.session.id, user.value?.id)
try {
error.value = ''
hasStream.value = false
connectionState.value = 'connecting'
failureReason.value = null
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
credentials: 'include',
})
device.value = await createMediasoupDevice(rtpCapabilities)
recvTransport.value = await createRecvTransport(device.value, props.session.id)
recvTransport.value.on('connectionstatechange', () => {
const state = recvTransport.value.connectionState
if (state === 'connected') connectionState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('LiveSessionPanel: Receive transport connection state changed', {
state,
transportId: recvTransport.value.id,
sessionId: props.session.id,
})
if (state === 'failed') {
connectionState.value = 'failed'
runFailureReasonCheck()
}
}
})
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') {
connectionState.value = 'failed'
runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState,
transportId: recvTransport.value.id,
consumerId: consumer.value.id,
})
}
else {
connectionState.value = 'connected'
}
// 4. Attach video track to video element
// Wait for video ref to be available (nextTick ensures DOM is updated)
await nextTick()
// Retry logic: wait for videoRef to become available
let attempts = 0
while (!videoRef.value && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 100))
attempts++
}
if (!consumer.value.track) {
logError('LiveSessionPanel: No video track available', {
consumerId: consumer.value.id,
consumerKind: consumer.value.kind,
consumerPaused: consumer.value.paused,
consumerClosed: consumer.value.closed,
consumerProducerId: consumer.value.producerId,
transportConnectionState: recvTransport.value?.connectionState,
})
error.value = 'No video track available - consumer may not be receiving data from producer'
return
}
if (!videoRef.value) {
logError('LiveSessionPanel: Video ref not available', {
consumerId: consumer.value.id,
hasTrack: !!consumer.value.track,
})
error.value = 'Video element not available'
return
}
const stream = new MediaStream([consumer.value.track])
videoRef.value.srcObject = stream
hasStream.value = true
// Wait for video metadata to load (indicates video is actually receiving data)
const metadataPromise = new Promise((resolve) => {
if (videoRef.value.readyState >= 2) {
resolve()
return
}
let resolved = false
const handler = () => {
if (resolved) return
resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler)
if (metadataTimeoutId) clearTimeout(metadataTimeoutId)
resolve()
}
videoRef.value.addEventListener('loadedmetadata', handler)
const metadataTimeoutId = setTimeout(() => {
if (resolved) return
resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler)
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
resolve()
}, 5000)
})
await metadataPromise
try {
const playPromise = videoRef.value.play()
if (playPromise !== undefined) await playPromise
}
catch (playErr) {
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
consumerId: consumer.value.id,
error: playErr.message || String(playErr),
errorName: playErr.name,
videoPaused: videoRef.value.paused,
videoReadyState: videoRef.value.readyState,
})
// Don't set error - video might still work, just needs user interaction
}
consumer.value.track.addEventListener('ended', () => {
error.value = 'Video track ended'
hasStream.value = false
})
videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
})
}
catch (err) {
connectionState.value = 'failed'
runFailureReasonCheck()
logError('LiveSessionPanel: WebRTC setup error', {
err: err.message || String(err),
data: err.data,
stack: err.stack,
status: err.status,
statusCode: err.statusCode,
sessionId: props.session?.id,
})
const errorMsg = err.data?.message || err.message || err.toString() || 'Failed to connect to stream'
error.value = errorMsg
cleanup()
}
}
function cleanup() {
if (consumer.value) {
consumer.value.close()
consumer.value = null
}
if (recvTransport.value) {
recvTransport.value.close()
recvTransport.value = null
}
device.value = null
if (videoRef.value) {
videoRef.value.srcObject = null
}
hasStream.value = false
connectionState.value = ''
failureReason.value = null
}
watch(
() => props.session?.id,
(id) => {
cleanup()
if (id && props.session?.hasStream) {
setupWebRTC()
}
else {
error.value = id ? 'No stream available' : ''
}
},
{ immediate: true },
)
watch(
() => props.session?.hasStream,
(hasStream) => {
if (hasStream && props.session?.id && !device.value) {
setupWebRTC()
}
else if (!hasStream) {
cleanup()
error.value = 'Stream ended'
}
},
)
onBeforeUnmount(() => {
cleanup()
})
</script>