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 0000000..c68d98c
Binary files /dev/null and b/docs/screenshot.png differ
diff --git a/nuxt.config.js b/nuxt.config.js
index d656dbd..c538e1b 100644
--- a/nuxt.config.js
+++ b/nuxt.config.js
@@ -32,10 +32,12 @@ export default defineNuxtConfig({
public: {
version: pkg.version ?? '',
},
+ cotTtlMs: 90_000,
+ cotRequireAuth: true,
+ cotDebug: false,
},
devServer: {
host: '0.0.0.0',
- port: 3000,
...(useDevHttps
? { https: { key: devKey, cert: devCert } }
: {}),
diff --git a/package-lock.json b/package-lock.json
index 497cd9f..e253560 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,21 +1,26 @@
{
"name": "kestrelos",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kestrelos",
+ "version": "0.3.0",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+ "fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
+ "jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
+ "qrcode": "^1.5.4",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",
@@ -6680,6 +6685,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -7422,6 +7436,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -7581,6 +7604,12 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -8753,6 +8782,24 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/fast-xml-parser": {
+ "version": "5.3.6",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
+ "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.2"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -9711,6 +9758,12 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT"
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -10397,6 +10450,48 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -10703,6 +10798,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -12506,6 +12610,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -12518,6 +12631,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -12567,7 +12686,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12729,6 +12847,15 @@
"node": ">=4"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
@@ -13491,6 +13618,141 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -13789,6 +14051,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/reserved-identifiers": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
@@ -14274,8 +14542,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
- "license": "ISC",
- "optional": true
+ "license": "ISC"
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
@@ -14799,6 +15072,18 @@
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"license": "MIT"
},
+ "node_modules/strnum": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/structured-clone-es": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz",
@@ -16670,6 +16955,12 @@
"node": ">= 8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
diff --git a/package.json b/package.json
index cf7ef5d..2a0317c 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"test": "vitest",
+ "test:integration": "vitest run --config vitest.integration.config.js",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test test/e2e",
"test:e2e:ui": "playwright test --ui test/e2e",
@@ -20,13 +21,16 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+ "fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
+ "jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
+ "qrcode": "^1.5.4",
"sqlite3": "^5.1.7",
"vue": "^3.4.0",
"vue-router": "^4.4.0",
diff --git a/server/api/auth/config.get.js b/server/api/auth/config.get.js
index 9cc48d1..ce8e907 100644
--- a/server/api/auth/config.get.js
+++ b/server/api/auth/config.get.js
@@ -1,3 +1,3 @@
-import { getAuthConfig } from '../../utils/authConfig.js'
+import { getAuthConfig } from '../../utils/oidc.js'
export default defineEventHandler(() => getAuthConfig())
diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js
index 7ea6054..35dd323 100644
--- a/server/api/auth/login.post.js
+++ b/server/api/auth/login.post.js
@@ -1,7 +1,7 @@
import { setCookie } from 'h3'
import { getDb } from '../../utils/db.js'
import { verifyPassword } from '../../utils/password.js'
-import { getSessionMaxAgeDays } from '../../utils/session.js'
+import { getSessionMaxAgeDays } from '../../utils/constants.js'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
@@ -15,6 +15,10 @@ export default defineEventHandler(async (event) => {
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
throw createError({ statusCode: 401, message: 'Invalid credentials' })
}
+
+ // Invalidate all existing sessions for this user to prevent session fixation
+ await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
+
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()
diff --git a/server/api/auth/oidc/authorize.get.js b/server/api/auth/oidc/authorize.get.js
index b5ca320..f494de6 100644
--- a/server/api/auth/oidc/authorize.get.js
+++ b/server/api/auth/oidc/authorize.get.js
@@ -1,5 +1,5 @@
-import { getAuthConfig } from '../../../utils/authConfig.js'
import {
+ getAuthConfig,
getOidcConfig,
getOidcRedirectUri,
createOidcParams,
diff --git a/server/api/auth/oidc/callback.get.js b/server/api/auth/oidc/callback.get.js
index 2e9a680..840ff51 100644
--- a/server/api/auth/oidc/callback.get.js
+++ b/server/api/auth/oidc/callback.get.js
@@ -6,7 +6,7 @@ import {
exchangeCode,
} from '../../../utils/oidc.js'
import { getDb } from '../../../utils/db.js'
-import { getSessionMaxAgeDays } from '../../../utils/session.js'
+import { getSessionMaxAgeDays } from '../../../utils/constants.js'
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
@@ -74,6 +74,9 @@ export default defineEventHandler(async (event) => {
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
}
+ // Invalidate all existing sessions for this user to prevent session fixation
+ await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
+
const sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID()
const now = new Date()
diff --git a/server/api/cameras.get.js b/server/api/cameras.get.js
index 2255a41..bb91b0e 100644
--- a/server/api/cameras.get.js
+++ b/server/api/cameras.get.js
@@ -1,12 +1,19 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getActiveSessions } from '../utils/liveSessions.js'
+import { getActiveEntities } from '../utils/cotStore.js'
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
- const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
+ const config = useRuntimeConfig()
+ const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
+ const [db, sessions, cotEntities] = await Promise.all([
+ getDb(),
+ getActiveSessions(),
+ getActiveEntities(ttlMs),
+ ])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
- return { devices, liveSessions: sessions }
+ return { devices, liveSessions: sessions, cotEntities }
})
diff --git a/server/api/cot/config.get.js b/server/api/cot/config.get.js
new file mode 100644
index 0000000..ee6c5b6
--- /dev/null
+++ b/server/api/cot/config.get.js
@@ -0,0 +1,8 @@
+import { getCotSslPaths, getCotPort } from '../../utils/cotSsl.js'
+
+/** Public CoT server config for QR code / client setup (port and whether TLS is used). */
+export default defineEventHandler(() => {
+ const config = useRuntimeConfig()
+ const paths = getCotSslPaths(config)
+ return { port: getCotPort(), ssl: Boolean(paths) }
+})
diff --git a/server/api/cot/server-package.get.js b/server/api/cot/server-package.get.js
new file mode 100644
index 0000000..917c082
--- /dev/null
+++ b/server/api/cot/server-package.get.js
@@ -0,0 +1,60 @@
+import { existsSync } from 'node:fs'
+import JSZip from 'jszip'
+import { getCotSslPaths, getCotPort, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
+import { requireAuth } from '../../utils/authHelpers.js'
+
+/**
+ * Build config.pref XML for iTAK: server connection + CA cert for trust (credentials entered in app).
+ * connectString format: host:port:ssl or host:port:tcp
+ */
+function buildConfigPref(hostname, port, ssl) {
+ const connectString = `${hostname}:${port}:${ssl ? 'ssl' : 'tcp'}`
+ return `
+
+ 1
+ KestrelOS
+ true
+ ${escapeXml(connectString)}
+ cert/caCert.p12
+ ${escapeXml(TRUSTSTORE_PASSWORD)}
+ true
+
+`
+}
+
+function escapeXml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+export default defineEventHandler(async (event) => {
+ requireAuth(event)
+ const config = useRuntimeConfig()
+ const paths = getCotSslPaths(config)
+ if (!paths || !existsSync(paths.certPath)) {
+ setResponseStatus(event, 404)
+ return { error: `CoT server is not using TLS. Server package ${COT_TLS_REQUIRED_MESSAGE} Use the QR code and add the server with SSL disabled (plain TCP) instead.` }
+ }
+
+ const hostname = getRequestURL(event).hostname
+ const port = getCotPort()
+
+ try {
+ const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
+ const zip = new JSZip()
+ zip.file('config.pref', buildConfigPref(hostname, port, true))
+ zip.folder('cert').file('caCert.p12', p12)
+
+ const blob = await zip.generateAsync({ type: 'nodebuffer' })
+ setHeader(event, 'Content-Type', 'application/zip')
+ setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-itak-server-package.zip"')
+ return blob
+ }
+ catch (err) {
+ setResponseStatus(event, 500)
+ return { error: 'Failed to build server package.', detail: err?.message }
+ }
+})
diff --git a/server/api/cot/truststore.get.js b/server/api/cot/truststore.get.js
new file mode 100644
index 0000000..3ef072c
--- /dev/null
+++ b/server/api/cot/truststore.get.js
@@ -0,0 +1,24 @@
+import { existsSync } from 'node:fs'
+import { getCotSslPaths, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
+import { requireAuth } from '../../utils/authHelpers.js'
+
+export default defineEventHandler((event) => {
+ requireAuth(event)
+ const config = useRuntimeConfig()
+ const paths = getCotSslPaths(config)
+ if (!paths || !existsSync(paths.certPath)) {
+ setResponseStatus(event, 404)
+ return { error: `CoT server is not using TLS or cert not found. Trust store ${COT_TLS_REQUIRED_MESSAGE}` }
+ }
+
+ try {
+ const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
+ setHeader(event, 'Content-Type', 'application/x-pkcs12')
+ setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-cot-truststore.p12"')
+ return p12
+ }
+ catch (err) {
+ setResponseStatus(event, 500)
+ return { error: 'Failed to build trust store.', detail: err?.message }
+ }
+})
diff --git a/server/api/devices.post.js b/server/api/devices.post.js
index ed83405..24d3c74 100644
--- a/server/api/devices.post.js
+++ b/server/api/devices.post.js
@@ -1,4 +1,4 @@
-import { getDb } from '../utils/db.js'
+import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
@@ -7,13 +7,15 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({}))
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
const id = crypto.randomUUID()
- const { run, get } = await getDb()
- await run(
- 'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
- [id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
- )
- const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
- const device = rowToDevice(row)
- if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
- return sanitizeDeviceForResponse(device)
+ const db = await getDb()
+ return withTransaction(db, async ({ run, get }) => {
+ await run(
+ 'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
+ )
+ const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
+ const device = rowToDevice(row)
+ if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
+ return sanitizeDeviceForResponse(device)
+ })
})
diff --git a/server/api/devices/[id].patch.js b/server/api/devices/[id].patch.js
index 5f29283..275c4f2 100644
--- a/server/api/devices/[id].patch.js
+++ b/server/api/devices/[id].patch.js
@@ -1,55 +1,49 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js'
+import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event).catch(() => ({}))) || {}
- const updates = []
- const params = []
+ const updates = {}
if (typeof body.name === 'string') {
- updates.push('name = ?')
- params.push(body.name.trim())
+ updates.name = body.name.trim()
}
if (DEVICE_TYPES.includes(body.device_type)) {
- updates.push('device_type = ?')
- params.push(body.device_type)
+ updates.device_type = body.device_type
}
if (body.vendor !== undefined) {
- updates.push('vendor = ?')
- params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
+ updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null
}
if (Number.isFinite(body.lat)) {
- updates.push('lat = ?')
- params.push(body.lat)
+ updates.lat = body.lat
}
if (Number.isFinite(body.lng)) {
- updates.push('lng = ?')
- params.push(body.lng)
+ updates.lng = body.lng
}
if (typeof body.stream_url === 'string') {
- updates.push('stream_url = ?')
- params.push(body.stream_url.trim())
+ updates.stream_url = body.stream_url.trim()
}
if (SOURCE_TYPES.includes(body.source_type)) {
- updates.push('source_type = ?')
- params.push(body.source_type)
+ updates.source_type = body.source_type
}
if (body.config !== undefined) {
- updates.push('config = ?')
- params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
+ updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null)
}
const { run, get } = await getDb()
- if (updates.length === 0) {
+ if (Object.keys(updates).length === 0) {
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row)
return device ? sanitizeDeviceForResponse(device) : row
}
- params.push(id)
- await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
+ const { query, params } = buildUpdateQuery('devices', null, updates)
+ if (query) {
+ await run(query, [...params, id])
+ }
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row)
diff --git a/server/api/live/[id].delete.js b/server/api/live/[id].delete.js
index f44bc16..ba0d702 100644
--- a/server/api/live/[id].delete.js
+++ b/server/api/live/[id].delete.js
@@ -1,35 +1,38 @@
import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
+import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
- const session = getLiveSession(id)
- if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
- if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
+ return await acquire(`session-delete-${id}`, async () => {
+ const session = getLiveSession(id)
+ if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
+ if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
- // Clean up producer if it exists
- if (session.producerId) {
- const producer = getProducer(session.producerId)
- if (producer) {
- producer.close()
+ // Clean up producer if it exists
+ if (session.producerId) {
+ const producer = getProducer(session.producerId)
+ if (producer) {
+ producer.close()
+ }
}
- }
- // Clean up transport if it exists
- if (session.transportId) {
- const transport = getTransport(session.transportId)
- if (transport) {
- transport.close()
+ // Clean up transport if it exists
+ if (session.transportId) {
+ const transport = getTransport(session.transportId)
+ if (transport) {
+ transport.close()
+ }
}
- }
- // Clean up router
- await closeRouter(id)
+ // Clean up router
+ await closeRouter(id)
- deleteLiveSession(id)
- return { ok: true }
+ await deleteLiveSession(id)
+ return { ok: true }
+ })
})
diff --git a/server/api/live/[id].patch.js b/server/api/live/[id].patch.js
index 3550fd0..dc584bf 100644
--- a/server/api/live/[id].patch.js
+++ b/server/api/live/[id].patch.js
@@ -1,31 +1,57 @@
import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
+import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
- const session = getLiveSession(id)
- if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
- if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
-
const body = await readBody(event).catch(() => ({}))
const lat = Number(body?.lat)
const lng = Number(body?.lng)
const updates = {}
if (Number.isFinite(lat)) updates.lat = lat
if (Number.isFinite(lng)) updates.lng = lng
- if (Object.keys(updates).length) {
- updateLiveSession(id, updates)
+ if (Object.keys(updates).length === 0) {
+ // No updates, just return current session
+ const session = getLiveSession(id)
+ if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
+ if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
+ return {
+ id: session.id,
+ label: session.label,
+ lat: session.lat,
+ lng: session.lng,
+ updatedAt: session.updatedAt,
+ }
}
- const updated = getLiveSession(id)
- return {
- id: updated.id,
- label: updated.label,
- lat: updated.lat,
- lng: updated.lng,
- updatedAt: updated.updatedAt,
- }
+ // Use lock to atomically check and update session
+ return await acquire(`session-patch-${id}`, async () => {
+ const session = getLiveSession(id)
+ if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
+ if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
+
+ try {
+ const updated = await updateLiveSession(id, updates)
+ // Re-verify after update (updateLiveSession throws if session not found)
+ if (!updated || updated.userId !== user.id) {
+ throw createError({ statusCode: 404, message: 'Live session not found' })
+ }
+ return {
+ id: updated.id,
+ label: updated.label,
+ lat: updated.lat,
+ lng: updated.lng,
+ updatedAt: updated.updatedAt,
+ }
+ }
+ catch (err) {
+ if (err.message === 'Session not found') {
+ throw createError({ statusCode: 404, message: 'Live session not found' })
+ }
+ throw err
+ }
+ })
})
diff --git a/server/api/live/start.post.js b/server/api/live/start.post.js
index 77e9f95..a92c319 100644
--- a/server/api/live/start.post.js
+++ b/server/api/live/start.post.js
@@ -1,40 +1,44 @@
import { requireAuth } from '../../utils/authHelpers.js'
import {
- createSession,
+ getOrCreateSession,
getActiveSessionByUserId,
deleteLiveSession,
} from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
+import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event).catch(() => ({}))
- const label = typeof body?.label === 'string' ? body.label.trim() : ''
+ const label = typeof body?.label === 'string' ? body.label.trim().slice(0, 100) : ''
- // Replace any existing live session for this user (one session per user)
- const existing = getActiveSessionByUserId(user.id)
- if (existing) {
- if (existing.producerId) {
- const producer = getProducer(existing.producerId)
- if (producer) producer.close()
+ // Atomically get or create session, replacing existing if needed
+ return await acquire(`session-start-${user.id}`, async () => {
+ const existing = await getActiveSessionByUserId(user.id)
+ if (existing) {
+ // Clean up existing session resources
+ if (existing.producerId) {
+ const producer = getProducer(existing.producerId)
+ if (producer) producer.close()
+ }
+ if (existing.transportId) {
+ const transport = getTransport(existing.transportId)
+ if (transport) transport.close()
+ }
+ if (existing.routerId) {
+ await closeRouter(existing.id).catch((err) => {
+ console.error('[live.start] Error closing previous router:', err)
+ })
+ }
+ await deleteLiveSession(existing.id)
+ console.log('[live.start] Replaced previous session:', existing.id)
}
- if (existing.transportId) {
- const transport = getTransport(existing.transportId)
- if (transport) transport.close()
- }
- if (existing.routerId) {
- await closeRouter(existing.id).catch((err) => {
- console.error('[live.start] Error closing previous router:', err)
- })
- }
- deleteLiveSession(existing.id)
- console.log('[live.start] Replaced previous session:', existing.id)
- }
- const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
- console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
- return {
- id: session.id,
- label: session.label,
- }
+ const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`)
+ console.log('[live.start] Session ready:', { id: session.id, userId: user.id, label: session.label })
+ return {
+ id: session.id,
+ label: session.label,
+ }
+ })
})
diff --git a/server/api/live/webrtc/connect-transport.post.js b/server/api/live/webrtc/connect-transport.post.js
index d3da6f9..12ba47c 100644
--- a/server/api/live/webrtc/connect-transport.post.js
+++ b/server/api/live/webrtc/connect-transport.post.js
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getTransport } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
- requireAuth(event) // Verify authentication
+ const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, dtlsParameters } = body
@@ -15,8 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' })
}
- // Note: Both publisher and viewers can connect their own transports
- // The transportId ensures they can only connect transports they created
+
+ // Verify user has permission to connect transport for this session
+ // Only session owner or admin/leader can connect transports
+ if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
+ throw createError({ statusCode: 403, message: 'Forbidden' })
+ }
const transport = getTransport(transportId)
if (!transport) {
diff --git a/server/api/live/webrtc/create-consumer.post.js b/server/api/live/webrtc/create-consumer.post.js
index c5ec6a5..b2eb4b6 100644
--- a/server/api/live/webrtc/create-consumer.post.js
+++ b/server/api/live/webrtc/create-consumer.post.js
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
- requireAuth(event) // Verify authentication
+ const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, rtpCapabilities } = body
@@ -15,6 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) {
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
}
+
+ // Authorization check: only session owner or admin/leader can consume
+ if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
+ throw createError({ statusCode: 403, message: 'Forbidden' })
+ }
+
if (!session.producerId) {
throw createError({ statusCode: 404, message: 'No producer available for this session' })
}
diff --git a/server/api/live/webrtc/create-producer.post.js b/server/api/live/webrtc/create-producer.post.js
index bca2602..54a27e3 100644
--- a/server/api/live/webrtc/create-producer.post.js
+++ b/server/api/live/webrtc/create-producer.post.js
@@ -1,6 +1,7 @@
import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getTransport, producers } from '../../../utils/mediasoup.js'
+import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
@@ -11,33 +12,48 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
}
- const session = getLiveSession(sessionId)
- if (!session) {
- throw createError({ statusCode: 404, message: 'Session not found' })
- }
- if (session.userId !== user.id) {
- throw createError({ statusCode: 403, message: 'Forbidden' })
- }
+ return await acquire(`create-producer-${sessionId}`, async () => {
+ const session = getLiveSession(sessionId)
+ if (!session) {
+ throw createError({ statusCode: 404, message: 'Session not found' })
+ }
+ if (session.userId !== user.id) {
+ throw createError({ statusCode: 403, message: 'Forbidden' })
+ }
- const transport = getTransport(transportId)
- if (!transport) {
- throw createError({ statusCode: 404, message: 'Transport not found' })
- }
+ const transport = getTransport(transportId)
+ if (!transport) {
+ throw createError({ statusCode: 404, message: 'Transport not found' })
+ }
- const producer = await transport.produce({ kind, rtpParameters })
- producers.set(producer.id, producer)
- producer.on('close', () => {
- producers.delete(producer.id)
- const s = getLiveSession(sessionId)
- if (s && s.producerId === producer.id) {
- updateLiveSession(sessionId, { producerId: null })
+ const producer = await transport.produce({ kind, rtpParameters })
+ producers.set(producer.id, producer)
+ producer.on('close', async () => {
+ producers.delete(producer.id)
+ const s = getLiveSession(sessionId)
+ if (s && s.producerId === producer.id) {
+ try {
+ await updateLiveSession(sessionId, { producerId: null })
+ }
+ catch {
+ // Ignore errors during cleanup
+ }
+ }
+ })
+
+ try {
+ await updateLiveSession(sessionId, { producerId: producer.id })
+ }
+ catch (err) {
+ if (err.message === 'Session not found') {
+ throw createError({ statusCode: 404, message: 'Session not found' })
+ }
+ throw err
+ }
+
+ return {
+ id: producer.id,
+ kind: producer.kind,
}
})
-
- updateLiveSession(sessionId, { producerId: producer.id })
-
- return {
- id: producer.id,
- kind: producer.kind,
- }
})
diff --git a/server/api/live/webrtc/create-transport.post.js b/server/api/live/webrtc/create-transport.post.js
index 25de2b4..ff9844b 100644
--- a/server/api/live/webrtc/create-transport.post.js
+++ b/server/api/live/webrtc/create-transport.post.js
@@ -2,6 +2,7 @@ import { getRequestURL } from 'h3'
import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, createTransport } from '../../../utils/mediasoup.js'
+import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
@@ -12,28 +13,38 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId required' })
}
- const session = getLiveSession(sessionId)
- if (!session) {
- throw createError({ statusCode: 404, message: 'Session not found' })
- }
+ return await acquire(`create-transport-${sessionId}`, async () => {
+ const session = getLiveSession(sessionId)
+ if (!session) {
+ throw createError({ statusCode: 404, message: 'Session not found' })
+ }
- // Only publisher (session owner) can create producer transport
- // Viewers can create consumer transports
- if (isProducer && session.userId !== user.id) {
- throw createError({ statusCode: 403, message: 'Forbidden' })
- }
+ // Only publisher (session owner) can create producer transport
+ // Viewers can create consumer transports
+ if (isProducer && session.userId !== user.id) {
+ throw createError({ statusCode: 403, message: 'Forbidden' })
+ }
- const url = getRequestURL(event)
- const requestHost = url.hostname
- const router = await getRouter(sessionId)
- const { transport, params } = await createTransport(router, requestHost)
+ const url = getRequestURL(event)
+ const requestHost = url.hostname
+ const router = await getRouter(sessionId)
+ const { transport, params } = await createTransport(router, requestHost)
- if (isProducer) {
- updateLiveSession(sessionId, {
- transportId: transport.id,
- routerId: router.id,
- })
- }
+ if (isProducer) {
+ try {
+ await updateLiveSession(sessionId, {
+ transportId: transport.id,
+ routerId: router.id,
+ })
+ }
+ catch (err) {
+ if (err.message === 'Session not found') {
+ throw createError({ statusCode: 404, message: 'Session not found' })
+ }
+ throw err
+ }
+ }
- return params
+ return params
+ })
})
diff --git a/server/api/live/webrtc/router-rtp-capabilities.get.js b/server/api/live/webrtc/router-rtp-capabilities.get.js
index 2394707..707e5a0 100644
--- a/server/api/live/webrtc/router-rtp-capabilities.get.js
+++ b/server/api/live/webrtc/router-rtp-capabilities.get.js
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => {
- requireAuth(event)
+ const user = requireAuth(event)
const sessionId = getQuery(event).sessionId
if (!sessionId) {
@@ -15,6 +15,11 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Session not found' })
}
+ // Only session owner or admin/leader can access
+ if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
+ throw createError({ statusCode: 403, message: 'Forbidden' })
+ }
+
const router = await getRouter(sessionId)
return router.rtpCapabilities
})
diff --git a/server/api/me/avatar.delete.js b/server/api/me/avatar.delete.js
index 603dc5c..2f55325 100644
--- a/server/api/me/avatar.delete.js
+++ b/server/api/me/avatar.delete.js
@@ -6,7 +6,14 @@ import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) return { ok: true }
- const path = join(getAvatarsDir(), user.avatar_path)
+
+ // Validate avatar path to prevent path traversal attacks
+ const filename = user.avatar_path
+ if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
+ throw createError({ statusCode: 400, message: 'Invalid avatar path' })
+ }
+
+ const path = join(getAvatarsDir(), filename)
await unlink(path).catch(() => {})
const { run } = await getDb()
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
diff --git a/server/api/me/avatar.get.js b/server/api/me/avatar.get.js
index 67707db..30a88dd 100644
--- a/server/api/me/avatar.get.js
+++ b/server/api/me/avatar.get.js
@@ -8,8 +8,15 @@ const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
- const path = join(getAvatarsDir(), user.avatar_path)
- const ext = user.avatar_path.split('.').pop()?.toLowerCase()
+
+ // Validate avatar path to prevent path traversal attacks
+ const filename = user.avatar_path
+ if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
+ throw createError({ statusCode: 400, message: 'Invalid avatar path' })
+ }
+
+ const path = join(getAvatarsDir(), filename)
+ const ext = filename.split('.').pop()?.toLowerCase()
const mime = MIME[ext] ?? 'application/octet-stream'
try {
const buf = await readFile(path)
diff --git a/server/api/me/avatar.put.js b/server/api/me/avatar.put.js
index 75956ea..0ee8097 100644
--- a/server/api/me/avatar.put.js
+++ b/server/api/me/avatar.put.js
@@ -8,6 +8,24 @@ const MAX_SIZE = 2 * 1024 * 1024
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
+/**
+ * Validate image content using magic bytes to prevent MIME type spoofing.
+ * @param {Buffer} buffer - File data buffer
+ * @returns {string|null} Detected MIME type or null if invalid
+ */
+function validateImageContent(buffer) {
+ if (!buffer || buffer.length < 8) return null
+ // JPEG: FF D8 FF
+ if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
+ return 'image/jpeg'
+ }
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
+ return 'image/png'
+ }
+ return null
+}
+
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const form = await readMultipartFormData(event)
@@ -16,7 +34,14 @@ export default defineEventHandler(async (event) => {
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
const mime = file.type ?? ''
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
- const ext = EXT_BY_MIME[mime] ?? 'jpg'
+
+ // Validate file content matches declared MIME type
+ const actualMime = validateImageContent(file.data)
+ if (!actualMime || actualMime !== mime) {
+ throw createError({ statusCode: 400, message: 'File content does not match declared type' })
+ }
+
+ const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
const filename = `${user.id}.${ext}`
const dir = getAvatarsDir()
const path = join(dir, filename)
diff --git a/server/api/me/cot-password.put.js b/server/api/me/cot-password.put.js
new file mode 100644
index 0000000..73c19bd
--- /dev/null
+++ b/server/api/me/cot-password.put.js
@@ -0,0 +1,26 @@
+import { getDb } from '../../utils/db.js'
+import { requireAuth } from '../../utils/authHelpers.js'
+import { hashPassword } from '../../utils/password.js'
+
+export default defineEventHandler(async (event) => {
+ const currentUser = requireAuth(event)
+ const body = await readBody(event).catch(() => ({}))
+ const password = body?.password
+
+ if (typeof password !== 'string' || password.length < 1) {
+ throw createError({ statusCode: 400, message: 'Password is required' })
+ }
+
+ const { get, run } = await getDb()
+ const user = await get(
+ 'SELECT id, auth_provider FROM users WHERE id = ?',
+ [currentUser.id],
+ )
+ if (!user) {
+ throw createError({ statusCode: 404, message: 'User not found' })
+ }
+
+ const hash = hashPassword(password)
+ await run('UPDATE users SET cot_password_hash = ? WHERE id = ?', [hash, currentUser.id])
+ return { ok: true }
+})
diff --git a/server/api/pois.post.js b/server/api/pois.post.js
index 8c588b8..ca22a0b 100644
--- a/server/api/pois.post.js
+++ b/server/api/pois.post.js
@@ -1,6 +1,6 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
-import { POI_ICON_TYPES } from '../utils/poiConstants.js'
+import { POI_ICON_TYPES } from '../utils/validation.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
diff --git a/server/api/pois/[id].patch.js b/server/api/pois/[id].patch.js
index 3dd6ded..12b8638 100644
--- a/server/api/pois/[id].patch.js
+++ b/server/api/pois/[id].patch.js
@@ -1,39 +1,37 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
-import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
+import { POI_ICON_TYPES } from '../../utils/validation.js'
+import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event)) || {}
- const updates = []
- const params = []
+ const updates = {}
if (typeof body.label === 'string') {
- updates.push('label = ?')
- params.push(body.label.trim())
+ updates.label = body.label.trim()
}
if (POI_ICON_TYPES.includes(body.iconType)) {
- updates.push('icon_type = ?')
- params.push(body.iconType)
+ updates.icon_type = body.iconType
}
if (Number.isFinite(body.lat)) {
- updates.push('lat = ?')
- params.push(body.lat)
+ updates.lat = body.lat
}
if (Number.isFinite(body.lng)) {
- updates.push('lng = ?')
- params.push(body.lng)
+ updates.lng = body.lng
}
- if (updates.length === 0) {
+ if (Object.keys(updates).length === 0) {
const { get } = await getDb()
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row
}
- params.push(id)
const { run, get } = await getDb()
- await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
+ const { query, params } = buildUpdateQuery('pois', null, updates)
+ if (query) {
+ await run(query, [...params, id])
+ }
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row
diff --git a/server/api/users.post.js b/server/api/users.post.js
index d509473..78402c2 100644
--- a/server/api/users.post.js
+++ b/server/api/users.post.js
@@ -1,4 +1,4 @@
-import { getDb } from '../utils/db.js'
+import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { hashPassword } from '../utils/password.js'
@@ -21,18 +21,20 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
}
- const { run, get } = await getDb()
- const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
- if (existing) {
- throw createError({ statusCode: 409, message: 'Identifier already in use' })
- }
+ const db = await getDb()
+ return withTransaction(db, async ({ run, get }) => {
+ const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
+ if (existing) {
+ throw createError({ statusCode: 409, message: 'Identifier already in use' })
+ }
- const id = crypto.randomUUID()
- const now = new Date().toISOString()
- await run(
- 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- [id, identifier, hashPassword(password), role, now, 'local', null, null],
- )
- const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
- return user
+ const id = crypto.randomUUID()
+ const now = new Date().toISOString()
+ await run(
+ 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [id, identifier, hashPassword(password), role, now, 'local', null, null],
+ )
+ const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
+ return user
+ })
})
diff --git a/server/api/users/[id].patch.js b/server/api/users/[id].patch.js
index 19ef36a..57465e5 100644
--- a/server/api/users/[id].patch.js
+++ b/server/api/users/[id].patch.js
@@ -1,6 +1,7 @@
-import { getDb } from '../../utils/db.js'
+import { getDb, withTransaction } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js'
+import { buildUpdateQuery } from '../../utils/queryBuilder.js'
const ROLES = ['admin', 'leader', 'member']
@@ -9,52 +10,52 @@ export default defineEventHandler(async (event) => {
const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event)
- const { run, get } = await getDb()
+ const db = await getDb()
- const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
- if (!user) throw createError({ statusCode: 404, message: 'User not found' })
+ return withTransaction(db, async ({ run, get }) => {
+ const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
+ if (!user) throw createError({ statusCode: 404, message: 'User not found' })
- const updates = []
- const params = []
+ const updates = {}
- if (body?.role !== undefined) {
- const role = body.role
- if (!role || !ROLES.includes(role)) {
- throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
- }
- updates.push('role = ?')
- params.push(role)
- }
-
- if (user.auth_provider === 'local') {
- if (body?.identifier !== undefined) {
- const identifier = body.identifier?.trim()
- if (!identifier || identifier.length < 1) {
- throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
+ if (body?.role !== undefined) {
+ const role = body.role
+ if (!role || !ROLES.includes(role)) {
+ throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
}
- const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
- if (existing) {
- throw createError({ statusCode: 409, message: 'Identifier already in use' })
- }
- updates.push('identifier = ?')
- params.push(identifier)
+ updates.role = role
}
- if (body?.password !== undefined && body.password !== '') {
- const password = body.password
- if (typeof password !== 'string' || password.length < 1) {
- throw createError({ statusCode: 400, message: 'password cannot be empty' })
+
+ if (user.auth_provider === 'local') {
+ if (body?.identifier !== undefined) {
+ const identifier = body.identifier?.trim()
+ if (!identifier || identifier.length < 1) {
+ throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
+ }
+ const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
+ if (existing) {
+ throw createError({ statusCode: 409, message: 'Identifier already in use' })
+ }
+ updates.identifier = identifier
+ }
+ if (body?.password !== undefined && body.password !== '') {
+ const password = body.password
+ if (typeof password !== 'string' || password.length < 1) {
+ throw createError({ statusCode: 400, message: 'password cannot be empty' })
+ }
+ updates.password_hash = hashPassword(password)
}
- updates.push('password_hash = ?')
- params.push(hashPassword(password))
}
- }
- if (updates.length === 0) {
- return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
- }
+ if (Object.keys(updates).length === 0) {
+ return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
+ }
- params.push(id)
- await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
- const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
- return updated
+ const { query, params } = buildUpdateQuery('users', null, updates)
+ if (query) {
+ await run(query, [...params, id])
+ }
+ const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
+ return updated
+ })
})
diff --git a/server/middleware/auth.js b/server/middleware/auth.js
index dbcb8e4..9fb0908 100644
--- a/server/middleware/auth.js
+++ b/server/middleware/auth.js
@@ -1,6 +1,6 @@
import { getCookie } from 'h3'
import { getDb } from '../utils/db.js'
-import { skipAuth } from '../utils/authSkipPaths.js'
+import { skipAuth } from '../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
if (skipAuth(event.path)) return
diff --git a/server/plugins/cot.js b/server/plugins/cot.js
new file mode 100644
index 0000000..0172cd7
--- /dev/null
+++ b/server/plugins/cot.js
@@ -0,0 +1,262 @@
+import { createServer as createTcpServer } from 'node:net'
+import { createServer as createTlsServer } from 'node:tls'
+import { readFileSync, existsSync } from 'node:fs'
+import { updateFromCot } from '../utils/cotStore.js'
+import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../utils/cotParser.js'
+import { validateCotAuth } from '../utils/cotAuth.js'
+import { getCotSslPaths, getCotPort } from '../utils/cotSsl.js'
+import { registerCleanup } from '../utils/shutdown.js'
+import { COT_AUTH_TIMEOUT_MS } from '../utils/constants.js'
+import { acquire } from '../utils/asyncLock.js'
+
+const serverState = {
+ tcpServer: null,
+ tlsServer: null,
+}
+const relaySet = new Set()
+const allSockets = new Set()
+const socketBuffers = new WeakMap()
+const socketAuthTimeout = new WeakMap()
+
+function clearAuthTimeout(socket) {
+ const t = socketAuthTimeout.get(socket)
+ if (t) {
+ clearTimeout(t)
+ socketAuthTimeout.delete(socket)
+ }
+}
+
+function removeFromRelay(socket) {
+ relaySet.delete(socket)
+ allSockets.delete(socket)
+ clearAuthTimeout(socket)
+ socketBuffers.delete(socket)
+}
+
+function broadcast(senderSocket, rawMessage) {
+ for (const s of relaySet) {
+ if (s !== senderSocket && !s.destroyed && s.writable) {
+ try {
+ s.write(rawMessage)
+ }
+ catch (err) {
+ console.error('[cot] Broadcast write error:', err?.message)
+ }
+ }
+ }
+}
+
+const createPreview = (payload) => {
+ try {
+ const str = payload.toString('utf8')
+ if (str.startsWith('<')) {
+ const s = str.length <= 120 ? str : str.slice(0, 120) + '...'
+ // eslint-disable-next-line no-control-regex -- sanitize control chars for log preview
+ return s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
+ }
+ return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
+ }
+ catch {
+ return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
+ }
+}
+
+async function processFrame(socket, rawMessage, payload, authenticated) {
+ const requireAuth = socket._cotRequireAuth !== false
+ const debug = socket._cotDebug === true
+ const parsed = parseCotPayload(payload)
+ if (debug) {
+ const preview = createPreview(payload)
+ console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
+ }
+ if (!parsed) return
+
+ if (parsed.type === 'auth') {
+ if (authenticated) return
+ console.log('[cot] auth attempt username=', parsed.username)
+ // Use lock per socket to prevent concurrent auth attempts
+ const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
+ await acquire(socketKey, async () => {
+ // Re-check authentication state after acquiring lock
+ if (socket._cotAuthenticated || socket.destroyed) return
+ try {
+ const valid = await validateCotAuth(parsed.username, parsed.password)
+ console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
+ if (!socket.writable || socket.destroyed) return
+ if (valid) {
+ clearAuthTimeout(socket)
+ relaySet.add(socket)
+ socket._cotAuthenticated = true
+ }
+ else {
+ socket.destroy()
+ }
+ }
+ catch (err) {
+ console.log('[cot] auth validation error:', err?.message)
+ if (!socket.destroyed) socket.destroy()
+ }
+ }).catch((err) => {
+ console.log('[cot] auth lock error:', err?.message)
+ if (!socket.destroyed) socket.destroy()
+ })
+ return
+ }
+
+ if (parsed.type === 'cot') {
+ if (requireAuth && !authenticated) {
+ socket.destroy()
+ return
+ }
+ updateFromCot(parsed).catch((err) => {
+ console.error('[cot] Error updating from CoT:', err?.message)
+ })
+ if (authenticated) broadcast(socket, rawMessage)
+ }
+}
+
+const parseFrame = (buf) => {
+ const takResult = parseTakStreamFrame(buf)
+ if (takResult) return { result: takResult, frameType: 'tak' }
+ if (buf[0] === 0x3C) {
+ const xmlResult = parseTraditionalXmlFrame(buf)
+ if (xmlResult) return { result: xmlResult, frameType: 'traditional' }
+ }
+ return { result: null, frameType: null }
+}
+
+const processBufferedData = async (socket, buf, authenticated) => {
+ if (buf.length === 0) return buf
+ const { result, frameType } = parseFrame(buf)
+ if (result && socket._cotDebug) {
+ console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
+ }
+ if (!result) return buf
+ const { payload, bytesConsumed } = result
+ const rawMessage = buf.subarray(0, bytesConsumed)
+ await processFrame(socket, rawMessage, payload, authenticated)
+ if (socket.destroyed) return null
+ const remainingBuf = buf.subarray(bytesConsumed)
+ socketBuffers.set(socket, remainingBuf)
+ return processBufferedData(socket, remainingBuf, authenticated)
+}
+
+async function onData(socket, data) {
+ const existingBuf = socketBuffers.get(socket)
+ const buf = Buffer.concat([existingBuf || Buffer.alloc(0), data])
+ socketBuffers.set(socket, buf)
+ const authenticated = Boolean(socket._cotAuthenticated)
+
+ if (socket._cotDebug && buf.length > 0 && !socket._cotFirstChunkLogged) {
+ socket._cotFirstChunkLogged = true
+ const hex = buf.subarray(0, Math.min(80, buf.length)).toString('hex')
+ console.log('[cot] first chunk len=', buf.length, 'first bytes (hex):', hex, 'starts with 0xBF:', buf[0] === 0xBF, 'starts with <:', buf[0] === 0x3C)
+ }
+ await processBufferedData(socket, buf, authenticated)
+}
+
+function setupSocket(socket, tls = false) {
+ const remote = socket.remoteAddress || 'unknown'
+ console.log('[cot] client connected', tls ? '(TLS)' : '(TCP)', 'from', remote)
+ allSockets.add(socket)
+ const config = useRuntimeConfig()
+ socket._cotDebug = Boolean(config.cotDebug)
+ socket._cotRequireAuth = config.cotRequireAuth !== false
+ if (socket._cotRequireAuth) {
+ const timeout = setTimeout(() => {
+ if (!socket._cotAuthenticated && !socket.destroyed) {
+ console.log('[cot] auth timeout, closing connection from', remote)
+ socket.destroy()
+ }
+ }, COT_AUTH_TIMEOUT_MS)
+ socketAuthTimeout.set(socket, timeout)
+ }
+ else {
+ socket._cotAuthenticated = true
+ relaySet.add(socket)
+ }
+
+ socket.on('data', data => onData(socket, data))
+ socket.on('error', (err) => {
+ console.error('[cot] Socket error:', err?.message)
+ })
+ socket.on('close', () => {
+ console.log('[cot] client disconnected', socket._cotAuthenticated ? '(was authenticated)' : '', 'from', remote)
+ removeFromRelay(socket)
+ })
+}
+
+function startCotServers() {
+ const config = useRuntimeConfig()
+ const { certPath, keyPath } = getCotSslPaths(config) || {}
+ const hasTls = certPath && keyPath && existsSync(certPath) && existsSync(keyPath)
+ const port = getCotPort()
+
+ try {
+ if (hasTls) {
+ const tlsOpts = {
+ cert: readFileSync(certPath),
+ key: readFileSync(keyPath),
+ rejectUnauthorized: false,
+ }
+ serverState.tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
+ serverState.tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
+ serverState.tlsServer.listen(port, '0.0.0.0', () => {
+ console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (TLS) - use this port in ATAK/iTAK and enable SSL')
+ })
+ }
+ else {
+ serverState.tcpServer = createTcpServer(socket => setupSocket(socket, false))
+ serverState.tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
+ serverState.tcpServer.listen(port, '0.0.0.0', () => {
+ console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (plain TCP) - use this port in ATAK/iTAK with SSL disabled')
+ })
+ }
+ }
+ catch (err) {
+ console.error('[cot] Failed to start CoT server:', err?.message)
+ if (err?.code === 'EADDRINUSE') {
+ console.error('[cot] Port', port, 'is already in use. Stop the other process or set COT_PORT to a different port.')
+ }
+ }
+}
+
+export default defineNitroPlugin((nitroApp) => {
+ nitroApp.hooks.hook('ready', startCotServers)
+ // Start immediately so CoT is up before first request in dev; ready may fire late in some setups.
+ setImmediate(startCotServers)
+
+ const cleanupServers = () => {
+ if (serverState.tcpServer) {
+ serverState.tcpServer.close()
+ serverState.tcpServer = null
+ }
+ if (serverState.tlsServer) {
+ serverState.tlsServer.close()
+ serverState.tlsServer = null
+ }
+ }
+
+ const cleanupSockets = () => {
+ for (const s of allSockets) {
+ try {
+ s.destroy()
+ }
+ catch {
+ /* ignore */
+ }
+ }
+ allSockets.clear()
+ relaySet.clear()
+ }
+
+ registerCleanup(async () => {
+ cleanupSockets()
+ cleanupServers()
+ })
+
+ nitroApp.hooks.hook('close', async () => {
+ cleanupSockets()
+ cleanupServers()
+ })
+})
diff --git a/server/plugins/websocket.js b/server/plugins/websocket.js
index 3d2cecf..f8cf7c6 100644
--- a/server/plugins/websocket.js
+++ b/server/plugins/websocket.js
@@ -1,6 +1,7 @@
import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
+import { registerCleanup } from '../utils/shutdown.js'
function parseCookie(cookieHeader) {
const cookies = {}
@@ -79,8 +80,15 @@ export default defineNitroPlugin((nitroApp) => {
callback(false, 401, 'Unauthorized')
return
}
- // Store user_id in request for later use
+ // Get user role for authorization checks
+ const user = await get('SELECT id, role FROM users WHERE id = ?', [session.user_id])
+ if (!user) {
+ callback(false, 401, 'Unauthorized')
+ return
+ }
+ // Store user_id and role in request for later use
info.req.userId = session.user_id
+ info.req.userRole = user.role
callback(true)
}
catch (err) {
@@ -92,7 +100,8 @@ export default defineNitroPlugin((nitroApp) => {
wss.on('connection', (ws, req) => {
const userId = req.userId
- if (!userId) {
+ const userRole = req.userRole
+ if (!userId || !userRole) {
ws.close(1008, 'Unauthorized')
return
}
@@ -109,6 +118,20 @@ export default defineNitroPlugin((nitroApp) => {
return
}
+ // Verify user has access to this session (authorization check per message)
+ const { getLiveSession } = await import('../utils/liveSessions.js')
+ const session = getLiveSession(sessionId)
+ if (!session) {
+ ws.send(JSON.stringify({ error: 'Session not found' }))
+ return
+ }
+
+ // Only session owner or admin/leader can access the session
+ if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
+ ws.send(JSON.stringify({ error: 'Forbidden' }))
+ return
+ }
+
// Track session connection
if (currentSessionId !== sessionId) {
if (currentSessionId) {
@@ -142,6 +165,13 @@ export default defineNitroPlugin((nitroApp) => {
})
console.log('[websocket] WebSocket server started on /ws')
+
+ registerCleanup(async () => {
+ if (wss) {
+ wss.close()
+ wss = null
+ }
+ })
})
nitroApp.hooks.hook('close', () => {
diff --git a/server/routes/health/ready.get.js b/server/routes/health/ready.get.js
index 9889c5d..d7ad9c9 100644
--- a/server/routes/health/ready.get.js
+++ b/server/routes/health/ready.get.js
@@ -1 +1,9 @@
-export default defineEventHandler(() => ({ status: 'ready' }))
+import { healthCheck } from '../../utils/db.js'
+
+export default defineEventHandler(async () => {
+ const health = await healthCheck()
+ if (!health.healthy) {
+ throw createError({ statusCode: 503, message: 'Database not ready' })
+ }
+ return { status: 'ready' }
+})
diff --git a/server/utils/asyncLock.js b/server/utils/asyncLock.js
new file mode 100644
index 0000000..32eacf5
--- /dev/null
+++ b/server/utils/asyncLock.js
@@ -0,0 +1,47 @@
+/**
+ * Async lock utility - Promise-based mutex per key.
+ * Ensures only one async operation executes per key at a time.
+ */
+
+const locks = new Map()
+
+/**
+ * Get or create a queue for a lock key.
+ * @param {string} lockKey - Lock key
+ * @returns {Promise} Existing or new queue promise
+ */
+const getOrCreateQueue = (lockKey) => {
+ const existingQueue = locks.get(lockKey)
+ if (existingQueue) return existingQueue
+ const newQueue = Promise.resolve()
+ locks.set(lockKey, newQueue)
+ return newQueue
+}
+
+/**
+ * Acquire a lock for a key and execute callback.
+ * Only one callback per key executes at a time.
+ * @param {string} key - Lock key
+ * @param {Function} callback - Async function to execute
+ * @returns {Promise} Result of callback
+ */
+export async function acquire(key, callback) {
+ const lockKey = String(key)
+ const queue = getOrCreateQueue(lockKey)
+
+ const next = queue.then(() => callback()).finally(() => {
+ if (locks.get(lockKey) === next) {
+ locks.delete(lockKey)
+ }
+ })
+
+ locks.set(lockKey, next)
+ return next
+}
+
+/**
+ * Clear all locks (for testing).
+ */
+export function clearLocks() {
+ locks.clear()
+}
diff --git a/server/utils/authConfig.js b/server/utils/authConfig.js
deleted file mode 100644
index 707d9c0..0000000
--- a/server/utils/authConfig.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export function getAuthConfig() {
- const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
- const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
- return Object.freeze({ oidc: { enabled: hasOidc, label } })
-}
diff --git a/server/utils/authHelpers.js b/server/utils/authHelpers.js
index efc28ba..f9b590c 100644
--- a/server/utils/authHelpers.js
+++ b/server/utils/authHelpers.js
@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
return user
}
+
+// Auth path utilities
+export const SKIP_PATHS = Object.freeze([
+ '/api/auth/login',
+ '/api/auth/logout',
+ '/api/auth/config',
+ '/api/auth/oidc/authorize',
+ '/api/auth/oidc/callback',
+])
+
+export const PROTECTED_PATH_PREFIXES = Object.freeze([
+ '/api/cameras',
+ '/api/devices',
+ '/api/live',
+ '/api/me',
+ '/api/pois',
+ '/api/users',
+])
+
+export function skipAuth(path) {
+ if (path.startsWith('/api/health') || path === '/health') return true
+ return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
+}
diff --git a/server/utils/authSkipPaths.js b/server/utils/authSkipPaths.js
deleted file mode 100644
index 831e5e5..0000000
--- a/server/utils/authSkipPaths.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
-export const SKIP_PATHS = Object.freeze([
- '/api/auth/login',
- '/api/auth/logout',
- '/api/auth/config',
- '/api/auth/oidc/authorize',
- '/api/auth/oidc/callback',
-])
-
-/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
-export const PROTECTED_PATH_PREFIXES = Object.freeze([
- '/api/cameras',
- '/api/devices',
- '/api/live',
- '/api/me',
- '/api/pois',
- '/api/users',
-])
-
-export function skipAuth(path) {
- if (path.startsWith('/api/health') || path === '/health') return true
- return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
-}
diff --git a/server/utils/bootstrap.js b/server/utils/bootstrap.js
deleted file mode 100644
index 40c7a16..0000000
--- a/server/utils/bootstrap.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { randomBytes } from 'node:crypto'
-import { hashPassword } from './password.js'
-
-const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
-
-const generateRandomPassword = () =>
- Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
-
-export async function bootstrapAdmin(run, get) {
- const row = await get('SELECT COUNT(*) as n FROM users')
- if (row?.n !== 0) return
-
- const email = process.env.BOOTSTRAP_EMAIL?.trim()
- const password = process.env.BOOTSTRAP_PASSWORD
- const identifier = (email && password) ? email : 'admin'
- const plainPassword = (email && password) ? password : generateRandomPassword()
-
- await run(
- 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- [crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
- )
-
- if (!email || !password) {
- console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
- }
-}
diff --git a/server/utils/constants.js b/server/utils/constants.js
new file mode 100644
index 0000000..fa43575
--- /dev/null
+++ b/server/utils/constants.js
@@ -0,0 +1,30 @@
+/**
+ * Application constants with environment variable support.
+ */
+
+// Timeouts (milliseconds)
+export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
+export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
+export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
+export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
+export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
+
+// Ports
+export const COT_PORT = Number(process.env.COT_PORT) || 8089
+export const WEBSOCKET_PATH = process.env.WEBSOCKET_PATH || '/ws'
+
+// Limits
+export const MAX_PAYLOAD_BYTES = Number(process.env.MAX_PAYLOAD_BYTES) || 64 * 1024
+export const MAX_STRING_LENGTH = Number(process.env.MAX_STRING_LENGTH) || 1000
+export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) || 255
+
+// Mediasoup
+export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
+export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
+
+// Session
+const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
+export function getSessionMaxAgeDays() {
+ const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
+ return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
+}
diff --git a/server/utils/cotAuth.js b/server/utils/cotAuth.js
new file mode 100644
index 0000000..0e1895b
--- /dev/null
+++ b/server/utils/cotAuth.js
@@ -0,0 +1,25 @@
+import { getDb } from './db.js'
+import { verifyPassword } from './password.js'
+
+/**
+ * Validate CoT auth: local users use password_hash; OIDC users use cot_password_hash (ATAK password).
+ * @param {string} identifier - KestrelOS identifier (username)
+ * @param {string} password - Plain password from CoT auth
+ * @returns {Promise} True if valid
+ */
+export async function validateCotAuth(identifier, password) {
+ const id = typeof identifier === 'string' ? identifier.trim() : ''
+ if (!id || typeof password !== 'string') return false
+
+ const { get } = await getDb()
+ const user = await get(
+ 'SELECT auth_provider, password_hash, cot_password_hash FROM users WHERE identifier = ?',
+ [id],
+ )
+ if (!user) return false
+
+ const hash = user.auth_provider === 'local' ? user.password_hash : user.cot_password_hash
+ if (!hash) return false
+
+ return verifyPassword(password, hash)
+}
diff --git a/server/utils/cotParser.js b/server/utils/cotParser.js
new file mode 100644
index 0000000..49b4530
--- /dev/null
+++ b/server/utils/cotParser.js
@@ -0,0 +1,151 @@
+import { XMLParser } from 'fast-xml-parser'
+import { MAX_PAYLOAD_BYTES } from './constants.js'
+
+// CoT protocol detection constants
+export const COT_FIRST_BYTE_TAK = 0xBF
+export const COT_FIRST_BYTE_XML = 0x3C
+
+/** @param {number} byte - First byte of stream. @returns {boolean} */
+export function isCotFirstByte(byte) {
+ return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
+}
+
+const TRADITIONAL_DELIMITER = Buffer.from('', 'utf8')
+
+/**
+ * @param {Buffer} buf
+ * @param {number} offset
+ * @param {number} value - Accumulated value
+ * @param {number} shift - Current bit shift
+ * @param {number} bytesRead - Bytes consumed so far
+ * @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
+ */
+function readVarint(buf, offset, value = 0, shift = 0, bytesRead = 0) {
+ if (offset + bytesRead >= buf.length) return { value, bytesRead }
+ const b = buf[offset + bytesRead]
+ const newValue = value + ((b & 0x7F) << shift)
+ const newBytesRead = bytesRead + 1
+ if ((b & 0x80) === 0) return { value: newValue, bytesRead: newBytesRead }
+ const newShift = shift + 7
+ if (newShift > 28) return { value: 0, bytesRead: 0 }
+ return readVarint(buf, offset, newValue, newShift, newBytesRead)
+}
+
+/**
+ * TAK stream frame: 0xBF, varint length, payload.
+ * @param {Buffer} buf
+ * @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
+ */
+export function parseTakStreamFrame(buf) {
+ if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
+ const { value: length, bytesRead } = readVarint(buf, 1)
+ if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
+ const bytesConsumed = 1 + bytesRead + length
+ if (buf.length < bytesConsumed) return null
+ return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed }
+}
+
+/**
+ * Traditional CoT: one XML message delimited by .
+ * @param {Buffer} buf
+ * @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
+ */
+export function parseTraditionalXmlFrame(buf) {
+ if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
+ const idx = buf.indexOf(TRADITIONAL_DELIMITER)
+ if (idx === -1) return null
+ const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
+ if (bytesConsumed > MAX_PAYLOAD_BYTES) return null
+ return { payload: buf.subarray(0, bytesConsumed), bytesConsumed }
+}
+
+const xmlParser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ parseTagValue: false,
+ ignoreDeclaration: true,
+ ignorePiTags: true,
+ processEntities: false, // Disable entity expansion to prevent XML bomb attacks
+ maxAttributes: 100,
+ parseAttributeValue: false,
+ trimValues: true,
+ parseTrueNumberOnly: false,
+ arrayMode: false,
+ stopNodes: [], // Could add depth limit here if needed
+})
+
+/**
+ * Case-insensitive key lookup in nested object.
+ * @returns {unknown} Found value or undefined.
+ */
+function findInObject(obj, key) {
+ if (!obj || typeof obj !== 'object') return undefined
+ const k = key.toLowerCase()
+ for (const [name, val] of Object.entries(obj)) {
+ if (name.toLowerCase() === k) return val
+ if (typeof val === 'object' && val !== null) {
+ const found = findInObject(val, key)
+ if (found !== undefined) return found
+ }
+ }
+ return undefined
+}
+
+/**
+ * Extract { username, password } from detail.auth (or __auth / credentials).
+ * @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid.
+ */
+function extractAuth(parsed) {
+ const detail = findInObject(parsed, 'detail')
+ if (!detail || typeof detail !== 'object') return null
+ const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials')
+ if (!auth || typeof auth !== 'object') return null
+ const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username
+ const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password
+ if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null
+ return { username: username.trim(), password }
+}
+
+/**
+ * Parse CoT XML payload into auth or position. Does not mutate payload.
+ * @param {Buffer} payload - UTF-8 XML
+ * @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null.
+ */
+export function parseCotPayload(payload) {
+ if (!payload?.length) return null
+ const str = payload.toString('utf8').trim()
+ if (!str.startsWith('<')) return null
+ try {
+ const parsed = xmlParser.parse(str)
+ const event = findInObject(parsed, 'event')
+ if (!event || typeof event !== 'object') return null
+
+ const auth = extractAuth(parsed)
+ if (auth) return { type: 'auth', username: auth.username, password: auth.password }
+
+ const uid = String(event['@_uid'] ?? event.uid ?? '')
+ const eventType = String(event['@_type'] ?? event.type ?? '')
+ const point = findInObject(parsed, 'point') ?? findInObject(event, 'point')
+ const extractCoords = (pt) => {
+ if (!pt || typeof pt !== 'object') return { lat: Number.NaN, lng: Number.NaN }
+ return {
+ lat: Number(pt['@_lat'] ?? pt.lat),
+ lng: Number(pt['@_lon'] ?? pt.lon ?? pt['@_lng'] ?? pt.lng),
+ }
+ }
+ const { lat, lng } = extractCoords(point)
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
+
+ const detail = findInObject(parsed, 'detail')
+ const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null
+ const callsign = contact && typeof contact === 'object'
+ ? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign'])
+ : ''
+ const label = typeof callsign === 'string' ? callsign.trim() || uid : uid
+
+ return { type: 'cot', id: uid, lat, lng, label, eventType }
+ }
+ catch {
+ return null
+ }
+}
diff --git a/server/utils/cotSsl.js b/server/utils/cotSsl.js
new file mode 100644
index 0000000..586bc5a
--- /dev/null
+++ b/server/utils/cotSsl.js
@@ -0,0 +1,73 @@
+import { existsSync, readFileSync, unlinkSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { tmpdir } from 'node:os'
+import { execSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+
+/** Default password for the CoT trust store (document in atak-itak.md). */
+export const TRUSTSTORE_PASSWORD = 'kestrelos'
+
+/** Default CoT server port. */
+export const DEFAULT_COT_PORT = 8089
+
+/**
+ * CoT port from env or default.
+ * @returns {number} Port number (COT_PORT env or DEFAULT_COT_PORT).
+ */
+export function getCotPort() {
+ return Number(process.env.COT_PORT ?? DEFAULT_COT_PORT)
+}
+
+/** Message when an endpoint requires TLS but server is not using it. */
+export const COT_TLS_REQUIRED_MESSAGE = 'Only available when the server runs with SSL (e.g. .dev-certs or COT_SSL_*).'
+
+/**
+ * Resolve CoT server TLS cert and key paths (for plugin and API).
+ * @param {{ cotSslCert?: string, cotSslKey?: string }} [config] - Runtime config (optional)
+ * @returns {{ certPath: string, keyPath: string } | null} Paths when TLS is configured, else null.
+ */
+export function getCotSslPaths(config = {}) {
+ if (process.env.COT_SSL_CERT && process.env.COT_SSL_KEY) {
+ return { certPath: process.env.COT_SSL_CERT, keyPath: process.env.COT_SSL_KEY }
+ }
+ if (config.cotSslCert && config.cotSslKey) {
+ return { certPath: config.cotSslCert, keyPath: config.cotSslKey }
+ }
+ const candidates = [
+ join(process.cwd(), '.dev-certs', 'cert.pem'),
+ join(__dirname, '../../.dev-certs', 'cert.pem'),
+ ]
+ for (const certPath of candidates) {
+ const keyPath = certPath.replace('cert.pem', 'key.pem')
+ if (existsSync(certPath) && existsSync(keyPath)) {
+ return { certPath, keyPath }
+ }
+ }
+ return null
+}
+
+/**
+ * Build a P12 trust store from a PEM cert path (for truststore download and server package).
+ * @param {string} certPath - Path to cert.pem
+ * @param {string} password - P12 password
+ * @returns {Buffer} P12 buffer
+ * @throws {Error} If openssl fails
+ */
+export function buildP12FromCertPath(certPath, password) {
+ const outPath = join(tmpdir(), `kestrelos-cot-p12-${Date.now()}.p12`)
+ try {
+ execSync(
+ `openssl pkcs12 -export -nokeys -in "${certPath}" -out "${outPath}" -passout pass:${password}`,
+ { stdio: 'pipe' },
+ )
+ const p12 = readFileSync(outPath)
+ unlinkSync(outPath)
+ return p12
+ }
+ catch (err) {
+ if (existsSync(outPath)) unlinkSync(outPath)
+ throw err
+ }
+}
diff --git a/server/utils/cotStore.js b/server/utils/cotStore.js
new file mode 100644
index 0000000..09e920f
--- /dev/null
+++ b/server/utils/cotStore.js
@@ -0,0 +1,71 @@
+/**
+ * In-memory CoT entity store: upsert by id, prune on read by TTL.
+ * Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
+ */
+
+import { acquire } from './asyncLock.js'
+import { COT_ENTITY_TTL_MS } from './constants.js'
+
+const entities = new Map()
+
+/**
+ * Upsert entity by id. Input is not mutated; stored value is a new object.
+ * @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
+ */
+export async function updateFromCot(parsed) {
+ if (!parsed || typeof parsed.id !== 'string') return
+ const lat = Number(parsed.lat)
+ const lng = Number(parsed.lng)
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
+
+ await acquire(`cot-${parsed.id}`, async () => {
+ const now = Date.now()
+ const existing = entities.get(parsed.id)
+ const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
+ const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
+
+ entities.set(parsed.id, {
+ id: parsed.id,
+ lat,
+ lng,
+ label,
+ type,
+ updatedAt: now,
+ })
+ })
+}
+
+/**
+ * Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
+ * @param {number} [ttlMs]
+ * @returns {Promise>} Snapshot of active entities.
+ */
+export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
+ return acquire('cot-prune', async () => {
+ const now = Date.now()
+ const active = []
+ const expired = []
+ for (const entity of entities.values()) {
+ if (now - entity.updatedAt <= ttlMs) {
+ active.push({
+ id: entity.id,
+ lat: entity.lat,
+ lng: entity.lng,
+ label: entity.label ?? entity.id,
+ type: entity.type ?? '',
+ updatedAt: entity.updatedAt,
+ })
+ }
+ else {
+ expired.push(entity.id)
+ }
+ }
+ for (const id of expired) entities.delete(id)
+ return active
+ })
+}
+
+/** Clear store (tests only). */
+export function clearCotStore() {
+ entities.clear()
+}
diff --git a/server/utils/db.js b/server/utils/db.js
index 8a38b32..f75e33f 100644
--- a/server/utils/db.js
+++ b/server/utils/db.js
@@ -2,12 +2,15 @@ import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { promisify } from 'node:util'
-import { bootstrapAdmin } from './bootstrap.js'
+import { randomBytes } from 'node:crypto'
+import { hashPassword } from './password.js'
+import { registerCleanup } from './shutdown.js'
-const require = createRequire(import.meta.url)
-const sqlite3 = require('sqlite3')
+// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
+const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
+const sqlite3 = requireFromRoot('sqlite3')
-const SCHEMA_VERSION = 3
+const SCHEMA_VERSION = 4
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -111,6 +114,12 @@ const migrateToV3 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
+const migrateToV4 = async (run, all) => {
+ const info = await all('PRAGMA table_info(users)')
+ if (info.some(c => c.name === 'cot_password_hash')) return
+ await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
+}
+
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -122,6 +131,10 @@ const runMigrations = async (run, all, get) => {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
+ if (version < 4) {
+ await migrateToV4(run, all)
+ await setSchemaVersion(run, 4)
+ }
}
const initDb = async (db, run, all, get) => {
@@ -140,7 +153,29 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.pois)
await run(SCHEMA.devices)
- if (!testPath) await bootstrapAdmin(run, get)
+ if (!testPath) {
+ // Bootstrap admin user on first run
+ const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
+ const generateRandomPassword = () =>
+ Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
+
+ const row = await get('SELECT COUNT(*) as n FROM users')
+ if (row?.n === 0) {
+ const email = process.env.BOOTSTRAP_EMAIL?.trim()
+ const password = process.env.BOOTSTRAP_PASSWORD
+ const identifier = (email && password) ? email : 'admin'
+ const plainPassword = (email && password) ? password : generateRandomPassword()
+
+ await run(
+ 'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
+ )
+
+ if (!email || !password) {
+ console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
+ }
+ }
+ }
}
export async function getDb() {
@@ -167,9 +202,91 @@ export async function getDb() {
}
dbInstance = { db, run, all, get }
+
+ registerCleanup(async () => {
+ if (dbInstance) {
+ try {
+ await new Promise((resolve, reject) => {
+ dbInstance.db.close((err) => {
+ if (err) reject(err)
+ else resolve()
+ })
+ })
+ }
+ catch (error) {
+ console.error('[db] Error closing database during shutdown:', error?.message)
+ }
+ dbInstance = null
+ }
+ })
+
return dbInstance
}
+/**
+ * Health check for database connection.
+ * @returns {Promise<{ healthy: boolean, error?: string }>} Health status
+ */
+export async function healthCheck() {
+ try {
+ const db = await getDb()
+ await db.get('SELECT 1')
+ return { healthy: true }
+ }
+ catch (error) {
+ return {
+ healthy: false,
+ error: error?.message || String(error),
+ }
+ }
+}
+
+/**
+ * Database connection model documentation:
+ *
+ * KestrelOS uses SQLite with WAL (Write-Ahead Logging) mode for concurrent access.
+ * - Single connection instance shared across all requests (singleton pattern)
+ * - WAL mode allows multiple readers and one writer concurrently
+ * - Connection is initialized on first getDb() call and reused thereafter
+ * - Busy timeout is set to 5000ms to handle concurrent access gracefully
+ * - Transactions are supported via withTransaction() helper
+ *
+ * Concurrency considerations:
+ * - SQLite with WAL handles concurrent reads efficiently
+ * - Writes are serialized (one at a time)
+ * - For high write loads, consider migrating to PostgreSQL
+ * - Current model is suitable for moderate traffic (< 100 req/sec)
+ *
+ * Connection lifecycle:
+ * - Created on first getDb() call
+ * - Persists for application lifetime
+ * - Closed during graceful shutdown
+ * - Test path can be set via setDbPathForTest() for testing
+ */
+
+/**
+ * Execute a callback within a database transaction.
+ * Automatically commits on success or rolls back on error.
+ * @param {object} db - Database instance from getDb()
+ * @param {Function} callback - Async function receiving { run, all, get }
+ * @returns {Promise} Result of callback
+ */
+export async function withTransaction(db, callback) {
+ const { run } = db
+ await run('BEGIN TRANSACTION')
+ try {
+ const result = await callback(db)
+ await run('COMMIT')
+ return result
+ }
+ catch (error) {
+ await run('ROLLBACK').catch(() => {
+ // Ignore rollback errors
+ })
+ throw error
+ }
+}
+
export function closeDb() {
if (!dbInstance) return
try {
diff --git a/server/utils/liveSessions.js b/server/utils/liveSessions.js
index 9e7f40b..ffe9c35 100644
--- a/server/utils/liveSessions.js
+++ b/server/utils/liveSessions.js
@@ -1,47 +1,79 @@
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
+import { acquire } from './asyncLock.js'
+import { LIVE_SESSION_TTL_MS } from './constants.js'
-const TTL_MS = 60_000
const sessions = new Map()
-export const createSession = (userId, label = '') => {
- const id = crypto.randomUUID()
- const session = {
- id,
- userId,
- label: (label || 'Live').trim() || 'Live',
- lat: 0,
- lng: 0,
- updatedAt: Date.now(),
- routerId: null,
- producerId: null,
- transportId: null,
- }
- sessions.set(id, session)
- return session
+export const createSession = async (userId, label = '') => {
+ return acquire(`session-create-${userId}`, async () => {
+ const id = crypto.randomUUID()
+ const session = {
+ id,
+ userId,
+ label: (label || 'Live').trim() || 'Live',
+ lat: 0,
+ lng: 0,
+ updatedAt: Date.now(),
+ routerId: null,
+ producerId: null,
+ transportId: null,
+ }
+ sessions.set(id, session)
+ return session
+ })
+}
+
+/**
+ * Atomically get existing active session or create new one for user.
+ * @param {string} userId - User ID
+ * @param {string} label - Session label
+ * @returns {Promise