diff options
| -rw-r--r-- | defaults/web.xml | 14 | ||||
| -rw-r--r-- | htroot/LLMSelection_p.html | 36 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/LLMAdminProxyServlet.java | 272 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/OllamaTagsServlet.java | 4 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/OpenAIModelsServlet.java | 4 | ||||
| -rw-r--r-- | source/net/yacy/http/servlets/RAGProxyServlet.java | 11 |
6 files changed, 334 insertions, 7 deletions
diff --git a/defaults/web.xml b/defaults/web.xml index 0e5241e6c..2e2efa894 100644 --- a/defaults/web.xml +++ b/defaults/web.xml @@ -65,6 +65,13 @@ <servlet-class>net.yacy.http.servlets.OpenAIModelsServlet</servlet-class> </servlet> + <!-- admin passthrough proxy for configured LLM endpoints; also embedded in the + RAGProxy/OllamaTags/OpenAIModels servlets, activated by a hoststub parameter --> + <servlet> + <servlet-name>LLMAdminProxyServlet</servlet-name> + <servlet-class>net.yacy.http.servlets.LLMAdminProxyServlet</servlet-class> + </servlet> + <servlet> <servlet-name>MCPSearchServlet</servlet-name> <servlet-class>net.yacy.http.servlets.MCPSearchServlet</servlet-class> @@ -119,6 +126,13 @@ </servlet-mapping> <servlet-mapping> + <servlet-name>LLMAdminProxyServlet</servlet-name> + <url-pattern>/api/pull</url-pattern> + <url-pattern>/api/delete</url-pattern> + <url-pattern>/api/show</url-pattern> + </servlet-mapping> + + <servlet-mapping> <servlet-name>MCPSearchServlet</servlet-name> <url-pattern>/tools</url-pattern> <url-pattern>/tools/initialize</url-pattern> diff --git a/htroot/LLMSelection_p.html b/htroot/LLMSelection_p.html index 628ab1211..c7939b64f 100644 --- a/htroot/LLMSelection_p.html +++ b/htroot/LLMSelection_p.html @@ -128,8 +128,22 @@ /*** *** API functions to access Ollama or OpenAI endpoints (list/load/delete models) + *** + *** All endpoint calls are routed through the YaCy-internal admin passthrough + *** proxy (see LLMAdminProxyServlet.java): instead of calling the LLM endpoint + *** directly, the browser calls the same API path on YaCy itself (same-origin) + *** and passes the target endpoint as hoststub parameter. This keeps endpoints + *** reachable which are only visible from the YaCy server (remote YaCy + *** installations), avoids CORS/mixed-content issues and lets YaCy inject the + *** stored api_key server-side. The proxy requires admin authentication, which + *** the browser already holds on this page. ***/ + function proxyUrl(hoststub, path) { + const target = (hoststub || "").trim().replace(/\/+$/, ""); + return `${path}?hoststub=${encodeURIComponent(target)}`; + } + async function fetchJsonOrThrow(url, options = {}) { const response = await fetch(url, options); if (response.status !== 200) { @@ -222,7 +236,7 @@ } async function deleteOllamaModel(hoststub, modelName) { - const response = await fetch(`${hoststub}/api/delete`, { + const response = await fetch(proxyUrl(hoststub, "/api/delete"), { method: "DELETE", headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: JSON.stringify({ model: modelName }) @@ -235,7 +249,7 @@ } async function downloadOllamaModel(hoststub, modelName) { - const response = await fetch(`${hoststub}/api/pull`, { + const response = await fetch(proxyUrl(hoststub, "/api/pull"), { method: "POST", headers: {"Accept": "application/json", "Content-Type": "application/json"}, body: JSON.stringify({ model: modelName, stream: false }) @@ -256,7 +270,15 @@ } async function requestModelsForService(service, hoststub) { - return service === "OLLAMA" ? fetchJsonOrThrow(`${hoststub}/api/tags`) : fetchJsonOrThrow(`${hoststub}/v1/models`); + const url = service === "OLLAMA" ? proxyUrl(hoststub, "/api/tags") : proxyUrl(hoststub, "/v1/models"); + const options = {}; + const apikeyEl = document.getElementById("apikey"); + const apikey = apikeyEl ? apikeyEl.value.trim() : ""; + if (apikey) { + // for endpoints which are not saved yet the proxy cannot look up the key itself + options.headers = { "Authorization": `Bearer ${apikey}` }; + } + return fetchJsonOrThrow(url, options); } function handleModelLoadError(service, error) { @@ -1200,7 +1222,7 @@ if (!endpointBase) { return false; } - const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`; + const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath); const headers = { "Content-Type": "application/json" }; if (apikey) { headers.Authorization = `Bearer ${apikey}`; @@ -1296,7 +1318,7 @@ if (!endpointBase) { return false; } - const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`; + const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath); const headers = { "Content-Type": "application/json" }; if (apikey) { headers.Authorization = `Bearer ${apikey}`; @@ -1429,7 +1451,7 @@ if (!endpointBase) { return false; } - const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`; + const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath); const headers = { "Content-Type": "application/json" }; if (apikey) { headers.Authorization = `Bearer ${apikey}`; @@ -1618,7 +1640,7 @@ } async function runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, inputText) { - const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`; + const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath); const headers = { "Content-Type": "application/json" }; if (apikey) { headers.Authorization = `Bearer ${apikey}`; diff --git a/source/net/yacy/http/servlets/LLMAdminProxyServlet.java b/source/net/yacy/http/servlets/LLMAdminProxyServlet.java new file mode 100644 index 000000000..ff4ccd0d3 --- /dev/null +++ b/source/net/yacy/http/servlets/LLMAdminProxyServlet.java @@ -0,0 +1,272 @@ +/** + * LLMAdminProxyServlet + * Copyright 2026 by Michael Peter Christen + * First released 04.07.2026 at https://yacy.net + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program in the file lgpl21.txt + * If not, see <http://www.gnu.org/licenses/>. + */ + +package net.yacy.http.servlets; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import net.yacy.cora.protocol.Domains; +import net.yacy.cora.util.ConcurrentLog; +import net.yacy.cora.util.LogRedaction; +import net.yacy.data.UserDB; +import net.yacy.search.Switchboard; +import net.yacy.search.SwitchboardConstants; + +/** + * Admin passthrough proxy for OpenAI-API compatible LLM endpoints (Ollama, LM Studio, + * OpenAI, OpenRouter, ...). This makes the LLM endpoints which are configured in + * /LLMSelection_p.html reachable for the browser front-end even when YaCy runs on a + * remote host and the LLM endpoint is only visible from the YaCy server, not from the + * user's machine. As a side effect all endpoint calls become same-origin requests, + * which also avoids CORS and mixed-content (https YaCy / http Ollama) problems. + * + * Concept - one endpoint, two operation modes: + * YaCy mirrors the LLM API paths (/v1/chat/completions, /api/tags, /v1/models, + * /api/pull, /api/delete, /api/show) on its own port. The mode of a request is not + * distinguished by the path but by the presence of a "hoststub" request parameter + * (or X-LLM-Hoststub header) that selects the target endpoint: + * + * - Public mode (no hoststub): the request is NOT handled here. The serving servlet + * (RAGProxyServlet, OllamaTagsServlet, OpenAIModelsServlet) keeps its normal public + * behavior: target endpoint, model routing and api_key come exclusively from the + * server-side configuration, and the AIShield rules (localhost restriction, rate + * limiting, see /AIShield_p.html) apply. This mode serves yacychat.html and external + * OpenAI-compatible clients, also for non-admin users. + * + * - Admin passthrough mode (hoststub given): the request is forwarded 1:1 to the given + * endpoint, streaming the response back chunk by chunk. Because here the caller + * determines the proxy target, this mode is strictly limited to authenticated + * administrators; AIShield rules and RAG augmentation are skipped. This mode serves + * the endpoint management functions of /LLMSelection_p.html (model lists, model + * pull/delete, capability tests). + * + * Security invariant: "hoststub given => admin authentication enforced before anything + * else happens". A non-admin user cannot turn the public mode into an open proxy. + * Additional hardening even for admins: only the paths listed above are mirrored, and + * mutating operations are limited to endpoints already present in the configuration; + * arbitrary hoststubs are only accepted for the read-only model list probes. The + * api_key is injected server-side from the stored configuration, so it does not need + * to be exposed to the browser. + * + * This class is both a servlet on its own (for the paths which exist only in + * passthrough mode, mapped in defaults/web.xml) and a static helper embedded in the + * servlets that share their path with the public mode. + */ +public class LLMAdminProxyServlet extends HttpServlet { + + private static final long serialVersionUID = 3411544789759643138L; + + private static final String HOSTSTUB_PARAMETER = "hoststub"; + private static final String HOSTSTUB_HEADER = "X-LLM-Hoststub"; + + /** paths that this proxy is willing to mirror on the target endpoint */ + private static final Set<String> PROXY_PATHS = Set.of( + "/v1/chat/completions", "/v1/models", + "/api/tags", "/api/show", "/api/pull", "/api/delete"); + + /** read-only probe paths which may be used with a not-yet-saved hoststub */ + private static final Set<String> PROBE_PATHS = Set.of("/api/tags", "/v1/models"); + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + @Override + public void service(ServletRequest request, ServletResponse response) throws IOException, ServletException { + // this servlet is mapped to paths which only exist in passthrough mode (i.e. /api/pull); + // without a hoststub there is nothing we can serve + if (!tryHandle((HttpServletRequest) request, (HttpServletResponse) response)) { + ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST, "hoststub parameter required"); + } + } + + /** + * Handle the request in admin passthrough mode if a hoststub is given. + * Servlets on shared paths (RAGProxyServlet, OllamaTagsServlet, OpenAIModelsServlet) + * call this first and continue with their normal public behavior when this returns false. + * @return true if the request carried a hoststub and was fully handled (including error responses) + */ + public static boolean tryHandle(final HttpServletRequest hrequest, final HttpServletResponse hresponse) throws IOException { + String hoststub = hrequest.getParameter(HOSTSTUB_PARAMETER); + if (hoststub == null || hoststub.trim().isEmpty()) hoststub = hrequest.getHeader(HOSTSTUB_HEADER); + if (hoststub == null || hoststub.trim().isEmpty()) return false; + handle(hrequest, hresponse, hoststub.trim()); + return true; + } + + private static void handle(final HttpServletRequest hrequest, final HttpServletResponse hresponse, String hoststub) throws IOException { + final long start = System.currentTimeMillis(); + final String method = hrequest.getMethod(); + + if ("OPTIONS".equals(method)) { + hresponse.setStatus(HttpServletResponse.SC_OK); + return; + } + + // the caller determines the proxy target, therefore this mode is admin-only + if (!requireAdmin(hrequest, hresponse)) { + ConcurrentLog.warn("LLMAdminProxy", "event=passthrough phase=reject reason=not-admin ip=" + hrequest.getRemoteAddr()); + return; + } + + while (hoststub.endsWith("/")) hoststub = hoststub.substring(0, hoststub.length() - 1); + if (!hoststub.startsWith("http://") && !hoststub.startsWith("https://")) { + hresponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "hoststub must be a http(s) URL"); + return; + } + + final String path = hrequest.getRequestURI(); + if (!PROXY_PATHS.contains(path)) { + hresponse.sendError(HttpServletResponse.SC_NOT_FOUND, "path not proxied"); + return; + } + + // resolve the api_key for the hoststub from the stored configuration; + // mutating operations are only allowed on configured endpoints + final Map<String, String> configured = configuredHoststubs(); + final boolean known = configured.containsKey(hoststub); + if (!known && !PROBE_PATHS.contains(path)) { + hresponse.sendError(HttpServletResponse.SC_FORBIDDEN, "hoststub is not a configured endpoint"); + return; + } + + final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(hoststub + path)) + .timeout(Duration.ofMillis("/api/pull".equals(path) ? 60 * 60 * 1000 : 10 * 60 * 1000)); + final String contentType = hrequest.getContentType(); + if (contentType != null) requestBuilder.header("Content-Type", contentType); + final String accept = hrequest.getHeader("Accept"); + if (accept != null) requestBuilder.header("Accept", accept); + + // inject the Authorization header server-side; a client-provided header + // (needed to probe endpoints which are not saved yet) takes precedence + final String clientAuthorization = hrequest.getHeader("Authorization"); + final String configuredKey = configured.get(hoststub); + if (clientAuthorization != null && !clientAuthorization.isEmpty()) { + requestBuilder.header("Authorization", clientAuthorization); + } else if (configuredKey != null && !configuredKey.isEmpty()) { + requestBuilder.header("Authorization", "Bearer " + configuredKey); + } + + if ("GET".equals(method) || "HEAD".equals(method)) { + requestBuilder.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + final byte[] body = hrequest.getInputStream().readAllBytes(); + requestBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(body)); + } + + ConcurrentLog.info("LLMAdminProxy", "event=passthrough phase=start method=" + method + " path=" + path + " backend=" + LogRedaction.redact(hoststub) + " known=" + known); + try { + final HttpResponse<InputStream> upstream = CLIENT.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); + hresponse.setStatus(upstream.statusCode()); + hresponse.setHeader("Content-Type", upstream.headers().firstValue("Content-Type").orElse("application/json;charset=utf-8")); + final ServletOutputStream out = hresponse.getOutputStream(); + try (InputStream in = upstream.body()) { + final byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + out.flush(); // flush each chunk to support streamed responses (chat completions, pull progress) + } + } + ConcurrentLog.info("LLMAdminProxy", "event=passthrough phase=end status=" + upstream.statusCode() + " durationMs=" + (System.currentTimeMillis() - start)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + hresponse.sendError(HttpServletResponse.SC_BAD_GATEWAY, "upstream request interrupted"); + } catch (final IOException e) { + ConcurrentLog.warn("LLMAdminProxy", "event=passthrough phase=end result=failure reason=" + LogRedaction.redactMessage(e) + " durationMs=" + (System.currentTimeMillis() - start)); + if (!hresponse.isCommitted()) hresponse.sendError(HttpServletResponse.SC_BAD_GATEWAY, "endpoint not reachable from the YaCy server"); + } + } + + /** + * Check for administrator access, mirroring the rules of YaCySecurityHandler: + * localhost access with the localhost-admin setting is granted without credentials, + * everything else requires an authenticated user with the admin right. Sends the + * authentication challenge (401) when credentials are missing. + */ + private static boolean requireAdmin(final HttpServletRequest hrequest, final HttpServletResponse hresponse) throws IOException { + final Switchboard sb = Switchboard.getSwitchboard(); + final String adminRole = UserDB.AccessRight.ADMIN_RIGHT.toString(); + if (sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false) + && Domains.isLocalhost(hrequest.getRemoteAddr())) return true; + if (hrequest.isUserInRole(adminRole)) return true; + try { + if (hrequest.authenticate(hresponse) && hrequest.isUserInRole(adminRole)) return true; + } catch (final ServletException | IOException e) { + // fall through to the error response below + } + if (!hresponse.isCommitted()) hresponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "admin login required"); + return false; + } + + /** + * Collect all LLM endpoints from the stored configuration together with their api_key: + * the inference system used on /LLMSelection_p.html and all production model rows. + */ + private static Map<String, String> configuredHoststubs() { + final Switchboard sb = Switchboard.getSwitchboard(); + final Map<String, String> hoststubs = new HashMap<>(); + try { + final JSONObject inference = new JSONObject(new JSONTokener(sb.getConfig("ai.inference_system", "{}"))); + putHoststub(hoststubs, inference); + } catch (final JSONException e) {} + try { + final JSONArray productionModels = new JSONArray(new JSONTokener(sb.getConfig("ai.production_models", "[]"))); + for (int i = 0; i < productionModels.length(); i++) { + putHoststub(hoststubs, productionModels.optJSONObject(i)); + } + } catch (final JSONException e) {} + return hoststubs; + } + + private static void putHoststub(final Map<String, String> hoststubs, final JSONObject row) { + if (row == null) return; + String hoststub = row.optString("hoststub", "").trim(); + while (hoststub.endsWith("/")) hoststub = hoststub.substring(0, hoststub.length() - 1); + if (hoststub.isEmpty()) return; + final String apiKey = row.optString("api_key", "").trim(); + // do not let a row without key shadow a key from another row for the same endpoint + if (!hoststubs.containsKey(hoststub) || !apiKey.isEmpty()) hoststubs.put(hoststub, apiKey); + } +} diff --git a/source/net/yacy/http/servlets/OllamaTagsServlet.java b/source/net/yacy/http/servlets/OllamaTagsServlet.java index 85dc190ca..026eb19eb 100644 --- a/source/net/yacy/http/servlets/OllamaTagsServlet.java +++ b/source/net/yacy/http/servlets/OllamaTagsServlet.java @@ -48,6 +48,10 @@ public class OllamaTagsServlet extends HttpServlet { HttpServletResponse hresponse = (HttpServletResponse) response; HttpServletRequest hrequest = (HttpServletRequest) request; + // admin passthrough mode: a hoststub parameter selects an LLM endpoint + // whose /api/tags is mirrored 1:1 (admin-only) + if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return; + // Add CORS headers hresponse.setHeader("Access-Control-Allow-Origin", "*"); hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); diff --git a/source/net/yacy/http/servlets/OpenAIModelsServlet.java b/source/net/yacy/http/servlets/OpenAIModelsServlet.java index c4d9f0f71..0f98fb86a 100644 --- a/source/net/yacy/http/servlets/OpenAIModelsServlet.java +++ b/source/net/yacy/http/servlets/OpenAIModelsServlet.java @@ -48,6 +48,10 @@ public class OpenAIModelsServlet extends HttpServlet { HttpServletResponse hresponse = (HttpServletResponse) response; HttpServletRequest hrequest = (HttpServletRequest) request; + // admin passthrough mode: a hoststub parameter selects an LLM endpoint + // whose /v1/models is mirrored 1:1 (admin-only) + if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return; + // Add CORS headers hresponse.setHeader("Access-Control-Allow-Origin", "*"); hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java index c41789e9c..6e0b8fd0e 100644 --- a/source/net/yacy/http/servlets/RAGProxyServlet.java +++ b/source/net/yacy/http/servlets/RAGProxyServlet.java @@ -55,6 +55,13 @@ import net.yacy.search.Switchboard; /** * This class implements a Retrieval Augmented Generation ("RAG") proxy which * uses a YaCy search index to enrich a chat with search results. + * + * The endpoint has two operation modes (see LLMAdminProxyServlet for the concept): + * without a "hoststub" request parameter it is the public, AIShield-regulated RAG chat + * proxy implemented in this class, where the target LLM comes from the server-side + * configuration. With a "hoststub" parameter the request is handed over to the + * admin-only passthrough proxy which mirrors the given endpoint 1:1. + * * You can test this using a curl command: curl -X POST "http://localhost:8090/v1/chat/completions"\ -s -H "Content-Type: application/json"\ @@ -92,6 +99,10 @@ public class RAGProxyServlet extends HttpServlet { HttpServletResponse hresponse = (HttpServletResponse) response; HttpServletRequest hrequest = (HttpServletRequest) request; + // admin passthrough mode: a hoststub parameter selects a configured LLM endpoint + // which is mirrored 1:1 (admin-only, no RAG augmentation, no AIShield rules) + if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return; + // Add CORS headers hresponse.setHeader("Access-Control-Allow-Origin", "*"); hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); |
