Compare commits
14 Commits
090e90ea70
...
codex/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
331175ae3c | ||
| e288881c41 | |||
|
|
3069114ffd | ||
|
|
09df127e06 | ||
| 49176b42fd | |||
| e70801ae98 | |||
| 703670e7a0 | |||
|
|
1423dca199 | ||
|
|
5b013947b4 | ||
|
|
5113e341b2 | ||
| d7f10bf545 | |||
|
|
6096a0ad03 | ||
|
|
d7df2e4aee | ||
|
|
b2f604b120 |
@@ -10,6 +10,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60
|
|||||||
DASHBOARD_URL=
|
DASHBOARD_URL=
|
||||||
TERMINAL_ACTIONS_ENABLED=true
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
# LLM layer
|
# LLM layer
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -134,6 +134,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60
|
|||||||
DASHBOARD_URL=https://intelligence.example.internal
|
DASHBOARD_URL=https://intelligence.example.internal
|
||||||
TERMINAL_ACTIONS_ENABLED=true
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
LLM_PROVIDER=openrouter
|
LLM_PROVIDER=openrouter
|
||||||
@@ -185,9 +187,22 @@ LLM_MODEL=your-model
|
|||||||
|
|
||||||
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
|
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
|
||||||
|
|
||||||
When data remains stale past `STALE_DATA_MAX_AGE_MINUTES`, the server sends an operator alert through configured Telegram/Discord channels after failed or degraded sweep attempts. `STALE_ALERT_COOLDOWN_MINUTES` prevents repeated stale alerts from spamming every refresh interval. Set `DASHBOARD_URL` to the Pangolin/public URL you want included in those alerts.
|
#### Terminal Action Exposure
|
||||||
|
|
||||||
The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
|
`POST /api/action` and `POST /api/sweep` can trigger operational actions such as manual sweeps. The dashboard has a **SET TOKEN** control that stores your `SWEEP_TOKEN` in browser local storage and sends it as the `x-crucix-token` header; do not put action tokens in URLs.
|
||||||
|
|
||||||
|
Recommended settings:
|
||||||
|
|
||||||
|
| Deployment | Settings |
|
||||||
|
| --- | --- |
|
||||||
|
| Private local machine | `NODE_ENV=development`, optional `SWEEP_TOKEN`, optional `TERMINAL_ACTIONS_ENABLED=true`. Localhost can run actions without a token for development. |
|
||||||
|
| Private LAN / Dockge | Set a strong `SWEEP_TOKEN`, keep `TERMINAL_ACTIONS_ENABLED=true`, expose only to trusted clients. |
|
||||||
|
| Pangolin-authenticated reverse proxy | Set a strong `SWEEP_TOKEN`, keep Pangolin auth in front, use the dashboard **SET TOKEN** flow once per browser. |
|
||||||
|
| Public internet | Do not expose Terminal Actions directly. If exposure is unavoidable, require `SWEEP_TOKEN`, keep proxy authentication enabled, lower `TERMINAL_ACTION_RATE_LIMIT_MAX`, and monitor server audit logs. |
|
||||||
|
|
||||||
|
Action endpoints reject cross-origin POST origins, apply a small in-memory per-IP rate limit, and write sanitized audit lines without logging the token.
|
||||||
|
|
||||||
|
When data remains stale past `STALE_DATA_MAX_AGE_MINUTES`, the server sends an operator alert through configured Telegram/Discord channels after failed or degraded sweep attempts. `STALE_ALERT_COOLDOWN_MINUTES` prevents repeated stale alerts from spamming every refresh interval. Set `DASHBOARD_URL` to the Pangolin/public URL you want included in those alerts.
|
||||||
|
|
||||||
#### Memory And Prediction Loop
|
#### Memory And Prediction Loop
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export async function runSource(name, fn, ...args) {
|
|||||||
});
|
});
|
||||||
const data = await Promise.race([dataPromise, timeoutPromise]);
|
const data = await Promise.race([dataPromise, timeoutPromise]);
|
||||||
const hasError = Boolean(data?.error);
|
const hasError = Boolean(data?.error);
|
||||||
const isDegraded = hasError || ['no_credentials', 'degraded', 'failed'].includes(data?.status);
|
const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
|
||||||
|
const isDegraded = hasError || degradedStatuses.includes(data?.status);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
status: isDegraded ? 'degraded' : 'ok',
|
status: isDegraded ? 'degraded' : 'ok',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
||||||
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
||||||
// Public feed access varies; RapidAPI tier available for programmatic use.
|
// Public feed access varies; RapidAPI tier available for programmatic use.
|
||||||
// This module attempts the public endpoints and falls back to a documented stub.
|
// This module reports explicit disabled/degraded state instead of making
|
||||||
|
// unavailable aircraft data look live.
|
||||||
|
|
||||||
import { safeFetch } from '../utils/fetch.mjs';
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
// Get all military aircraft
|
// Get all military aircraft
|
||||||
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
|
source: 'adsb-rapidapi',
|
||||||
headers: {
|
headers: {
|
||||||
'X-RapidAPI-Key': apiKey,
|
'X-RapidAPI-Key': apiKey,
|
||||||
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
||||||
@@ -151,21 +153,26 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
|
|
||||||
// Attempt to fetch from public feed
|
// Attempt to fetch from public feed
|
||||||
async function fetchPublicFeed() {
|
async function fetchPublicFeed() {
|
||||||
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000 });
|
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000, source: 'adsb-public' });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get military aircraft from available sources
|
async function getMilitaryAircraftResult(apiKey) {
|
||||||
export async function getMilitaryAircraft(apiKey) {
|
const failures = [];
|
||||||
|
|
||||||
// Try RapidAPI first if key available
|
// Try RapidAPI first if key available
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const data = await fetchViaRapidApi(apiKey);
|
const data = await fetchViaRapidApi(apiKey);
|
||||||
if (data && !data.error) {
|
if (data && !data.error) {
|
||||||
const aircraft = data.ac || data.aircraft || [];
|
const aircraft = data.ac || data.aircraft || [];
|
||||||
if (Array.isArray(aircraft)) {
|
if (Array.isArray(aircraft)) {
|
||||||
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
|
return {
|
||||||
|
provider: 'rapidapi',
|
||||||
|
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
failures.push({ provider: 'rapidapi', error: data?.error || 'RapidAPI returned an unsupported payload' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try public feed
|
// Try public feed
|
||||||
@@ -173,11 +180,21 @@ export async function getMilitaryAircraft(apiKey) {
|
|||||||
if (pubData && !pubData.error) {
|
if (pubData && !pubData.error) {
|
||||||
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
||||||
if (Array.isArray(aircraft)) {
|
if (Array.isArray(aircraft)) {
|
||||||
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
|
return {
|
||||||
|
provider: 'public-feed',
|
||||||
|
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
failures.push({ provider: 'public-feed', error: pubData?.error || 'Public feed returned an unsupported payload' });
|
||||||
|
|
||||||
return null; // all sources failed
|
return { provider: null, aircraft: null, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get military aircraft from available sources
|
||||||
|
export async function getMilitaryAircraft(apiKey) {
|
||||||
|
const result = await getMilitaryAircraftResult(apiKey);
|
||||||
|
return result.aircraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all aircraft in a geographic bounding box via RapidAPI
|
// Get all aircraft in a geographic bounding box via RapidAPI
|
||||||
@@ -208,7 +225,8 @@ export async function getAircraftInArea(lat, lon, radiusNm = 250, apiKey) {
|
|||||||
// Briefing — attempt to get military flight data, document what's available
|
// Briefing — attempt to get military flight data, document what's available
|
||||||
export async function briefing() {
|
export async function briefing() {
|
||||||
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
|
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
|
||||||
const militaryAircraft = await getMilitaryAircraft(apiKey);
|
const result = await getMilitaryAircraftResult(apiKey);
|
||||||
|
const militaryAircraft = result.aircraft;
|
||||||
|
|
||||||
// If we got data, analyze it
|
// If we got data, analyze it
|
||||||
if (militaryAircraft && militaryAircraft.length > 0) {
|
if (militaryAircraft && militaryAircraft.length > 0) {
|
||||||
@@ -255,6 +273,7 @@ export async function briefing() {
|
|||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'live',
|
status: 'live',
|
||||||
|
provider: result.provider,
|
||||||
totalMilitary: militaryAircraft.length,
|
totalMilitary: militaryAircraft.length,
|
||||||
byCountry,
|
byCountry,
|
||||||
categories: {
|
categories: {
|
||||||
@@ -269,10 +288,18 @@ export async function briefing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No data available — return stub with integration documentation
|
// No data available — return stub with integration documentation
|
||||||
|
const status = apiKey ? 'degraded' : 'disabled';
|
||||||
|
const error = apiKey
|
||||||
|
? 'ADS-B providers returned no usable aircraft data'
|
||||||
|
: 'ADSB_API_KEY or RAPIDAPI_KEY is not configured';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: apiKey ? 'error' : 'no_key',
|
status,
|
||||||
|
provider: result.provider,
|
||||||
|
error,
|
||||||
|
failures: result.failures,
|
||||||
militaryAircraft: [],
|
militaryAircraft: [],
|
||||||
message: apiKey
|
message: apiKey
|
||||||
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export default {
|
|||||||
staleAlertCooldownMinutes: intEnv('STALE_ALERT_COOLDOWN_MINUTES', 60),
|
staleAlertCooldownMinutes: intEnv('STALE_ALERT_COOLDOWN_MINUTES', 60),
|
||||||
dashboardUrl: process.env.DASHBOARD_URL || null,
|
dashboardUrl: process.env.DASHBOARD_URL || null,
|
||||||
sweepToken: process.env.SWEEP_TOKEN || null,
|
sweepToken: process.env.SWEEP_TOKEN || null,
|
||||||
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true),
|
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
|
||||||
|
terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
|
||||||
|
terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
|
||||||
|
|
||||||
llm: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,4 +16,5 @@ Source docs:
|
|||||||
- [Telegram](telegram.md)
|
- [Telegram](telegram.md)
|
||||||
- [FIRMS](firms.md)
|
- [FIRMS](firms.md)
|
||||||
- [Maritime](maritime.md)
|
- [Maritime](maritime.md)
|
||||||
|
- [ADS-B](adsb.md)
|
||||||
- [Reddit](reddit.md)
|
- [Reddit](reddit.md)
|
||||||
|
|||||||
24
docs/sources/adsb.md
Normal file
24
docs/sources/adsb.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ADS-B Source
|
||||||
|
|
||||||
|
ADS-B Exchange support is optional and intended for unfiltered aircraft and military-flight awareness.
|
||||||
|
|
||||||
|
- Source module: `apis/sources/adsb.mjs`
|
||||||
|
- Preferred provider: ADS-B Exchange via RapidAPI
|
||||||
|
- Credentials: `ADSB_API_KEY` or `RAPIDAPI_KEY`
|
||||||
|
- Runtime status without credentials: `disabled`
|
||||||
|
- Runtime status when providers fail: `degraded`
|
||||||
|
- Runtime status with usable aircraft payloads: `live`
|
||||||
|
|
||||||
|
The source does not treat a missing key or unavailable public feed as normal live data. `/api/health` and `/api/metrics` surface the degraded source state through the sweep source summary.
|
||||||
|
|
||||||
|
Known failure modes:
|
||||||
|
|
||||||
|
- Missing `ADSB_API_KEY` / `RAPIDAPI_KEY`: source is disabled with operator guidance.
|
||||||
|
- RapidAPI rejects or rate-limits the request: source is degraded and records provider failure detail.
|
||||||
|
- Public feed is blocked, rate-limited, or changes shape: source remains degraded instead of returning stale-looking data.
|
||||||
|
|
||||||
|
Register for the provider documented in the README, then set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADSB_API_KEY=<rapidapi-key>
|
||||||
|
```
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"brief:save": "node apis/save-briefing.mjs",
|
"brief:save": "node apis/save-briefing.mjs",
|
||||||
"diag": "node diag.mjs",
|
"diag": "node diag.mjs",
|
||||||
"test": "npm run test:unit",
|
"test": "npm run test:unit",
|
||||||
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/dashboard-geotagging.test.mjs",
|
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/reddit-source.test.mjs test/acled-source.test.mjs test/mojibake-text.test.mjs test/adsb.test.mjs test/dashboard-geotagging.test.mjs",
|
||||||
"compose:config": "docker compose config",
|
"compose:config": "docker compose config",
|
||||||
"clean": "node scripts/clean.mjs",
|
"clean": "node scripts/clean.mjs",
|
||||||
"fresh-start": "npm run clean && npm start"
|
"fresh-start": "npm run clean && npm start"
|
||||||
|
|||||||
160
server.mjs
160
server.mjs
@@ -41,6 +41,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
|
|||||||
let sweepInProgress = false;
|
let sweepInProgress = false;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const sseClients = new Set();
|
const sseClients = new Set();
|
||||||
|
const terminalActionBuckets = new Map();
|
||||||
const staleAlertState = {};
|
const staleAlertState = {};
|
||||||
|
|
||||||
// === Delta/Memory ===
|
// === Delta/Memory ===
|
||||||
@@ -292,7 +293,9 @@ app.get('/api/metrics', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/memory/search', (req, res) => {
|
app.get('/api/memory/search', (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
const guard = authorizeTerminalAction(req, res, 'memory:search');
|
||||||
|
if (!guard.ok) return;
|
||||||
|
auditTerminalAction(req, 'memory:search', 'ok');
|
||||||
res.json(intelligenceStore.queryMemory({
|
res.json(intelligenceStore.queryMemory({
|
||||||
q: req.query.q || '',
|
q: req.query.q || '',
|
||||||
limit: req.query.limit || 25,
|
limit: req.query.limit || 25,
|
||||||
@@ -300,7 +303,9 @@ app.get('/api/memory/search', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/memory/predictions', (req, res) => {
|
app.get('/api/memory/predictions', (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Memory queries disabled or unauthorized' });
|
const guard = authorizeTerminalAction(req, res, 'memory:predictions');
|
||||||
|
if (!guard.ok) return;
|
||||||
|
auditTerminalAction(req, 'memory:predictions', 'ok');
|
||||||
res.json(intelligenceStore.listPredictions({
|
res.json(intelligenceStore.listPredictions({
|
||||||
state: req.query.state || null,
|
state: req.query.state || null,
|
||||||
limit: req.query.limit || 25,
|
limit: req.query.limit || 25,
|
||||||
@@ -308,24 +313,33 @@ app.get('/api/memory/predictions', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sweep', express.json(), (req, res) => {
|
app.post('/api/sweep', express.json(), (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
const guard = authorizeTerminalAction(req, res, 'sweep');
|
||||||
triggerSweep(res);
|
if (!guard.ok) return;
|
||||||
|
triggerSweepAction(req, res, 'sweep');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/action', express.json(), async (req, res) => {
|
app.post('/api/action', express.json(), (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase();
|
||||||
const action = String(req.body?.action || req.query.action || '').toLowerCase();
|
const guard = authorizeTerminalAction(req, res, action || 'unknown');
|
||||||
|
if (!guard.ok) return;
|
||||||
|
|
||||||
if (action === 'status') {
|
if (action === 'status') {
|
||||||
return res.json({ ok: true, action, health: buildHealth() });
|
auditTerminalAction(req, 'status', 'ok');
|
||||||
|
return res.json({ ok: true, action, status: 'ok', health: buildHealth() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'brief') {
|
if (action === 'brief') {
|
||||||
if (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' });
|
if (!currentData) {
|
||||||
return res.json({ ok: true, action, text: buildBrief(currentData) });
|
auditTerminalAction(req, 'brief', 'rejected', 'no_data');
|
||||||
|
return res.status(503).json({ ok: false, action, error: 'No data yet - first sweep in progress' });
|
||||||
|
}
|
||||||
|
auditTerminalAction(req, 'brief', 'ok');
|
||||||
|
const brief = buildBrief(currentData);
|
||||||
|
return res.json({ ok: true, action, status: 'ok', brief, text: brief });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'memory') {
|
if (action === 'memory') {
|
||||||
|
auditTerminalAction(req, 'memory', 'ok');
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
action,
|
action,
|
||||||
@@ -335,11 +349,10 @@ app.post('/api/action', express.json(), async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'sweep') {
|
if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
|
||||||
return triggerSweep(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'memory', 'sweep'] });
|
auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action');
|
||||||
|
return res.status(400).json({ ok: false, error: 'Unknown action', allowed: ['status', 'brief', 'memory', 'sweep'], actions: ['status', 'brief', 'memory', 'sweep'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: available locales
|
// API: available locales
|
||||||
@@ -370,26 +383,114 @@ function broadcast(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestIp(req) {
|
||||||
|
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRequest(req) {
|
||||||
|
const remote = requestIp(req);
|
||||||
|
return remote === '::1'
|
||||||
|
|| remote === '127.0.0.1'
|
||||||
|
|| remote === '::ffff:127.0.0.1'
|
||||||
|
|| remote.startsWith('127.')
|
||||||
|
|| remote === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOriginPost(req) {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
if (!origin) return true;
|
||||||
|
try {
|
||||||
|
const originUrl = new URL(origin);
|
||||||
|
const host = req.get('host');
|
||||||
|
return host && originUrl.host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionToken(req) {
|
||||||
|
return req.get('x-crucix-token') || req.body?.token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditTerminalAction(req, action, outcome, detail = null) {
|
||||||
|
const suffix = detail ? ` detail=${detail}` : '';
|
||||||
|
console.log(`[Crucix][audit] terminal_action action=${action || 'unknown'} outcome=${outcome} ip=${requestIp(req)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimitTerminalAction(req, action) {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = Math.max(1000, config.terminalActionRateLimitWindowMs || 60_000);
|
||||||
|
const max = Math.max(1, config.terminalActionRateLimitMax || 10);
|
||||||
|
const key = `${requestIp(req)}:${action}`;
|
||||||
|
const bucket = terminalActionBuckets.get(key);
|
||||||
|
if (!bucket || now > bucket.resetAt) {
|
||||||
|
terminalActionBuckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
bucket.count += 1;
|
||||||
|
if (bucket.count > max) {
|
||||||
|
return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeTerminalAction(req, res, action) {
|
||||||
|
const rate = rateLimitTerminalAction(req, action);
|
||||||
|
if (!rate.ok) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'rate_limited');
|
||||||
|
res.set('Retry-After', String(rate.retryAfterSeconds));
|
||||||
|
res.status(429).json({ error: 'Too many terminal actions', retryAfterSeconds: rate.retryAfterSeconds });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sameOriginPost(req)) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'csrf_origin');
|
||||||
|
res.status(403).json({ error: 'Origin mismatch' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = isLocalRequest(req);
|
||||||
|
const token = actionToken(req);
|
||||||
|
if (!config.terminalActionsEnabled) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'disabled');
|
||||||
|
res.status(403).json({ error: 'Terminal actions are disabled' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.sweepToken) {
|
||||||
|
if (token !== config.sweepToken) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'invalid_token');
|
||||||
|
res.status(401).json({ error: 'Invalid terminal action token' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!local) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'missing_token');
|
||||||
|
res.status(403).json({ error: 'Terminal actions are local-only unless SWEEP_TOKEN is set' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSweepAction(req, res, auditAction) {
|
||||||
|
if (sweepInProgress) {
|
||||||
|
auditTerminalAction(req, auditAction, 'rejected', 'already_running');
|
||||||
|
return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
|
||||||
|
}
|
||||||
|
auditTerminalAction(req, auditAction, 'accepted');
|
||||||
|
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
||||||
|
return res.status(202).json({ ok: true, status: 'accepted' });
|
||||||
|
}
|
||||||
|
|
||||||
function dataAgeMs() {
|
function dataAgeMs() {
|
||||||
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
||||||
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
||||||
return Number.isFinite(ms) ? ms : null;
|
return Number.isFinite(ms) ? ms : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canRunTerminalAction(req) {
|
|
||||||
const remote = req.ip || '';
|
|
||||||
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
||||||
const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
|
|
||||||
if (config.sweepToken) return token === config.sweepToken;
|
|
||||||
return Boolean(config.terminalActionsEnabled || local);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerSweep(res) {
|
|
||||||
if (sweepInProgress) return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
|
|
||||||
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
|
||||||
return res.status(202).json({ ok: true, status: 'accepted' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLLMStatus() {
|
function getLLMStatus() {
|
||||||
if (!config.llm.provider) return { state: 'disabled' };
|
if (!config.llm.provider) return { state: 'disabled' };
|
||||||
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
||||||
@@ -433,7 +534,8 @@ function buildHealth() {
|
|||||||
llm: getLLMStatus(),
|
llm: getLLMStatus(),
|
||||||
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
||||||
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
||||||
terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
|
terminalActionsEnabled: config.terminalActionsEnabled,
|
||||||
|
terminalActionsTokenRequired: !!config.sweepToken,
|
||||||
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
||||||
language: currentLanguage,
|
language: currentLanguage,
|
||||||
memory: intelligenceStore.status(),
|
memory: intelligenceStore.status(),
|
||||||
|
|||||||
82
test/adsb.test.mjs
Normal file
82
test/adsb.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
async function withFetch(mockFetch, fn) {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalAdsbKey = process.env.ADSB_API_KEY;
|
||||||
|
const originalRapidKey = process.env.RAPIDAPI_KEY;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
delete process.env.ADSB_API_KEY;
|
||||||
|
delete process.env.RAPIDAPI_KEY;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalAdsbKey === undefined) delete process.env.ADSB_API_KEY;
|
||||||
|
else process.env.ADSB_API_KEY = originalAdsbKey;
|
||||||
|
if (originalRapidKey === undefined) delete process.env.RAPIDAPI_KEY;
|
||||||
|
else process.env.RAPIDAPI_KEY = originalRapidKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(payload, ok = true, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
text: async () => JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ADS-B reports disabled when no key is configured and public feed fails', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({ error: 'blocked' }, false, 403), async () => {
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'disabled');
|
||||||
|
assert.match(data.error, /ADSB_API_KEY|RAPIDAPI_KEY/);
|
||||||
|
assert.equal(data.militaryAircraft.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ADS-B reports degraded when RapidAPI and public feed fail', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({ error: 'unavailable' }, false, 503), async () => {
|
||||||
|
process.env.ADSB_API_KEY = 'test-key';
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'degraded');
|
||||||
|
assert.match(data.error, /providers returned no usable/);
|
||||||
|
assert.equal(data.failures.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ADS-B returns live RapidAPI military aircraft payloads', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({
|
||||||
|
ac: [{
|
||||||
|
hex: 'AE1234',
|
||||||
|
flight: 'RCH123',
|
||||||
|
t: 'KC135',
|
||||||
|
lat: 50,
|
||||||
|
lon: 8,
|
||||||
|
mil: true,
|
||||||
|
}],
|
||||||
|
}), async () => {
|
||||||
|
process.env.ADSB_API_KEY = 'test-key';
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'live');
|
||||||
|
assert.equal(data.provider, 'rapidapi');
|
||||||
|
assert.equal(data.totalMilitary, 1);
|
||||||
|
assert.equal(data.militaryAircraft[0].callsign, 'RCH123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSource treats disabled source status as degraded health', async () => {
|
||||||
|
const { runSource } = await import('../apis/briefing.mjs');
|
||||||
|
const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' }));
|
||||||
|
|
||||||
|
assert.equal(result.status, 'degraded');
|
||||||
|
assert.equal(result.error, null);
|
||||||
|
});
|
||||||
@@ -122,6 +122,43 @@ test('server exposes memory-backed query APIs and dashboard memory action', () =
|
|||||||
assert.match(html, /runTerminalAction\('memory'\)/);
|
assert.match(html, /runTerminalAction\('memory'\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('terminal action endpoints avoid URL tokens and include hardening gates', () => {
|
||||||
|
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||||
|
assert.match(server, /app\.post\('\/api\/action'/);
|
||||||
|
assert.match(server, /app\.post\('\/api\/sweep'/);
|
||||||
|
assert.match(server, /x-crucix-token/);
|
||||||
|
assert.match(server, /sameOriginPost/);
|
||||||
|
assert.match(server, /rateLimitTerminalAction/);
|
||||||
|
assert.match(server, /auditTerminalAction/);
|
||||||
|
assert.doesNotMatch(server, /req\.query\.token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard exposes token configuration flow without devtools edits', () => {
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
assert.match(html, /configureTerminalActionToken/);
|
||||||
|
assert.match(html, /crucix_sweep_token/);
|
||||||
|
assert.match(html, /x-crucix-token/);
|
||||||
|
assert.match(html, /SET TOKEN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server dashboard shell does not embed an operational snapshot', () => {
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
assert.match(html, /let D = createDashboardShellData\(\);/);
|
||||||
|
assert.doesNotMatch(html, /2026-04-03T16:18:10\.188Z/);
|
||||||
|
assert.doesNotMatch(html, /Trump announced new strikes on Iran/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server dashboard fetches api data before initialization', () => {
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
const serverMode = html.indexOf('if (canProbeApi)');
|
||||||
|
const apiFetch = html.indexOf("fetch('/api/data')");
|
||||||
|
const firstInitAfterServerMode = html.indexOf('init();', serverMode);
|
||||||
|
|
||||||
|
assert.ok(serverMode > -1);
|
||||||
|
assert.ok(apiFetch > serverMode);
|
||||||
|
assert.ok(firstInitAfterServerMode > apiFetch);
|
||||||
|
});
|
||||||
|
|
||||||
test('stale alert is skipped for fresh health and resets active key', () => {
|
test('stale alert is skipped for fresh health and resets active key', () => {
|
||||||
const state = { lastStaleAlertKey: 'old', lastStaleAlertAt: 100 };
|
const state = { lastStaleAlertKey: 'old', lastStaleAlertAt: 100 };
|
||||||
const decision = shouldSendStaleAlert({ stale: false }, state, { now: 200 });
|
const decision = shouldSendStaleAlert({ stale: false }, state, { now: 200 });
|
||||||
|
|||||||
Reference in New Issue
Block a user