major: kestrel is now a tak server (#6)
All checks were successful
ci/woodpecker/push/push Pipeline was successful

## Added

- CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity
- Support for TAK stream protocol and traditional XML CoT messages
- TLS/SSL support with automatic fallback to plain TCP
- Username/password authentication for CoT connections
- Real-time device position tracking with TTL-based expiration (90s default)
- API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password`
- TAK Server section in Settings with QR code for iTAK setup
- ATAK password management in Account page for OIDC users
- CoT device markers on map showing real-time positions
- Comprehensive documentation in `docs/` directory
- Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG`
- Dependencies: `fast-xml-parser`, `jszip`, `qrcode`

## Changed

- Authentication system supports CoT password management for OIDC users
- Database schema includes `cot_password_hash` field
- Test suite refactored to follow functional design principles

## Removed

- Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js`

## Security

- XML entity expansion protection in CoT parser
- Enhanced input validation and SQL injection prevention
- Authentication timeout to prevent hanging connections

## Breaking Changes

- Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations.

## Migration Notes

- OIDC users must set ATAK password via Account settings before connecting
- Docker: expose port 8089 (`-p 8089:8089`)
- Kubernetes: update Helm values to expose port 8089

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-02-17 16:41:41 +00:00
parent b18283d3b3
commit e61e6bc7e3
117 changed files with 5329 additions and 1040 deletions

View File

@@ -0,0 +1,128 @@
/**
* @vitest-environment node
*
* Integration test: built server on 3000 (API) and 8089 (CoT). Uses temp DB and bootstrap
* user so CoT auth can be asserted (socket stays open on success).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { buildAuthCotXml } from '../helpers/fakeAtakClient.js'
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '../..')
const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
execSync(
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
{ cwd: projectRoot, stdio: 'pipe' },
)
}
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
clearTimeout(t)
if (res.ok) return baseURL
}
catch {
// try next
}
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
}
beforeAll(async () => {
ensureDevCerts()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
}
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
}
testState.serverProcess = spawn('node', ['.output/server/index.mjs'], {
cwd: projectRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
}, 120000)
afterAll(() => {
if (testState.serverProcess?.pid) {
testState.serverProcess.kill('SIGTERM')
}
})
it('serves health on port 3000', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
catch {
return tryProtocols(protocols.slice(1))
}
}
const res = await tryProtocols(['https', 'http'])
expect(res?.ok).toBe(true)
const body = await res.json()
expect(body).toHaveProperty('status', 'ok')
expect(body).toHaveProperty('endpoints')
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
s.setTimeout(8000, () => reject(new Error('connect timeout')))
})
await new Promise(r => setTimeout(r, 600))
expect(socket.destroyed).toBe(false)
socket.destroy()
})
})