Add a retrieval-only HTTP bridge so Open-WebUI can reach the index
Open-WebUI runs in its own container with no WebGUI session, and nginx applies auth_request to everything it serves, so it cannot call the plugin API. Same shape as the arr webhook listener: node outside nginx, shared secret, one read-only verb. Serves chunks rather than answers because the caller already has a model loaded.
This commit is contained in:
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Retrieval-only HTTP bridge over the AI index. Lets a client that cannot see Varaverk's
|
||||
// filesystem — Open-WebUI in its own container — search the corpus and receive grounding
|
||||
// chunks as JSON.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// Started by start_ai_server.sh, which is the only thing that knows the port and secret.
|
||||
// Lives outside Unraid's nginx exactly as webhook_listener.js does, because the caller is a
|
||||
// container with no WebGUI session and nginx applies auth_request to everything it serves.
|
||||
// The shared secret in the query string is therefore the only gate.
|
||||
//
|
||||
// Returns chunks, never answers. Open-WebUI already has a model loaded and is going to
|
||||
// generate from whatever this returns; generating here as well would double the latency and
|
||||
// burn a second model load for a result the caller discards. ai_query.sh keeps the
|
||||
// generation path for CLI use — this endpoint deliberately does not.
|
||||
//
|
||||
// Calls search() in-process rather than shelling out to ai_query.sh. A shell round trip per
|
||||
// request would fork bash, re-source three conf files and re-open the index every time, for
|
||||
// a call that is otherwise a single embed plus a vector scan.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// One endpoint, one verb.
|
||||
// GET /search. No index management, no conf access, no generation. The surface an
|
||||
// unauthenticated port exposes should be the smallest thing that does the job.
|
||||
//
|
||||
// The caller chooses breadth, within bounds.
|
||||
// k, kind and section are accepted because the client knows what kind of question it is
|
||||
// asking. All are clamped or validated here — the caller is trusted to express intent,
|
||||
// not to be correct.
|
||||
//
|
||||
// Failures answer in the same shape as successes.
|
||||
// Every response is JSON with an ok flag. A tool calling this has no way to render an
|
||||
// HTML error page, and a caller that cannot parse the failure reports nothing at all.
|
||||
//
|
||||
// OPERATIONAL SAFEGUARDS
|
||||
// The secret is compared at fixed length before anything else runs.
|
||||
// No index is opened and no embedding is requested until the key matches, so an
|
||||
// unauthenticated caller cannot make this process do work. Compared with
|
||||
// timingSafeEqual on equal-length buffers — a plain !== leaks length and position
|
||||
// through response timing on a port that is, by design, reachable without a session.
|
||||
//
|
||||
// Read-only by construction.
|
||||
// The index is opened by search() for reading and nothing here writes to it, to conf,
|
||||
// or to the filesystem. The worst a valid key can do is read documentation that is
|
||||
// already in a git repository.
|
||||
//
|
||||
// Bounded work per request.
|
||||
// k is clamped to 1..25 and the query to 2000 characters, so no single call can pull the
|
||||
// whole index into memory or embed an unbounded string.
|
||||
//
|
||||
// Bad input is refused, not guessed.
|
||||
// kind is checked against the five real values; an unknown one is rejected rather than
|
||||
// passed to SQL where it would match zero rows and read as "the index has no answer" —
|
||||
// the most misleading failure this system can produce.
|
||||
//
|
||||
// Binds where it is told, and says so.
|
||||
// The bind address comes from the launcher. It is reachable from the docker bridge by
|
||||
// necessity; that is why the secret exists and why the surface is one read-only verb.
|
||||
//
|
||||
// CONFIGURATION
|
||||
// argv: <port> <secret> <bind> <db> <ollamaUrl> <embedModel>
|
||||
// All supplied by start_ai_server.sh from AI_HTTP_PORT, AI_HTTP_SECRET, AI_INDEX_DB and
|
||||
// <HOST>_OLLAMA_URL / _OLLAMA_EMBED_MODEL.
|
||||
//
|
||||
// RUNTIME MODES
|
||||
// GET /search?key=…&q=…[&k=8][&kind=readme][§ion=PURPOSE]
|
||||
// GET /health?key=… liveness only — no index access
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const [, , port, secret, bind, db, ollamaUrl, embedModel] = process.argv;
|
||||
|
||||
if (!port || !secret || !bind || !db || !ollamaUrl || !embedModel) {
|
||||
process.stderr.write('Usage: ai_serve.js <port> <secret> <bind> <db> <ollamaUrl> <embedModel>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { search } = require(path.join(__dirname, 'lib', 'search.js'));
|
||||
|
||||
const KINDS = new Set(['header', 'readme', 'manual', 'template', 'doc']);
|
||||
const MAX_Q = 2000;
|
||||
const SECRET_B = Buffer.from(secret);
|
||||
|
||||
function send(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// Equal-length compare first: timingSafeEqual throws on a length mismatch, and the length
|
||||
// itself is not worth leaking through an exception path on an unauthenticated port.
|
||||
function keyOk(given) {
|
||||
if (typeof given !== 'string') return false;
|
||||
const g = Buffer.from(given);
|
||||
if (g.length !== SECRET_B.length) return false;
|
||||
return crypto.timingSafeEqual(g, SECRET_B);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let url;
|
||||
try { url = new URL(req.url, 'http://localhost'); }
|
||||
catch { return send(res, 400, { ok: false, error: 'Bad request' }); }
|
||||
|
||||
if (!keyOk(url.searchParams.get('key'))) {
|
||||
return send(res, 403, { ok: false, error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (url.pathname === '/health') {
|
||||
return send(res, 200, { ok: true, service: 'ai_serve', db });
|
||||
}
|
||||
|
||||
if (url.pathname !== '/search') {
|
||||
return send(res, 404, { ok: false, error: 'Not found' });
|
||||
}
|
||||
|
||||
const q = (url.searchParams.get('q') || '').trim();
|
||||
if (!q) return send(res, 400, { ok: false, error: 'q is required' });
|
||||
if (q.length > MAX_Q) {
|
||||
return send(res, 400, { ok: false, error: `q exceeds ${MAX_Q} characters` });
|
||||
}
|
||||
|
||||
const kind = url.searchParams.get('kind') || null;
|
||||
if (kind && !KINDS.has(kind)) {
|
||||
return send(res, 400, {
|
||||
ok: false,
|
||||
error: `unknown kind '${kind}' (expected: ${[...KINDS].join(', ')})`,
|
||||
});
|
||||
}
|
||||
|
||||
let k = parseInt(url.searchParams.get('k') || '8', 10);
|
||||
if (!Number.isFinite(k)) k = 8;
|
||||
k = Math.min(Math.max(k, 1), 25);
|
||||
|
||||
try {
|
||||
const r = await search({
|
||||
dbPath: db, url: ollamaUrl, embedModel, query: q,
|
||||
k, perFile: 3,
|
||||
section: url.searchParams.get('section') || null,
|
||||
kind,
|
||||
});
|
||||
send(res, 200, {
|
||||
ok: true,
|
||||
query: q,
|
||||
intents: r.intents,
|
||||
scanned: r.scanned,
|
||||
results: r.results.map(x => ({
|
||||
path: x.path,
|
||||
section: x.section,
|
||||
heading: x.heading,
|
||||
score: Number(x.score.toFixed(4)),
|
||||
content: x.content,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
send(res, 500, { ok: false, error: `retrieval failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (e) => {
|
||||
process.stderr.write(`ai_serve: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(parseInt(port, 10), bind, () => {
|
||||
process.stdout.write(`ai_serve listening on ${bind}:${port}\n`);
|
||||
});
|
||||
Reference in New Issue
Block a user