diff --git a/.env.example b/.env.example index 5d09163..559f763 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60 DASHBOARD_URL= TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 +TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard # LLM layer diff --git a/README.md b/README.md index 8c1a45a..55e9e91 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ STALE_ALERT_COOLDOWN_MINUTES=60 DASHBOARD_URL=https://intelligence.example.internal TERMINAL_ACTIONS_ENABLED=true SWEEP_TOKEN= +TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000 +TERMINAL_ACTION_RATE_LIMIT_MAX=10 BRIEF_VERBOSITY=standard 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`. -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. #### Scenario Watchlist diff --git a/crucix.config.mjs b/crucix.config.mjs index c0604b0..484109b 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -26,7 +26,9 @@ export default { staleAlertCooldownMinutes: intEnv('STALE_ALERT_COOLDOWN_MINUTES', 60), dashboardUrl: process.env.DASHBOARD_URL || 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: { provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 8c14f2a..4f2f7bd 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -432,6 +432,7 @@ let terminalOutput = 'Ready. Live data is loaded from /api/data in server mode.' let terminalBusy = false; let currentRegion = 'world'; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; +const terminalActionTokenKey = 'crucix_sweep_token'; const layerTypeMap = { air: ['air'], @@ -632,6 +633,7 @@ function renderTopbar(){ const ts = new Date(D.meta.timestamp); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); + const hasActionToken = !!getTerminalActionToken(); document.getElementById('topbar').innerHTML=`
CRUCIX MONITOR @@ -644,12 +646,26 @@ function renderTopbar(){ ${d} ${timeStr} ${t('dashboard.sources','SOURCES')} ${D.meta.sourcesOk}/${D.meta.sourcesQueried} ${D.delta?.summary ? `${t('dashboard.delta','DELTA')} ${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}` : ''} + ${t('dashboard.highAlert','HIGH ALERT')}
`; renderRegionControls(); } +function getTerminalActionToken(){ + return localStorage.getItem(terminalActionTokenKey) || localStorage.getItem('crucix_terminal_action_token') || ''; +} + +function configureTerminalActionToken(){ + const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken()); + if(next === null) return; + const clean = next.trim(); + if(clean) localStorage.setItem(terminalActionTokenKey, clean); + else localStorage.removeItem(terminalActionTokenKey); + renderTopbar(); +} + // === LEFT RAIL === function layerMode(key){ return layerModes[key] || 'normal'; } function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; } @@ -1592,6 +1608,12 @@ function renderLower(){ async function runTerminalAction(action){ if(terminalBusy) return; + let token = getTerminalActionToken(); + if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){ + configureTerminalActionToken(); + token = getTerminalActionToken(); + if(!token) return; + } terminalBusy = true; terminalOutput = `> ${action}\nRunning...`; renderRight(); @@ -1600,7 +1622,7 @@ async function runTerminalAction(action){ method:'POST', headers:{ 'Content-Type':'application/json', - ...(localStorage.getItem('crucix_sweep_token') ? {'x-crucix-token': localStorage.getItem('crucix_sweep_token')} : {}) + ...(token ? {'x-crucix-token': token} : {}) }, body:JSON.stringify({action}) }); @@ -1686,6 +1708,7 @@ function renderRight(){ +
${terminalOutput.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])).replace(/\n/g,'
')}
diff --git a/server.mjs b/server.mjs index eb661c6..180a550 100644 --- a/server.mjs +++ b/server.mjs @@ -41,6 +41,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started let sweepInProgress = false; const startTime = Date.now(); const sseClients = new Set(); +const terminalActionBuckets = new Map(); const staleAlertState = {}; // === Delta/Memory === @@ -292,28 +293,35 @@ app.get('/api/metrics', (req, res) => { }); app.post('/api/sweep', express.json(), (req, res) => { - if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' }); - triggerSweep(res); + const guard = authorizeTerminalAction(req, res, 'sweep'); + if (!guard.ok) return; + triggerSweepAction(req, res, 'sweep'); }); -app.post('/api/action', express.json(), async (req, res) => { - if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' }); - const action = String(req.body?.action || req.query.action || '').toLowerCase(); +app.post('/api/action', express.json(), (req, res) => { + const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase(); + const guard = authorizeTerminalAction(req, res, action || 'unknown'); + if (!guard.ok) return; 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 (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' }); - return res.json({ ok: true, action, text: buildBrief(currentData) }); + if (!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 === 'sweep') { - return triggerSweep(res); - } + if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep'); - res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'sweep'] }); + auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action'); + return res.status(400).json({ ok: false, error: 'Unknown action', allowed: ['status', 'brief', 'sweep'], actions: ['status', 'brief', 'sweep'] }); }); // API: available locales @@ -344,26 +352,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() { const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime; const ms = ts ? Date.now() - new Date(ts).getTime() : 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() { if (!config.llm.provider) return { state: 'disabled' }; if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider }; @@ -407,7 +503,8 @@ function buildHealth() { llm: getLLMStatus(), telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId), discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl), - terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken), + terminalActionsEnabled: config.terminalActionsEnabled, + terminalActionsTokenRequired: !!config.sweepToken, refreshIntervalMinutes: config.refreshIntervalMinutes, language: currentLanguage, memory: intelligenceStore.status(), diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs index e740690..27d34ff 100644 --- a/test/fetch-utils.test.mjs +++ b/test/fetch-utils.test.mjs @@ -101,6 +101,25 @@ test('safeFetchText returns text and byte count', async () => { } }); +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\(\);/);