mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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
|
* 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
|
* 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
|
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||||
* is caught at connect time by the guard instead.
|
|
||||||
*/
|
*/
|
||||||
export function isPrivateDestination(rawUrl: string): boolean {
|
export function isPrivateDestination(rawUrl: string): boolean {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
@@ -98,12 +97,18 @@ export function evaluateEgress(
|
|||||||
|
|
||||||
const host = normalizeHost(url.hostname);
|
const host = normalizeHost(url.hostname);
|
||||||
const isPrivate = isPrivateDestination(rawUrl);
|
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 };
|
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 {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ describe("evaluateEgress", () => {
|
|||||||
expect(decision.isPrivate).toBe(true);
|
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", () => {
|
it("matches the allowlist case-insensitively", () => {
|
||||||
expect(
|
expect(
|
||||||
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
|
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ export async function probeAiModels(input: {
|
|||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
apiKey?: string | null;
|
apiKey?: string | null;
|
||||||
providerId?: number | null;
|
providerId?: number | null;
|
||||||
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> {
|
}): Promise<{
|
||||||
|
models: string[];
|
||||||
|
source: "live" | "fallback" | "none";
|
||||||
|
warning?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/ai/probe-models", input)).data;
|
return (await authApi.post("/ai/probe-models", input)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -85,21 +85,24 @@ function AiProviderEditForm({
|
|||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
const [customModel, setCustomModel] = useState(false);
|
const [customModel, setCustomModel] = useState(false);
|
||||||
const [detecting, setDetecting] = useState(false);
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const detectModels = useCallback(async () => {
|
const detectModels = useCallback(async () => {
|
||||||
setDetecting(true);
|
setDetecting(true);
|
||||||
|
setDetectWarning(null);
|
||||||
try {
|
try {
|
||||||
const detected = await getAiProviderModels(provider.id);
|
const detected = await getAiProviderModels(provider.id);
|
||||||
setModels(detected);
|
setModels(detected);
|
||||||
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||||
} catch {
|
} catch (error) {
|
||||||
setModels([]);
|
setModels([]);
|
||||||
setCustomModel(true);
|
setCustomModel(true);
|
||||||
|
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
|
||||||
} finally {
|
} finally {
|
||||||
setDetecting(false);
|
setDetecting(false);
|
||||||
}
|
}
|
||||||
}, [provider.id, defaultModel]);
|
}, [provider.id, defaultModel, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void detectModels();
|
void detectModels();
|
||||||
@@ -203,6 +206,11 @@ function AiProviderEditForm({
|
|||||||
placeholder={t("ai.defaultModelPlaceholder")}
|
placeholder={t("ai.defaultModelPlaceholder")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{detectWarning && (
|
||||||
|
<p className="text-[11px] leading-snug text-destructive">
|
||||||
|
{detectWarning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -269,8 +277,8 @@ export function AiProviderSettings({
|
|||||||
apiKey: apiKey.trim() || null,
|
apiKey: apiKey.trim() || null,
|
||||||
});
|
});
|
||||||
setModels(result.models);
|
setModels(result.models);
|
||||||
if (result.source === "fallback") {
|
if (result.source !== "live") {
|
||||||
setDetectWarning(t("ai.modelDetectFailed"));
|
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
|
||||||
}
|
}
|
||||||
// Pick the first suggestion so the field is never left empty.
|
// Pick the first suggestion so the field is never left empty.
|
||||||
setDefaultModel((current) => current || result.models[0] || "");
|
setDefaultModel((current) => current || result.models[0] || "");
|
||||||
|
|||||||
@@ -82,4 +82,17 @@ describe("AiProviderSettings", () => {
|
|||||||
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
screen.getByRole("combobox", { name: "ai.defaultModel" }),
|
||||||
).toBeTruthy();
|
).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