mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-15 12:10:49 +02:00
feat: key verification, category filter, installer deps/run, UI polish
Web console - LLM API keys: Save now verifies the key with a cheap authenticated call per provider (anthropic/openai/xai/gemini/deepseek/mistral/groq/together/openrouter/ moonshot/qwen/nvidia) and shows ✓ valid / ✗ rejected / • saved; new POST /api/keys/verify. Keys still save when a provider isn't auto-verifiable. - Leads: a single "category" dropdown filters the board to one category. - Provider/Model row alignment fixed (stacked-group top margin no longer applies inside a field-row). - "own interceptor" renamed to "NeuroSploit Interceptor" in the proxy picker. Installers - setup.sh: v4.3.0; detects a package manager (apt/brew/dnf/pacman/apk) and installs missing deps (curl, git, nmap, node; rustscan + typst via cargo); optional launch at the end (NEUROSPLOIT_RUN=1 or a prompt). Skip with NEUROSPLOIT_NO_DEPS=1. - install.ps1: v4.3.0; installs deps via winget (Node, Nmap, Git; typst via cargo); optional launch at the end. - setup.bat (new): Windows CMD entry point that bootstraps install.ps1 (local or from GitHub). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc397f1b50
commit
1a2e1791e3
7 files changed
+254
-20
No files matched your search
+33
-2
@@ -14,7 +14,7 @@ function Ok ($m) { Write-Host " + $m" -ForegroundColor Green }
|
||||
function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " NeuroSploit installer (Windows) — v3.6.9" -ForegroundColor Cyan
|
||||
Write-Host " NeuroSploit installer (Windows) — v4.3.0" -ForegroundColor Cyan
|
||||
|
||||
# arch → asset arch (only x64 prebuilt today; arm64 falls back to source)
|
||||
$rawArch = $env:PROCESSOR_ARCHITECTURE
|
||||
@@ -29,7 +29,7 @@ $ref = $env:NEUROSPLOIT_REF
|
||||
if (-not $ref) {
|
||||
try { $ref = (Invoke-RestMethod "https://api.github.com/repos/$slug/releases/latest").tag_name } catch { }
|
||||
}
|
||||
if (-not $ref) { $ref = "v3.6.9" }
|
||||
if (-not $ref) { $ref = "v4.3.0" }
|
||||
Say "Release: $ref"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
@@ -104,9 +104,40 @@ Ok "Set NEUROSPLOIT_BASE=$dir (User)."
|
||||
$env:Path = "$dir;$env:Path"; $env:NEUROSPLOIT_BASE = $dir
|
||||
|
||||
Ok ("Version: " + (& $exePath --version))
|
||||
|
||||
# ---- dependencies: install what winget can (best-effort, never fatal) ----
|
||||
if ($env:NEUROSPLOIT_NO_DEPS -ne "1") {
|
||||
Say "Checking dependencies..."
|
||||
$have = { param($c) [bool](Get-Command $c -ErrorAction SilentlyContinue) }
|
||||
$deps = @(
|
||||
@{ cmd = "node"; id = "OpenJS.NodeJS"; why = "web console" },
|
||||
@{ cmd = "nmap"; id = "Insecure.Nmap"; why = "recon (optional)" },
|
||||
@{ cmd = "git"; id = "Git.Git"; why = "source build / updates" }
|
||||
)
|
||||
foreach ($d in $deps) {
|
||||
if (& $have $d.cmd) { Ok ("{0} present" -f $d.cmd) }
|
||||
elseif (Get-Command winget -ErrorAction SilentlyContinue) {
|
||||
Say ("installing {0} ({1})..." -f $d.cmd, $d.why)
|
||||
try { winget install -e --id $d.id --accept-source-agreements --accept-package-agreements | Out-Null; Ok ("{0} installed" -f $d.cmd) }
|
||||
catch { Warn ("could not install {0} automatically — install it manually" -f $d.cmd) }
|
||||
} else { Warn ("{0} missing (winget unavailable) — install manually for: {1}" -f $d.cmd, $d.why) }
|
||||
}
|
||||
if (& $have cargo) {
|
||||
if (-not (& $have typst)) { Say "installing typst (cargo, PDF reports)..."; try { cargo install typst-cli | Out-Null; Ok "typst installed" } catch { Warn "typst optional — skipped" } }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Ok "Installed. Open a NEW terminal, then from ANY folder:"
|
||||
Write-Host " neurosploit # interactive session"
|
||||
Write-Host " neurosploit run http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 -v"
|
||||
Write-Host " neurosploit --help"
|
||||
Warn "Update later: just re-run this script."
|
||||
|
||||
# ---- optionally launch now (NEUROSPLOIT_RUN=1, or confirm when interactive) ----
|
||||
$runNow = $env:NEUROSPLOIT_RUN
|
||||
if (-not $runNow -and [Environment]::UserInteractive) {
|
||||
$ans = Read-Host "`n Launch the interactive session now? [y/N]"
|
||||
if ($ans -match '^(y|yes)$') { $runNow = "1" }
|
||||
}
|
||||
if ($runNow -eq "1") { Say "Starting NeuroSploit..."; & $exePath }
|
||||
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
REM NeuroSploit installer for Windows (CMD) — by Joas A Santos & Red Team Leaders
|
||||
REM
|
||||
REM Double-click setup.bat, or run it from a terminal. It bootstraps the
|
||||
REM PowerShell installer (install.ps1), which detects the platform, installs
|
||||
REM Rust if missing, downloads or builds the binary, finds and installs
|
||||
REM dependencies (Node, Nmap, Git, typst), configures PATH + NEUROSPLOIT_BASE,
|
||||
REM and can launch NeuroSploit at the end.
|
||||
REM
|
||||
REM Env (optional): NEUROSPLOIT_REF, NEUROSPLOIT_DIR, NEUROSPLOIT_BUILD=1,
|
||||
REM NEUROSPLOIT_NO_DEPS=1, NEUROSPLOIT_RUN=1
|
||||
setlocal
|
||||
echo.
|
||||
echo NeuroSploit installer (Windows) v4.3.0
|
||||
echo --------------------------------------
|
||||
|
||||
where powershell >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [x] PowerShell not found. Install PowerShell, or use WSL with setup.sh.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Prefer the install.ps1 sitting next to this file; fall back to the published one.
|
||||
set "PS1=%~dp0install.ps1"
|
||||
if exist "%PS1%" (
|
||||
echo Running local installer: %PS1%
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%PS1%"
|
||||
) else (
|
||||
echo Fetching installer from GitHub...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/JoasASantos/NeuroSploit/main/install.ps1 | iex"
|
||||
)
|
||||
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
if "%RC%"=="0" (
|
||||
echo [ok] Done. Open a NEW terminal, then run: neurosploit
|
||||
) else (
|
||||
echo [x] Installer exited with code %RC%.
|
||||
)
|
||||
endlocal & exit /b %RC%
|
||||
@@ -28,10 +28,10 @@ cat <<'BANNER'
|
||||
|
||||
███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗
|
||||
████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit installer
|
||||
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.6.9 — Rust harness
|
||||
██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v4.3.0 — Rust harness
|
||||
██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos
|
||||
██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders
|
||||
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
|
||||
╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ v4.3.0
|
||||
BANNER
|
||||
|
||||
# ---- platform detection (Linux / macOS / Windows-WSL · x64 / arm64) ----
|
||||
@@ -63,7 +63,7 @@ if [ -z "$REF" ]; then
|
||||
REF="$(dl "https://api.github.com/repos/${REPO_SLUG}/releases/latest" /dev/stdout 2>/dev/null \
|
||||
| grep -m1 '"tag_name"' | sed -E 's/.*"tag_name" *: *"([^"]+)".*/\1/' || true)"
|
||||
fi
|
||||
[ -z "$REF" ] && REF="v3.6.9"
|
||||
[ -z "$REF" ] && REF="v4.3.0"
|
||||
say "Release: $REF"
|
||||
|
||||
installed=0
|
||||
@@ -154,11 +154,59 @@ if [ -d "$HOME/.config/fish" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- optional tooling hints ----
|
||||
say "Recommended tools (optional): curl nmap rustscan ffuf node npx typst"
|
||||
for t in curl nmap rustscan ffuf node npx typst; do
|
||||
command -v "$t" >/dev/null 2>&1 && ok "$t present" || warn "$t missing"
|
||||
done
|
||||
# ---- dependencies: detect, and install the ones a package manager can ----
|
||||
# Core recon/report tools. Rust is already handled above. Auto-install is
|
||||
# best-effort: it never fails the install, and is skipped with NEUROSPLOIT_NO_DEPS=1.
|
||||
PKG=""; SUDO=""
|
||||
if command -v apt-get >/dev/null 2>&1; then PKG="apt"
|
||||
elif command -v brew >/dev/null 2>&1; then PKG="brew"
|
||||
elif command -v dnf >/dev/null 2>&1; then PKG="dnf"
|
||||
elif command -v pacman >/dev/null 2>&1; then PKG="pacman"
|
||||
elif command -v apk >/dev/null 2>&1; then PKG="apk"; fi
|
||||
[ "$(id -u)" = "0" ] || command -v sudo >/dev/null 2>&1 && [ "$(id -u)" != "0" ] && SUDO="sudo"
|
||||
|
||||
pkg_install() { # pkg_install <name...>
|
||||
[ -n "$PKG" ] || { warn "no supported package manager — install manually: $*"; return 0; }
|
||||
case "$PKG" in
|
||||
apt) $SUDO apt-get update -qq >/dev/null 2>&1; $SUDO apt-get install -y "$@" >/dev/null 2>&1 ;;
|
||||
brew) brew install "$@" >/dev/null 2>&1 ;;
|
||||
dnf) $SUDO dnf install -y "$@" >/dev/null 2>&1 ;;
|
||||
pacman) $SUDO pacman -Sy --noconfirm "$@" >/dev/null 2>&1 ;;
|
||||
apk) $SUDO apk add "$@" >/dev/null 2>&1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Map a required command to its package name on the detected manager.
|
||||
pkg_name() { # pkg_name <cmd>
|
||||
case "$1" in
|
||||
npx) echo "node" ;; # npx ships with node
|
||||
node) [ "$PKG" = "apt" ] && echo "nodejs" || echo "node" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "${NEUROSPLOIT_NO_DEPS:-0}" != "1" ]; then
|
||||
say "Checking dependencies (install with the detected package manager: ${PKG:-none})…"
|
||||
# Essential for the web console + recon; typst is for PDF reports (optional).
|
||||
for t in curl git nmap node; do
|
||||
if command -v "$t" >/dev/null 2>&1; then ok "$t present"; else
|
||||
warn "$t missing — installing…"; pkg_install "$(pkg_name "$t")"
|
||||
command -v "$t" >/dev/null 2>&1 && ok "$t installed" || warn "could not install $t automatically — install it manually"
|
||||
fi
|
||||
done
|
||||
# Rust-cargo extras + report engine: install via cargo when present (no root).
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
command -v rustscan >/dev/null 2>&1 || { say "installing rustscan (cargo)…"; cargo install rustscan >/dev/null 2>&1 && ok "rustscan installed" || warn "rustscan optional — skipped"; }
|
||||
command -v typst >/dev/null 2>&1 || { say "installing typst (cargo)…"; cargo install typst-cli >/dev/null 2>&1 && ok "typst installed" || warn "typst optional (PDF reports) — skipped"; }
|
||||
fi
|
||||
# ffuf is Go; only hint it.
|
||||
command -v ffuf >/dev/null 2>&1 && ok "ffuf present" || warn "ffuf optional (fuzzing) — install via your package manager or Go"
|
||||
else
|
||||
say "Dependency auto-install skipped (NEUROSPLOIT_NO_DEPS=1)."
|
||||
for t in curl git nmap node rustscan ffuf typst; do
|
||||
command -v "$t" >/dev/null 2>&1 && ok "$t present" || warn "$t missing"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "Installed. Open a NEW terminal — or activate now with:"
|
||||
@@ -170,3 +218,16 @@ echo " neurosploit run http://testphp.vulnweb.com/ --subscription --model a
|
||||
echo " neurosploit --help"
|
||||
echo
|
||||
warn "Update later: just re-run this script. Best runtime: Kali (docker run -it kalilinux/kali-rolling)."
|
||||
|
||||
# ---- optionally launch right away ----
|
||||
# `curl | bash` has no TTY, so auto-run only when asked (NEUROSPLOIT_RUN=1) or
|
||||
# when a real terminal is attached and the operator confirms.
|
||||
RUN_NOW="${NEUROSPLOIT_RUN:-}"
|
||||
if [ -z "$RUN_NOW" ] && [ -t 0 ]; then
|
||||
printf '\n Launch the interactive session now? [y/N] '; read -r ans
|
||||
case "$ans" in y|Y|yes) RUN_NOW=1 ;; esac
|
||||
fi
|
||||
if [ "$RUN_NOW" = "1" ]; then
|
||||
say "Starting NeuroSploit…"
|
||||
exec env NEUROSPLOIT_BASE="$DIR" "$DIR/neurosploit"
|
||||
fi
|
||||
+47
-5
@@ -22,6 +22,7 @@ const state = {
|
||||
selected: new Set(),
|
||||
customLeads: [],
|
||||
filter: 'all',
|
||||
catFilter: '',
|
||||
search: '',
|
||||
expandedCats: new Set(),
|
||||
providers: [],
|
||||
@@ -212,6 +213,7 @@ async function loadAgents() {
|
||||
function renderBoard() {
|
||||
const root = $('#categories');
|
||||
root.innerHTML = '';
|
||||
populateCatFilter();
|
||||
for (const group of state.categories) {
|
||||
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
||||
const card = document.createElement('div');
|
||||
@@ -297,7 +299,8 @@ function updateChips() {
|
||||
|
||||
function applyFilters() {
|
||||
const q = state.search.trim().toLowerCase();
|
||||
const narrowing = !!q || state.filter !== 'all';
|
||||
const cat = state.catFilter || '';
|
||||
const narrowing = !!q || state.filter !== 'all' || !!cat;
|
||||
$$('.agent-row').forEach((row) => {
|
||||
const isSel = state.selected.has(row.dataset.id);
|
||||
let visible = true;
|
||||
@@ -308,6 +311,7 @@ function applyFilters() {
|
||||
});
|
||||
let anyCardVisible = false;
|
||||
$$('.cat-card').forEach((card) => {
|
||||
if (cat && card.dataset.category !== cat) { card.style.display = 'none'; return; }
|
||||
const shown = $$('.agent-row', card).filter((r) => !r.classList.contains('hidden-by-search'));
|
||||
card.style.display = shown.length ? '' : 'none';
|
||||
if (shown.length) anyCardVisible = true;
|
||||
@@ -324,6 +328,20 @@ function applyFilters() {
|
||||
show($('#leadsEmpty'), !anyCardVisible);
|
||||
}
|
||||
|
||||
// One place to pick a category: the select lists every category (with its
|
||||
// selected/total count); choosing one shows just that card, "All" resets.
|
||||
function populateCatFilter() {
|
||||
const sel = $('#catFilter');
|
||||
if (!sel) return;
|
||||
const cur = state.catFilter || '';
|
||||
sel.innerHTML = '<option value="">All categories</option>' + state.categories.map((g) => {
|
||||
const n = g.agents.filter((a) => state.selected.has(a.id)).length;
|
||||
return `<option value="${esc(g.category)}">${esc(g.category)} (${n}/${g.agents.length})</option>`;
|
||||
}).join('');
|
||||
sel.value = cur;
|
||||
}
|
||||
$('#catFilter')?.addEventListener('change', (e) => { state.catFilter = e.target.value; applyFilters(); });
|
||||
|
||||
$$('.chip').forEach((chip) => chip.addEventListener('click', () => {
|
||||
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
|
||||
chip.classList.add('chip-active');
|
||||
@@ -1748,17 +1766,41 @@ async function refreshKeyStatus() {
|
||||
<span class="p-kind">${p.kind === 'cli' ? 'cli+api' : 'api only'}</span>
|
||||
<input type="password" data-provider="${esc(p.key)}" placeholder="${set ? '•••••••• (set — enter to replace)' : 'paste API key'}" />
|
||||
<button class="btn btn-sm" data-save="${esc(p.key)}">Save</button>
|
||||
<span class="key-verify" data-verify="${esc(p.key)}"></span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
$$('[data-save]', root).forEach((btn) => btn.addEventListener('click', async () => {
|
||||
const doSave = async (btn) => {
|
||||
const provider = btn.dataset.save;
|
||||
const input = root.querySelector(`input[data-provider="${provider}"]`);
|
||||
const vtag = root.querySelector(`[data-verify="${provider}"]`);
|
||||
const key = input.value.trim();
|
||||
if (!key) return;
|
||||
await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
|
||||
input.value = '';
|
||||
refreshKeyStatus();
|
||||
btn.disabled = true; const label = btn.textContent; btn.textContent = 'Saving…';
|
||||
if (vtag) { vtag.className = 'key-verify checking'; vtag.textContent = '· verifying…'; }
|
||||
try {
|
||||
// Save verifies the key server-side with a cheap authenticated call.
|
||||
const r = await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
|
||||
input.value = '';
|
||||
const v = r.verify || { ok: null, message: 'saved' };
|
||||
if (vtag) {
|
||||
vtag.className = `key-verify ${v.ok === true ? 'ok' : v.ok === false ? 'bad' : 'unknown'}`;
|
||||
vtag.textContent = v.ok === true ? '✓ valid' : v.ok === false ? '✗ ' + v.message : '• ' + v.message;
|
||||
}
|
||||
toast(v.ok === true ? `${provider}: key valid` : v.ok === false ? `${provider}: ${v.message}` : `${provider}: ${v.message}`, v.ok === false ? 'error' : 'ok');
|
||||
// Refresh the set-dots but keep the verify tag we just set.
|
||||
state.keys = await api('/api/keys').catch(() => state.keys);
|
||||
const dot = root.querySelector(`[data-save="${provider}"]`)?.closest('.provider-row')?.querySelector('.dot');
|
||||
if (dot) dot.classList.add('set');
|
||||
} catch (e) {
|
||||
if (vtag) { vtag.className = 'key-verify bad'; vtag.textContent = '✗ ' + e.message; }
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = label;
|
||||
}
|
||||
};
|
||||
$$('[data-save]', root).forEach((btn) => btn.addEventListener('click', () => doSave(btn)));
|
||||
$$('input[data-provider]', root).forEach((inp) => inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') root.querySelector(`[data-save="${inp.dataset.provider}"]`)?.click();
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
<div class="section-desc">Toggle specific leads to test, or leave everything off to let recon-driven auto-selection choose.</div>
|
||||
</div>
|
||||
<div class="lead-toolbar">
|
||||
<select class="input-sm" id="catFilter" title="Show one category"><option value="">All categories</option></select>
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon" aria-hidden="true">⌕</span>
|
||||
<input id="leadSearch" type="text" placeholder="Search leads by name or CWE" />
|
||||
@@ -273,13 +274,13 @@
|
||||
<div class="field-group"><label class="field-label" for="fieldIntercept">Intercepting proxy</label>
|
||||
<select class="narrow" id="fieldIntercept">
|
||||
<option value="off" selected>off · direct</option>
|
||||
<option value="own">own interceptor (record + passive discovery)</option>
|
||||
<option value="own">NeuroSploit Interceptor (record + passive discovery)</option>
|
||||
<option value="burp">Burp Suite</option>
|
||||
<option value="caido">Caido</option>
|
||||
<option value="zap">OWASP ZAP</option>
|
||||
<option value="mitmproxy">mitmproxy</option>
|
||||
<option value="own+burp">own + Burp</option>
|
||||
<option value="own+caido">own + Caido</option>
|
||||
<option value="own+burp">NeuroSploit Interceptor + Burp</option>
|
||||
<option value="own+caido">NeuroSploit Interceptor + Caido</option>
|
||||
</select>
|
||||
<div class="field-help">The harness and agent commands route through it. Full HTTPS interception needs one of the tools (own tunnels TLS).</div>
|
||||
</div>
|
||||
|
||||
@@ -363,8 +363,11 @@ a.btn:disabled, a.btn[aria-disabled="true"] { pointer-events: none; }
|
||||
.field-error::before { content: "⚠ "; }
|
||||
.field-group:has(.field-error:not([hidden])) input,
|
||||
.field-group:has(.field-error:not([hidden])) textarea { border-color: var(--sev-critical-fg); }
|
||||
.field-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
|
||||
.field-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; align-items: flex-start; }
|
||||
.field-row > * { flex: 1; min-width: 160px; }
|
||||
/* inside a row the groups sit side by side — the stacked-group top margin would
|
||||
push every group after the first one down and misalign the controls */
|
||||
.field-row > .field-group + .field-group { margin-top: 0; }
|
||||
|
||||
.section-title { font-size: var(--fs-base); font-weight: 600; }
|
||||
.section-desc { font-size: var(--fs-sm); color: var(--text-dim); margin-top: 2px; }
|
||||
@@ -591,6 +594,11 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.provider-row input { flex: 1; font-family: var(--mono); font-size: var(--fs-sm); }
|
||||
.provider-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex: none; }
|
||||
.provider-row .dot.set { background: var(--sev-low-fg); }
|
||||
.key-verify { flex: none; font-size: var(--fs-2xs); font-weight: 700; min-width: 60px; }
|
||||
.key-verify.ok { color: var(--sev-low-fg); }
|
||||
.key-verify.bad { color: var(--sev-critical-fg); }
|
||||
.key-verify.unknown { color: var(--text-faint); font-weight: 500; }
|
||||
.key-verify.checking { color: var(--text-dim); font-weight: 500; }
|
||||
|
||||
/* ============================================================ Terminal dock
|
||||
|
||||
|
||||
+52
-1
@@ -144,6 +144,45 @@ const PROVIDERS = [
|
||||
// In-memory only — never written to disk. Cleared on server restart.
|
||||
const apiKeys = new Map(); // provider key -> secret
|
||||
|
||||
// A cheap authenticated endpoint per provider, to confirm a key works. GET a
|
||||
// models/account list — a 200 means the key authenticates, 401/403 means it does
|
||||
// not. Providers without a reachable check (local/proxy, or no public endpoint)
|
||||
// return ok:null — the key is saved, just not auto-verified.
|
||||
function keyProbe(provider, key) {
|
||||
const bearer = { Authorization: `Bearer ${key}` };
|
||||
switch (provider) {
|
||||
case 'anthropic': return { url: 'https://api.anthropic.com/v1/models', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
|
||||
case 'openai': return { url: 'https://api.openai.com/v1/models', headers: bearer };
|
||||
case 'xai': return { url: 'https://api.x.ai/v1/models', headers: bearer };
|
||||
case 'gemini': return { url: `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`, headers: {} };
|
||||
case 'deepseek': return { url: 'https://api.deepseek.com/models', headers: bearer };
|
||||
case 'mistral': return { url: 'https://api.mistral.ai/v1/models', headers: bearer };
|
||||
case 'groq': return { url: 'https://api.groq.com/openai/v1/models', headers: bearer };
|
||||
case 'together': return { url: 'https://api.together.xyz/v1/models', headers: bearer };
|
||||
case 'openrouter': return { url: 'https://openrouter.ai/api/v1/key', headers: bearer };
|
||||
case 'moonshot': return { url: 'https://api.moonshot.ai/v1/models', headers: bearer };
|
||||
case 'qwen': return { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', headers: bearer };
|
||||
case 'nvidia_nim': return { url: 'https://integrate.api.nvidia.com/v1/models', headers: bearer };
|
||||
default: return null; // opencode, nous, zai, litellm, azure, ollama, llamacpp — not auto-verifiable here
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyKey(provider, key) {
|
||||
const probe = keyProbe(provider, key);
|
||||
if (!probe) return { ok: null, message: 'saved — this provider is not auto-verifiable here' };
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
const r = await fetch(probe.url, { headers: probe.headers, signal: ctrl.signal }).finally(() => clearTimeout(timer));
|
||||
if (r.ok) return { ok: true, message: 'key is valid' };
|
||||
if (r.status === 401 || r.status === 403) return { ok: false, message: `key rejected (HTTP ${r.status})` };
|
||||
if (r.status === 429) return { ok: true, message: 'key valid (rate-limited)' };
|
||||
return { ok: null, message: `saved — provider returned HTTP ${r.status}` };
|
||||
} catch (e) {
|
||||
return { ok: null, message: `saved — could not reach provider (${e.name === 'AbortError' ? 'timeout' : e.message})` };
|
||||
}
|
||||
}
|
||||
|
||||
function envOverrides() {
|
||||
const env = {};
|
||||
for (const [key, secret] of apiKeys) {
|
||||
@@ -1838,7 +1877,19 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
if (body.key) apiKeys.set(body.provider, body.key);
|
||||
else apiKeys.delete(body.provider);
|
||||
return sendJson(res, 200, { ok: true });
|
||||
// Save also verifies the key with a cheap authenticated call, so the
|
||||
// operator knows it works before a run depends on it. The key is saved
|
||||
// either way (some providers can't be auto-verified from here).
|
||||
let verify = { ok: null, message: 'saved' };
|
||||
if (body.key && body.verify !== false) verify = await verifyKey(body.provider, body.key);
|
||||
return sendJson(res, 200, { ok: true, verify });
|
||||
}
|
||||
if (req.method === 'POST' && p === '/api/keys/verify') {
|
||||
const body = await readBody(req);
|
||||
const key = body.key || apiKeys.get(body.provider);
|
||||
if (!body.provider || !PROVIDERS.some((pr) => pr.key === body.provider)) return sendJson(res, 400, { error: 'unknown provider' });
|
||||
if (!key) return sendJson(res, 400, { error: 'no key to verify' });
|
||||
return sendJson(res, 200, await verifyKey(body.provider, key));
|
||||
}
|
||||
m = p.match(/^\/api\/keys\/([^/]+)$/);
|
||||
if (req.method === 'DELETE' && m) {
|
||||
|
||||
Reference in new issue
Block a user