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

@@ -94,6 +94,71 @@
</div>
</section>
<section
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
ATAK / device password
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-muted">
{{ user.auth_provider === 'oidc' ? 'Set a password to use when connecting from ATAK (check "Use Authentication" and enter your KestrelOS username and this password).' : 'Optionally set a separate password for ATAK; otherwise use your login password.' }}
</p>
<p
v-if="cotPasswordSuccess"
class="mb-3 text-sm text-green-400"
>
ATAK password saved.
</p>
<p
v-if="cotPasswordError"
class="mb-3 text-sm text-red-400"
>
{{ cotPasswordError }}
</p>
<form
class="space-y-3"
@submit.prevent="onSetCotPassword"
>
<div>
<label
for="account-cot-password"
class="kestrel-label"
>ATAK password</label>
<input
id="account-cot-password"
v-model="cotPassword"
type="password"
autocomplete="new-password"
class="kestrel-input"
:placeholder="user.auth_provider === 'oidc' ? 'Set password for ATAK' : 'Optional'"
>
</div>
<div>
<label
for="account-cot-password-confirm"
class="kestrel-label"
>Confirm ATAK password</label>
<input
id="account-cot-password-confirm"
v-model="cotPasswordConfirm"
type="password"
autocomplete="new-password"
class="kestrel-input"
>
</div>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
:disabled="cotPasswordLoading"
>
{{ cotPasswordLoading ? 'Saving…' : 'Save ATAK password' }}
</button>
</form>
</div>
</section>
<section
v-if="user?.auth_provider === 'local'"
class="mb-8"
@@ -181,6 +246,11 @@ const confirmPassword = ref('')
const passwordLoading = ref(false)
const passwordSuccess = ref(false)
const passwordError = ref('')
const cotPassword = ref('')
const cotPasswordConfirm = ref('')
const cotPasswordLoading = ref(false)
const cotPasswordSuccess = ref(false)
const cotPasswordError = ref('')
const accountInitials = computed(() => {
const id = user.value?.identifier ?? ''
@@ -254,4 +324,34 @@ async function onChangePassword() {
passwordLoading.value = false
}
}
async function onSetCotPassword() {
cotPasswordError.value = ''
cotPasswordSuccess.value = false
if (cotPassword.value !== cotPasswordConfirm.value) {
cotPasswordError.value = 'Password and confirmation do not match.'
return
}
if (cotPassword.value.length < 1) {
cotPasswordError.value = 'Password cannot be empty.'
return
}
cotPasswordLoading.value = true
try {
await $fetch('/api/me/cot-password', {
method: 'PUT',
body: { password: cotPassword.value },
credentials: 'include',
})
cotPassword.value = ''
cotPasswordConfirm.value = ''
cotPasswordSuccess.value = true
}
catch (e) {
cotPasswordError.value = e.data?.message ?? e.message ?? 'Failed to save ATAK password.'
}
finally {
cotPasswordLoading.value = false
}
}
</script>

View File

@@ -6,6 +6,7 @@
:devices="devices ?? []"
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@@ -22,7 +23,7 @@
</template>
<script setup>
const { devices, liveSessions } = useCameras()
const { devices, liveSessions, cotEntities } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const selectedCamera = ref(null)

View File

@@ -112,7 +112,7 @@
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ p.label || '' }}
{{ p.label || '-' }}
</td>
<td class="px-4 py-2 text-kestrel-muted">
{{ p.lat }}

View File

