major: kestrel is now a tak server (#6)
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
This commit was merged in pull request #6.
This commit is contained in:
2026-02-17 16:41:41 +00:00
parent b18283d3b3
commit e61e6bc7e3
117 changed files with 5329 additions and 1040 deletions

View File

@@ -66,6 +66,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotEntities: {
type: Array,
default: () => [],
},
canEditPois: {
type: Boolean,
default: false,
@@ -81,6 +85,7 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -89,6 +94,7 @@ const addPoiLatlng = ref(null)
const editPoi = ref(null)
const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' })
const resizeObserver = ref(null)
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd'
@@ -124,7 +130,7 @@ function getPoiIcon(L, poi) {
})
}
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent JS string for Leaflet SVG */
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */
function getLiveSessionIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
return L.divIcon({
@@ -135,6 +141,17 @@ function getLiveSessionIcon(L) {
})
}
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
@@ -201,6 +218,7 @@ function createMap(initialCenter) {
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
}
@@ -291,6 +309,39 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateCotMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
}
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
@@ -376,6 +427,8 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
const ctx = mapContext.value
if (ctx) {
@@ -404,8 +457,6 @@ function initMapWithLocation() {
)
}
let resizeObserver = null
onMounted(async () => {
if (!import.meta.client || typeof document === 'undefined') return
const [leaflet, offline] = await Promise.all([
@@ -428,10 +479,10 @@ onMounted(async () => {
nextTick(() => {
if (mapRef.value) {
resizeObserver = new ResizeObserver(() => {
resizeObserver.value = new ResizeObserver(() => {
mapContext.value?.map?.invalidateSize()
})
resizeObserver.observe(mapRef.value)
resizeObserver.value.observe(mapRef.value)
}
})
})
@@ -442,9 +493,9 @@ function onDocumentClick(e) {
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
if (resizeObserver && mapRef.value) {
resizeObserver.disconnect()
resizeObserver = null
if (resizeObserver.value && mapRef.value) {
resizeObserver.value.disconnect()
resizeObserver.value = null
}
destroyMap()
})
@@ -452,4 +503,5 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
</script>

View File

@@ -47,7 +47,7 @@
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 4000049999 on the server.</li>
<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>
@@ -66,7 +66,7 @@
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 4000049999.</li>
<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>
@@ -104,9 +104,9 @@ const hasStream = ref(false)
const error = ref('')
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let device = null
let recvTransport = null
let consumer = null
const device = ref(null)
const recvTransport = ref(null)
const consumer = ref(null)
async function runFailureReasonCheck() {
failureReason.value = await getWebRTCFailureReason()
@@ -135,16 +135,16 @@ async function setupWebRTC() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
recvTransport = await createRecvTransport(device, props.session.id)
device.value = await createMediasoupDevice(rtpCapabilities)
recvTransport.value = await createRecvTransport(device.value, props.session.id)
recvTransport.on('connectionstatechange', () => {
const state = recvTransport.connectionState
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.id,
transportId: recvTransport.value.id,
sessionId: props.session.id,
})
if (state === 'failed') {
@@ -154,8 +154,8 @@ async function setupWebRTC() {
}
})
const connectionPromise = waitForConnectionState(recvTransport, 10000)
consumer = await consumeProducer(recvTransport, device, props.session.id)
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') {
@@ -163,8 +163,8 @@ async function setupWebRTC() {
runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState,
transportId: recvTransport.id,
consumerId: consumer.id,
transportId: recvTransport.value.id,
consumerId: consumer.value.id,
})
}
else {
@@ -182,14 +182,14 @@ async function setupWebRTC() {
attempts++
}
if (!consumer.track) {
if (!consumer.value.track) {
logError('LiveSessionPanel: No video track available', {
consumerId: consumer.id,
consumerKind: consumer.kind,
consumerPaused: consumer.paused,
consumerClosed: consumer.closed,
consumerProducerId: consumer.producerId,
transportConnectionState: recvTransport?.connectionState,
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
@@ -197,14 +197,14 @@ async function setupWebRTC() {
if (!videoRef.value) {
logError('LiveSessionPanel: Video ref not available', {
consumerId: consumer.id,
hasTrack: !!consumer.track,
consumerId: consumer.value.id,
hasTrack: !!consumer.value.track,
})
error.value = 'Video element not available'
return
}
const stream = new MediaStream([consumer.track])
const stream = new MediaStream([consumer.value.track])
videoRef.value.srcObject = stream
hasStream.value = true
@@ -227,7 +227,7 @@ async function setupWebRTC() {
if (resolved) return
resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler)
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
resolve()
}, 5000)
})
@@ -239,7 +239,7 @@ async function setupWebRTC() {
}
catch (playErr) {
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
consumerId: consumer.id,
consumerId: consumer.value.id,
error: playErr.message || String(playErr),
errorName: playErr.name,
videoPaused: videoRef.value.paused,
@@ -248,12 +248,12 @@ async function setupWebRTC() {
// Don't set error - video might still work, just needs user interaction
}
consumer.track.addEventListener('ended', () => {
consumer.value.track.addEventListener('ended', () => {
error.value = 'Video track ended'
hasStream.value = false
})
videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
})
}
catch (err) {
@@ -274,15 +274,15 @@ async function setupWebRTC() {
}
function cleanup() {
if (consumer) {
consumer.close()
consumer = null
if (consumer.value) {
consumer.value.close()
consumer.value = null
}
if (recvTransport) {
recvTransport.close()
recvTransport = null
if (recvTransport.value) {
recvTransport.value.close()
recvTransport.value = null
}
device = null
device.value = null
if (videoRef.value) {
videoRef.value.srcObject = null
}
@@ -308,7 +308,7 @@ watch(
watch(
() => props.session?.hasStream,
(hasStream) => {
if (hasStream && props.session?.id && !device) {
if (hasStream && props.session?.id && !device.value) {
setupWebRTC()
}
else if (!hasStream) {