From e61e6bc7e32103b2c40d1fbf51ef7466c2d9f095 Mon Sep 17 00:00:00 2001
From: Keli Grubb
Date: Tue, 17 Feb 2026 16:41:41 +0000
Subject: [PATCH] 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
Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/6
---
Dockerfile | 3 +-
README.md | 43 ++-
app/components/KestrelMap.vue | 68 +++-
app/components/LiveSessionPanel.vue | 72 ++---
app/composables/useCameras.js | 5 +-
app/composables/useMediaQuery.js | 10 +-
app/composables/useWebRTC.js | 6 +-
app/pages/account.vue | 100 ++++++
app/pages/index.vue | 3 +-
app/pages/poi.vue | 2 +-
app/pages/settings.vue | 85 +++++
app/pages/share-live.vue | 108 +++----
app/utils/logger.js | 10 +-
docs/README.md | 11 +
docs/atak-itak.md | 79 +++++
docs/auth.md | 39 +++
docs/installation.md | 61 ++++
docs/live-streaming.md | 44 +++
docs/map-and-cameras.md | 52 +++
docs/screenshot.png | Bin 0 -> 163004 bytes
nuxt.config.js | 4 +-
package-lock.json | 297 ++++++++++++++++-
package.json | 4 +
server/api/auth/config.get.js | 2 +-
server/api/auth/login.post.js | 6 +-
server/api/auth/oidc/authorize.get.js | 2 +-
server/api/auth/oidc/callback.get.js | 5 +-
server/api/cameras.get.js | 11 +-
server/api/cot/config.get.js | 8 +
server/api/cot/server-package.get.js | 60 ++++
server/api/cot/truststore.get.js | 24 ++
server/api/devices.post.js | 22 +-
server/api/devices/[id].patch.js | 36 +--
server/api/live/[id].delete.js | 41 +--
server/api/live/[id].patch.js | 54 +++-
server/api/live/start.post.js | 56 ++--
.../api/live/webrtc/connect-transport.post.js | 10 +-
.../api/live/webrtc/create-consumer.post.js | 8 +-
.../api/live/webrtc/create-producer.post.js | 66 ++--
.../api/live/webrtc/create-transport.post.js | 51 +--
.../webrtc/router-rtp-capabilities.get.js | 7 +-
server/api/me/avatar.delete.js | 9 +-
server/api/me/avatar.get.js | 11 +-
server/api/me/avatar.put.js | 27 +-
server/api/me/cot-password.put.js | 26 ++
server/api/pois.post.js | 2 +-
server/api/pois/[id].patch.js | 26 +-
server/api/users.post.js | 30 +-
server/api/users/[id].patch.js | 81 ++---
server/middleware/auth.js | 2 +-
server/plugins/cot.js | 262 +++++++++++++++
server/plugins/websocket.js | 34 +-
server/routes/health/ready.get.js | 10 +-
server/utils/asyncLock.js | 47 +++
server/utils/authConfig.js | 5 -
server/utils/authHelpers.js | 23 ++
server/utils/authSkipPaths.js | 23 --
server/utils/bootstrap.js | 26 --
server/utils/constants.js | 30 ++
server/utils/cotAuth.js | 25 ++
server/utils/cotParser.js | 151 +++++++++
server/utils/cotSsl.js | 73 +++++
server/utils/cotStore.js | 71 ++++
server/utils/db.js | 127 +++++++-
server/utils/liveSessions.js | 144 +++++----
server/utils/logger.js | 84 +++++
server/utils/mediasoup.js | 79 ++---
server/utils/oidc.js | 7 +
server/utils/poiConstants.js | 1 -
server/utils/queryBuilder.js | 28 ++
server/utils/session.js | 6 -
server/utils/shutdown.js | 78 +++++
server/utils/validation.js | 150 +++++++++
server/utils/webrtcSignaling.js | 10 +-
test/helpers/env.js | 54 ++++
test/helpers/fakeAtakClient.js | 59 ++++
test/integration/server-and-cot.spec.js | 128 ++++++++
test/integration/shutdown.spec.js | 83 +++++
test/nuxt/CameraViewer.spec.js | 80 ++---
test/nuxt/NavDrawer.spec.js | 61 ++--
test/nuxt/auth-middleware.spec.js | 44 +--
test/nuxt/default-layout.spec.js | 36 +--
test/nuxt/index-page.spec.js | 4 +-
test/nuxt/logger.spec.js | 102 ++++++
test/nuxt/login.spec.js | 33 +-
test/nuxt/members-page.spec.js | 31 +-
test/nuxt/poi-page.spec.js | 15 +-
test/nuxt/useCameras.spec.js | 34 +-
test/nuxt/useLiveSessions.spec.js | 63 ++--
test/unit/asyncLock.spec.js | 104 ++++++
test/unit/authConfig.spec.js | 70 ++--
test/unit/authHelpers.spec.js | 60 ++--
test/unit/authSkipPaths.spec.js | 50 +--
test/unit/constants.spec.js | 40 +++
test/unit/cotAuth.spec.js | 63 ++++
test/unit/cotParser.spec.js | 147 +++++++++
test/unit/cotRouter.spec.js | 25 ++
test/unit/cotServer.spec.js | 47 +++
test/unit/cotSsl.spec.js | 138 ++++++++
test/unit/cotStore.spec.js | 58 ++++
test/unit/db.spec.js | 69 +++-
test/unit/deviceUtils.spec.js | 22 ++
test/unit/liveSessions.spec.js | 108 +++++--
test/unit/logger.spec.js | 150 ++++++---
test/unit/mediasoup.spec.js | 52 +--
test/unit/oidc.spec.js | 210 +++++++-----
test/unit/password.spec.js | 10 +-
test/unit/poiConstants.spec.js | 9 +
test/unit/queryBuilder.spec.js | 103 ++++++
test/unit/sanitize.spec.js | 71 ++++
test/unit/server-imports.spec.js | 21 +-
test/unit/session.spec.js | 48 +--
test/unit/shutdown.spec.js | 224 +++++++++++++
test/unit/validation.spec.js | 302 ++++++++++++++++++
test/unit/webrtcSignaling.spec.js | 37 ++-
vitest.config.js | 6 +-
vitest.integration.config.js | 15 +
117 files changed, 5329 insertions(+), 1040 deletions(-)
create mode 100644 docs/README.md
create mode 100644 docs/atak-itak.md
create mode 100644 docs/auth.md
create mode 100644 docs/installation.md
create mode 100644 docs/live-streaming.md
create mode 100644 docs/map-and-cameras.md
create mode 100644 docs/screenshot.png
create mode 100644 server/api/cot/config.get.js
create mode 100644 server/api/cot/server-package.get.js
create mode 100644 server/api/cot/truststore.get.js
create mode 100644 server/api/me/cot-password.put.js
create mode 100644 server/plugins/cot.js
create mode 100644 server/utils/asyncLock.js
delete mode 100644 server/utils/authConfig.js
delete mode 100644 server/utils/authSkipPaths.js
delete mode 100644 server/utils/bootstrap.js
create mode 100644 server/utils/constants.js
create mode 100644 server/utils/cotAuth.js
create mode 100644 server/utils/cotParser.js
create mode 100644 server/utils/cotSsl.js
create mode 100644 server/utils/cotStore.js
create mode 100644 server/utils/logger.js
delete mode 100644 server/utils/poiConstants.js
create mode 100644 server/utils/queryBuilder.js
delete mode 100644 server/utils/session.js
create mode 100644 server/utils/shutdown.js
create mode 100644 server/utils/validation.js
create mode 100644 test/helpers/env.js
create mode 100644 test/helpers/fakeAtakClient.js
create mode 100644 test/integration/server-and-cot.spec.js
create mode 100644 test/integration/shutdown.spec.js
create mode 100644 test/nuxt/logger.spec.js
create mode 100644 test/unit/asyncLock.spec.js
create mode 100644 test/unit/constants.spec.js
create mode 100644 test/unit/cotAuth.spec.js
create mode 100644 test/unit/cotParser.spec.js
create mode 100644 test/unit/cotRouter.spec.js
create mode 100644 test/unit/cotServer.spec.js
create mode 100644 test/unit/cotSsl.spec.js
create mode 100644 test/unit/cotStore.spec.js
create mode 100644 test/unit/poiConstants.spec.js
create mode 100644 test/unit/queryBuilder.spec.js
create mode 100644 test/unit/sanitize.spec.js
create mode 100644 test/unit/shutdown.spec.js
create mode 100644 test/unit/validation.spec.js
create mode 100644 vitest.integration.config.js
diff --git a/Dockerfile b/Dockerfile
index e315162..37e2324 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,11 +16,10 @@ USER node
WORKDIR /app
ENV HOST=0.0.0.0
-ENV PORT=3000
# Copy app as node user (builder stage ran as root)
COPY --from=builder --chown=node:node /app/.output ./.output
-EXPOSE 3000
+EXPOSE 3000 8089
CMD ["node", ".output/server/index.mjs"]
diff --git a/README.md b/README.md
index 47785bd..1de3c68 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream.
+
+
## Stack
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
@@ -34,7 +36,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
npm run dev
```
-3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's “untrusted certificate” warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
+3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's "untrusted certificate" warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
Without the certs, `npm run dev` still runs over HTTP as before.
@@ -48,31 +50,40 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
- **Mediasoup** server (runs automatically in the Nuxt process)
- **mediasoup-client** (browser library, included automatically)
-**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000–49999 on the server.
+**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000-49999 on the server.
-See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
+See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
+
+### ATAK / CoT (Cursor on Target)
+
+KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
## Scripts
-- `npm run dev` – development server
-- `npm run build` – production build
-- `npm run test` – run tests
-- `npm run test:coverage` – run tests with coverage (85% threshold)
-- `npm run lint` – ESLint (zero warnings)
+- `npm run dev` - development server
+- `npm run build` - production build
+- `npm run test` - run tests
+- `npm run test:coverage` - run tests with coverage (85% threshold)
+- `npm run test:e2e` - Playwright E2E tests
+- `npm run lint` - ESLint (zero warnings)
+
+## Documentation
+
+Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/installation.md) (npm, Docker, Helm), [authentication](docs/auth.md) (local login, OIDC), [map and cameras](docs/map-and-cameras.md) (adding IPTV, ALPR, CCTV, NVR, etc.), [ATAK and iTAK](docs/atak-itak.md), and [Share live](docs/live-streaming.md) (mobile device as live camera).
## Configuration
-- **Devices**: Manage cameras/devices via the API (`/api/devices`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
-- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
-- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
-- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
+- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
+- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
+- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
+- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
## Docker
```bash
docker build -t kestrelos:latest .
-docker run -p 3000:3000 kestrelos:latest
+docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
```
## Kubernetes (Helm)
@@ -95,9 +106,9 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
-- `major:` – breaking changes
-- `minor:` – new features
-- `patch:` – bug fixes, docs (default if no prefix)
+- `major:` - breaking changes
+- `minor:` - new features
+- `patch:` - bug fixes, docs (default if no prefix)
Example: `minor: Add map layer toggle`
diff --git a/app/components/KestrelMap.vue b/app/components/KestrelMap.vue
index a88d352..18e0cf7 100644
--- a/app/components/KestrelMap.vue
+++ b/app/components/KestrelMap.vue
@@ -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 = ``
return L.divIcon({
@@ -135,6 +141,17 @@ function getLiveSessionIcon(L) {
})
}
+const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
+function getCotEntityIcon(L) {
+ const html = ``
+ return L.divIcon({
+ className: 'poi-div-icon cot-entity-icon',
+ html: `${html}`,
+ 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 = `
+ {{ 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.' }}
+
+
+ ATAK password saved.
+
+
+ {{ cotPasswordError }}
+
+
+
+
+
{
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
+ }
+}
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 100d996..2e3b508 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -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 @@
diff --git a/app/pages/share-live.vue b/app/pages/share-live.vue
index 7feab93..8d7389b 100644
--- a/app/pages/share-live.vue
+++ b/app/pages/share-live.vue
@@ -39,7 +39,7 @@
Wrong host: server sees {{ webrtcFailureReason.wrongHost.serverHostname }} but you opened this page at {{ webrtcFailureReason.wrongHost.clientHostname }}. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
-
Firewall: Open UDP/TCP ports 40000–49999 on the server.
+
Firewall: Open UDP/TCP ports 40000-49999 on the server.
Wrong host: Server must see the same address you use (see above or open /api/live/debug-request-host).
Restrictive NAT / cellular: A TURN server may be required (future enhancement).
@@ -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
@@ -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
diff --git a/app/utils/logger.js b/app/utils/logger.js
index cb65599..79555b6 100644
--- a/app/utils/logger.js
+++ b/app/utils/logger.js
@@ -1,19 +1,19 @@
/** Client-side logger: sends to server, falls back to console. */
-let sessionId = null
-let userId = null
+const sessionId = ref(null)
+const userId = ref(null)
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
export function initLogger(sessId, uid) {
- sessionId = sessId
- userId = uid
+ sessionId.value = sessId
+ userId.value = uid
}
function sendToServer(level, message, data) {
setTimeout(() => {
$fetch('/api/log', {
method: 'POST',
- body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
+ body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
credentials: 'include',
}).catch(() => { /* server down - don't spam console */ })
}, 0)
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..be7727c
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,11 @@
+# KestrelOS Documentation
+
+Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
+
+## Quick Start
+
+1. [Installation](installation.md) - npm, Docker, or Helm
+2. [Authentication](auth.md) - First login (bootstrap admin or OIDC)
+3. [Map and cameras](map-and-cameras.md) - Add devices and view streams
+4. [ATAK and iTAK](atak-itak.md) - Connect TAK clients (port 8089)
+5. [Share live](live-streaming.md) - Stream from mobile device (HTTPS required)
diff --git a/docs/atak-itak.md b/docs/atak-itak.md
new file mode 100644
index 0000000..455db0e
--- /dev/null
+++ b/docs/atak-itak.md
@@ -0,0 +1,79 @@
+# ATAK and iTAK
+
+KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
+
+## Connection
+
+**Host:** KestrelOS hostname/IP
+**Port:** `8089` (CoT)
+**SSL:** Enable if server uses TLS (`.dev-certs/` or production cert)
+
+**Authentication:**
+- **Username:** KestrelOS identifier
+- **Password:** Login password (local) or ATAK password (OIDC; set in **Account**)
+
+## ATAK (Android)
+
+1. **Settings** → **Network** → **Connections** → Add **TAK Server**
+2. Set **Host** and **Port** (`8089`)
+3. Enable **Use Authentication**, enter username/password
+4. Save and connect
+
+## iTAK (iOS)
+
+**Option A - QR code (easiest):**
+1. KestrelOS **Settings** → **TAK Server** → Scan QR with iTAK
+2. Enter username/password when prompted
+
+**Option B - Manual:**
+1. **Settings** → **Network** → Add **TAK Server**
+2. Set **Host**, **Port** (`8089`), enable SSL if needed
+3. Enable **Use Authentication**, enter username/password
+4. Save and connect
+
+## Self-Signed Certificate (iTAK)
+
+If server uses self-signed cert (`.dev-certs/`):
+
+**Upload server package:**
+1. KestrelOS **Settings** → **TAK Server** → **Download server package (zip)**
+2. Transfer to iPhone (AirDrop, email, Safari)
+3. iTAK: **Settings** → **Network** → **Servers** → **+** → **Upload server package**
+4. Enter username/password
+
+**Or use plain TCP:**
+1. Stop KestrelOS, remove `.dev-certs/`, restart
+2. Add server with **SSL disabled**
+
+**ATAK (Android):** Download trust store from `https://your-server/api/cot/truststore`, import `.p12` (password: `kestrelos`), or use server package/plain TCP.
+
+## OIDC Users
+
+OIDC users must set an **ATAK password** first:
+1. Sign in with OIDC
+2. **Account** → **ATAK / device password** → set password
+3. Use KestrelOS username + ATAK password in TAK client
+
+## Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `COT_PORT` | `8089` | CoT server port |
+| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
+| `COT_REQUIRE_AUTH` | `true` | Require authentication |
+| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
+| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
+
+## Troubleshooting
+
+**"Error authenticating" with no `[cot]` logs:**
+- Connection not reaching server (TLS handshake failed or firewall blocking)
+- Check server logs show `[cot] CoT server listening on 0.0.0.0:8089`
+- Verify port `8089` (not `3000`) and firewall allows it
+- For TLS: trust cert (server package) or use plain TCP
+
+**"Error authenticating" with `[cot]` logs:**
+- Username must be KestrelOS identifier
+- Password must match (local: login password; OIDC: ATAK password)
+
+**Devices not on map:** They appear only while sending updates; drop off after TTL (~90s).
diff --git a/docs/auth.md b/docs/auth.md
new file mode 100644
index 0000000..8e3f35f
--- /dev/null
+++ b/docs/auth.md
@@ -0,0 +1,39 @@
+# Authentication
+
+KestrelOS supports **local login** (username/email + password) and optional **OIDC** (SSO). All users must sign in.
+
+## Local Login
+
+**First run:** On first start, KestrelOS creates an admin account:
+- If `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` are set → that account is created
+- Otherwise → default admin (`admin`) with random password printed in terminal
+
+**Sign in:** Open `/login`, enter identifier and password. Change password or add users via **Members** (admin only).
+
+## OIDC (SSO)
+
+**Enable:** Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Optional: `OIDC_LABEL`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`.
+
+**IdP setup:**
+1. Create OIDC client in your IdP (Keycloak, Auth0, etc.)
+2. Set redirect URI: `https:///api/auth/oidc/callback`
+3. Copy Client ID and Secret to env vars
+
+**Sign up:** Users sign up at the IdP. First OIDC login in KestrelOS creates their account automatically.
+
+**Redirect URI:** Defaults to `{APP_URL}/api/auth/oidc/callback` (uses `NUXT_APP_URL`/`APP_URL` or falls back to `HOST`/`PORT`).
+
+## OIDC Users and ATAK/iTAK
+
+OIDC users don't have a KestrelOS password. To use ATAK/iTAK:
+1. Sign in with OIDC
+2. Go to **Account** → set **ATAK password**
+3. Use KestrelOS username + ATAK password in TAK client
+
+## Roles
+
+- **Admin** - Manage users, edit POIs, add/edit devices (API)
+- **Leader** - Edit POIs, add/edit devices (API)
+- **Member** - View map/cameras/POIs, use Share live
+
+Only admins can change roles (Members page).
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..31679ac
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,61 @@
+# Installation
+
+Run KestrelOS from source (npm), Docker, or Kubernetes (Helm).
+
+## npm (from source)
+
+```bash
+git clone kestrelos
+cd kestrelos
+npm install
+npm run dev
+```
+
+Open **http://localhost:3000**. First run creates `data/kestrelos.db` and bootstraps an admin (see [Authentication](auth.md)).
+
+**Production:**
+```bash
+npm run build
+npm run preview
+# or
+node .output/server/index.mjs
+```
+
+Set `HOST=0.0.0.0` and `PORT` for production.
+
+## Docker
+
+```bash
+docker build -t kestrelos:latest .
+docker run -p 3000:3000 -p 8089:8089 \
+ -v kestrelos-data:/app/data \
+ kestrelos:latest
+```
+
+Expose ports **3000** (web/API) and **8089** (CoT for ATAK/iTAK).
+
+## Helm (Kubernetes)
+
+**From registry:**
+```bash
+helm repo add keligrubb --username USER --password TOKEN \
+ https://git.keligrubb.com/api/packages/keligrubb/helm
+helm install kestrelos keligrubb/kestrelos
+```
+
+**From source:**
+```bash
+helm install kestrelos ./helm/kestrelos
+```
+
+Configure in `helm/kestrelos/values.yaml`. Health: `GET /health`, `/health/live`, `/health/ready`.
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `HOST` | Nuxt default | Bind address (use `0.0.0.0` for all interfaces) |
+| `PORT` | `3000` | Web/API port |
+| `DB_PATH` | `data/kestrelos.db` | SQLite database path |
+
+See [Authentication](auth.md) for auth variables. See [ATAK and iTAK](atak-itak.md) for CoT options.
diff --git a/docs/live-streaming.md b/docs/live-streaming.md
new file mode 100644
index 0000000..7f0665c
--- /dev/null
+++ b/docs/live-streaming.md
@@ -0,0 +1,44 @@
+# Share Live
+
+Stream your phone's camera and location to KestrelOS. Appears as a **live session** on the map and in **Cameras**. Uses **WebRTC** (Mediasoup) and requires **HTTPS** on mobile.
+
+## Usage
+
+1. Open **Share live** (sidebar → **Share live** or `/share-live`)
+2. Tap **Start sharing**, allow camera/location permissions
+3. Device appears on map and in **Cameras**
+4. Tap **Stop sharing** to end
+
+**Permissions:** Admin/leader can start sharing. All users can view live sessions.
+
+## Requirements
+
+- **HTTPS** (browsers require secure context for camera/geolocation)
+- **Camera and location permissions**
+- **WebRTC ports:** UDP/TCP `40000-49999` open on server
+
+## Local Development
+
+**Generate self-signed cert:**
+```bash
+chmod +x scripts/gen-dev-cert.sh
+./scripts/gen-dev-cert.sh 192.168.1.123 # Your LAN IP
+npm run dev
+```
+
+**On phone:** Open `https://192.168.1.123:3000`, accept cert warning, sign in, use Share live.
+
+## WebRTC Configuration
+
+- Server auto-detects LAN IP for WebRTC
+- **Docker/multiple NICs:** Set `MEDIASOUP_ANNOUNCED_IP` to client-reachable IP/hostname
+- **"Wrong host" error:** Use same URL on phone/server, or set `MEDIASOUP_ANNOUNCED_IP`
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| "HTTPS required" | Use `https://` (not `http://`) |
+| "Media devices not available" | Ensure HTTPS and browser permissions |
+| "WebRTC: failed" / "Wrong host" | Set `MEDIASOUP_ANNOUNCED_IP`, open firewall ports `40000-49999` |
+| Stream not visible | Check server reachability and firewall |
diff --git a/docs/map-and-cameras.md b/docs/map-and-cameras.md
new file mode 100644
index 0000000..acd8d5b
--- /dev/null
+++ b/docs/map-and-cameras.md
@@ -0,0 +1,52 @@
+# Map and Cameras
+
+KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and ATAK/iTAK positions. Click markers or use **Cameras** page to view streams.
+
+## Map Layers
+
+- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
+- **POIs** - Points of interest (admin/leader can edit)
+- **Live sessions** - Mobile devices streaming via Share live
+- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
+
+## Cameras
+
+A **camera** is either:
+1. A **device** - Fixed feed with stream URL
+2. A **live session** - Mobile device streaming via Share live
+
+View via map markers or **Cameras** page (sidebar).
+
+## Device Types
+
+| device_type | Use case |
+|-------------|----------|
+| `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone` | Labeling/filtering |
+
+**source_type:** `mjpeg` (MJPEG over HTTP) or `hls` (HLS `.m3u8` playlist)
+
+Stream URLs must be `http://` or `https://`.
+
+## API: Devices
+
+**Create:** `POST /api/devices` (admin/leader)
+```json
+{
+ "name": "Main gate ALPR",
+ "device_type": "alpr",
+ "lat": 37.7749,
+ "lng": -122.4194,
+ "stream_url": "https://alpr.example.com/stream.m3u8",
+ "source_type": "hls"
+}
+```
+
+**List:** `GET /api/devices`
+**Update:** `PATCH /api/devices/:id`
+**Delete:** `DELETE /api/devices/:id`
+
+**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
+
+## POIs
+
+Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
diff --git a/docs/screenshot.png b/docs/screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..c68d98c825af99ee0a1b90ba0811f53335bbf20b
GIT binary patch
literal 163004
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