@@ -36,6 +36,67 @@
</div>
</section>
<section class="mb-8">
<h3 class="kestrel-section-label">
TAK Server (ATAK / iTAK)
</h3>
<div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-text">
Scan this QR code with iTAK (or ATAK) to add this KestrelOS server. You'll be prompted for your KestrelOS username and password after scanning.
</p>
<div
v-if="takQrDataUrl"
class="inline-block rounded-lg border border-kestrel-border bg-white p-3"
>
<img
:src="takQrDataUrl"
alt="TAK Server QR code"
class="h-48 w-48"
width="192"
height="192"
>
</div>
<p
v-else-if="takQrError"
class="text-sm text-red-400"
>
{{ takQrError }}
</p>
<p
v-else
class="text-sm text-kestrel-muted"
>
Loading QR code…
</p>
<p
v-if="takServerString"
class="mt-3 text-xs text-kestrel-muted break-all"
>
{{ takServerString }}
</p>
<template v-if="cotConfig?.ssl">
<p class="mt-3 text-sm text-kestrel-text">
This server uses a self-signed certificate. iTAK will not connect until it trusts the cert.
</p>
<ol class="mt-2 list-decimal list-inside space-y-1 text-sm text-kestrel-text">
<li>
<strong>Upload server package:</strong> Download below, then in iTAK tap Add Server (+) → Upload server package and select the zip; enter KestrelOS username and password when prompted.
</li>
<li>
<strong>Plain TCP:</strong> Remove or rename <code class="bg-kestrel-surface px-1 rounded">.dev-certs</code>, restart, then in iTAK add the server with SSL disabled.
</li>
</ol>
<a
href="/api/cot/server-package"
download="kestrelos-itak-server-package.zip"
class="kestrel-btn-secondary mt-3 inline-block"
>
Download server package (zip)
</a>
</template>
</div>
</section>
<section>
<h3 class="kestrel-section-label">
About
@@ -67,6 +128,11 @@ const tilesMessage = ref('')
const tilesMessageSuccess = ref(false)
const tilesLoading = ref(false)
const cotConfig = ref(null)
const takQrDataUrl = ref('')
const takQrError = ref('')
const takServerString = ref('')
async function loadTilesStored() {
if (typeof window === 'undefined') return
try {
@@ -106,7 +172,26 @@ async function onClearTiles() {
}
}
async function loadTakQr() {
if (typeof window === 'undefined') return
try {
const res = await $fetch('/api/cot/config')
cotConfig.value = res
const hostname = window.location.hostname
const port = res?.port ?? 8089
const protocol = res?.ssl ? 'ssl' : 'tcp'
const str = `KestrelOS,${hostname},${port},${protocol}`
takServerString.value = str
const QRCode = (await import('qrcode')).default
takQrDataUrl.value = await QRCode.toDataURL(str, { width: 192, margin: 1 })
}
catch (e) {
takQrError.value = e?.data?.error ?? e?.message ?? 'Could not load TAK server config.'
}
}
onMounted(() => {
loadTilesStored()
loadTakQr()
})
</script>

View File

@@ -39,7 +39,7 @@
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
</p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 on the server.</li>
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000-49999 on the server.</li>
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul>
@@ -68,7 +68,7 @@
v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
>
● Live you appear on the map
● Live - you appear on the map
</div>
</div>
@@ -122,11 +122,11 @@ const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let locationWatchId = null
let locationIntervalId = null
let device = null
let sendTransport = null
let producer = null
const locationWatchId = ref(null)
const locationIntervalId = ref(null)
const device = ref(null)
const sendTransport = ref(null)
const producer = ref(null)
async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason()
@@ -194,8 +194,8 @@ async function startSharing() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include',
})
device = await createMediasoupDevice(rtpCapabilities)
sendTransport = await createSendTransport(device, sessionId.value, {
device.value = await createMediasoupDevice(rtpCapabilities)
sendTransport.value = await createSendTransport(device.value, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => {
webrtcState.value = 'failed'
@@ -208,31 +208,31 @@ async function startSharing() {
if (!videoTrack) {
throw new Error('No video track available')
}
producer = await sendTransport.produce({ track: videoTrack })
producer.value = await sendTransport.value.produce({ track: videoTrack })
// Monitor producer events
producer.on('transportclose', () => {
producer.value.on('transportclose', () => {
logWarn('share-live: Producer transport closed', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
producerId: producer.value.id,
producerPaused: producer.value.paused,
producerClosed: producer.value.closed,
})
})
producer.on('trackended', () => {
producer.value.on('trackended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
producerPaused: producer.paused,
producerClosed: producer.closed,
producerId: producer.value.id,
producerPaused: producer.value.paused,
producerClosed: producer.value.closed,
})
})
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.on('connectionstatechange', () => {
const state = sendTransport.connectionState
sendTransport.value.on('connectionstatechange', () => {
const state = sendTransport.value.connectionState
if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', {
state,
transportId: sendTransport.id,
producerId: producer.id,
transportId: sendTransport.value.id,
producerId: producer.value.id,
})
if (state === 'failed') {
webrtcState.value = 'failed'
@@ -241,25 +241,25 @@ async function startSharing() {
}
})
// Monitor track state
if (producer.track) {
producer.track.addEventListener('ended', () => {
if (producer.value.track) {
producer.value.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', {
producerId: producer.id,
trackId: producer.track.id,
trackReadyState: producer.track.readyState,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
producerId: producer.value.id,
trackId: producer.value.track.id,
trackReadyState: producer.value.track.readyState,
trackEnabled: producer.value.track.enabled,
trackMuted: producer.value.track.muted,
})
})
producer.track.addEventListener('mute', () => {
producer.value.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', {
producerId: producer.id,
trackId: producer.track.id,
trackEnabled: producer.track.enabled,
trackMuted: producer.track.muted,
producerId: producer.value.id,
trackId: producer.value.track.id,
trackEnabled: producer.value.track.enabled,
trackMuted: producer.value.track.muted,
})
})
producer.track.addEventListener('unmute', () => {})
producer.value.track.addEventListener('unmute', () => {})
}
webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…')
@@ -273,7 +273,7 @@ async function startSharing() {
return
}
// 5. Get location (continuous) also requires HTTPS on mobile Safari
// 5. Get location (continuous) - also requires HTTPS on mobile Safari
if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.')
cleanup()
@@ -281,7 +281,7 @@ async function startSharing() {
}
try {
await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition(
locationWatchId.value = navigator.geolocation.watchPosition(
(pos) => {
resolve(pos)
},
@@ -332,9 +332,9 @@ async function startSharing() {
}
catch (e) {
if (e?.statusCode === 404) {
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationIntervalId = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
locationIntervalId.value = null
}
sharing.value = false
if (!locationUpdate404Logged) {
@@ -350,7 +350,7 @@ async function startSharing() {
}
await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000)
locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
}
catch (e) {
starting.value = false
@@ -363,23 +363,23 @@ async function startSharing() {
}
function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId)
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId.value)
}
locationWatchId = null
if (locationIntervalId != null) {
clearInterval(locationIntervalId)
locationWatchId.value = null
if (locationIntervalId.value != null) {
clearInterval(locationIntervalId.value)
}
locationIntervalId = null
if (producer) {
producer.close()
producer = null
locationIntervalId.value = null
if (producer.value) {
producer.value.close()
producer.value = null
}
if (sendTransport) {
sendTransport.close()
sendTransport = null
if (sendTransport.value) {
sendTransport.value.close()
sendTransport.value = null
}
device = null
device.value = null
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null