mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-25 15:36:58 +00:00
Fix private AI custom endpoints (#1299)
This commit is contained in:
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
|
||||
/**
|
||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||
* private only if it is "localhost" -- anything else resolves through DNS and
|
||||
* is caught at connect time by the guard instead.
|
||||
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||
*/
|
||||
export function isPrivateDestination(rawUrl: string): boolean {
|
||||
let url: URL;
|
||||
@@ -98,12 +97,18 @@ export function evaluateEgress(
|
||||
|
||||
const host = normalizeHost(url.hostname);
|
||||
const isPrivate = isPrivateDestination(rawUrl);
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
|
||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||
// must use the private fetch path; sending it through safeOutboundFetch
|
||||
// would reject it after DNS resolution and make hostname allowlist entries
|
||||
// ineffective. Only administrators can write this list.
|
||||
if (normalized.includes(host)) {
|
||||
return { allowed: true, isPrivate: true };
|
||||
}
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
|
||||
@@ -61,6 +61,16 @@ describe("evaluateEgress", () => {
|
||||
expect(decision.isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["llm.internal", "host.docker.internal"])(
|
||||
"routes allowlisted private DNS name %s through the private path",
|
||||
(host) => {
|
||||
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
|
||||
allowed: true,
|
||||
isPrivate: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the allowlist case-insensitively", () => {
|
||||
expect(
|
||||
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
|
||||
|
||||
@@ -114,7 +114,11 @@ export async function probeAiModels(input: {
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerId?: number | null;
|
||||
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> {
|
||||
}): Promise<{
|
||||
models: string[];
|
||||
source: "live" | "fallback" | "none";
|
||||
warning?: string;
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.post("/ai/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -85,21 +85,24 @@ function AiProviderEditForm({
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const detected = await getAiProviderModels(provider.id);
|
||||
setModels(detected);
|
||||
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
setModels([]);
|
||||
setCustomModel(true);
|
||||
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [provider.id, defaultModel]);
|
||||
}, [provider.id, defaultModel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void detectModels();
|
||||
@@ -203,6 +206,11 @@ function AiProviderEditForm({
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-destructive">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -269,8 +277,8 @@ export function AiProviderSettings({
|
||||
apiKey: apiKey.trim() || null,
|
||||
});
|
||||
setModels(result.models);
|
||||
if (result.source === "fallback") {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
if (result.source !== "live") {
|
||||
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
|
||||
}
|
||||
// Pick the first suggestion so the field is never left empty.
|
||||
setDefaultModel((current) => current || result.models[0] || "");
|
||||
|
||||
@@ -82,4 +82,17 @@ describe("AiProviderSettings", () => {
|
||||
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the provider error when refreshing models fails", async () => {
|
||||
api.getAiProviderModels.mockRejectedValue(
|
||||
new Error("Add llm.internal to the AI endpoint allowlist"),
|
||||
);
|
||||
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user