Compare commits

..
Author SHA1 Message Date
Thomas c39a8854aa feat: add authenticated integration request MCP tool (#6835) 2026-09-13 11:05:23 +02:00
Thomas c32a5317ca fix: remove apps multi-select toggle (#6834) 2026-09-12 16:34:03 +02:00
Thomas f18033ddaa fix: sync dev fixes into v2 and improve regression reports (#6821)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: homarr-update-contributors[bot] <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
Co-authored-by: homarr-crowdin[bot] <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Closes #6604
2026-09-12 15:31:04 +02:00
Thomas ff2b589380 fix(board): refine container spacing and collapse layout (#6833) 2026-09-12 15:23:50 +02:00
Thomas 9bc3802b3b fix(wud): authenticate HTTP requests and validate container access (#6831) 2026-09-12 10:57:36 +02:00
19 changed files with 672 additions and 99 deletions

No files matched your search

@@ -24,12 +24,14 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
<IntegrationSecrets
secrets={[
@@ -40,7 +42,7 @@ WUD does not require authentication by default. If your instance is protected wi
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
+31
View File
@@ -70,6 +70,37 @@ through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
## Authenticated integration requests
`integration_request` calls API endpoints without a dedicated tool, using stored integration credentials.
Get `integrationId` from `integration_all`. Every method requires **full integration access**: arbitrary GET
endpoints can expose credentials or change state. DELETE additionally requires `confirmed: true` after user confirmation.
Ask your agent to consult the service's official API docs with its browsing tools, or provide the endpoint contract.
For example, using the [Sonarr API docs](https://sonarr.tv/docs/api/#v3):
```json
{ "integrationId": "<Sonarr ID>", "method": "GET", "path": "/api/v3/series" }
```
Deleting series 42 **and its episode files**, after confirmation:
```json
{ "integrationId": "<Sonarr ID>", "method": "DELETE", "path": "/api/v3/series/42?deleteFiles=true", "confirmed": true }
```
Supports GET, POST, PUT, PATCH and DELETE with an optional JSON `body` (10 KiB maximum; not allowed for GET).
Returns `{status, data}` with parsed JSON, text, or `null` for an empty body, including upstream error responses.
Known credentials are redacted. Requests have a 15-second total deadline, a 10-second per-request timeout and a
1 MiB response limit. HTTPS uses Homarr's trusted certificates and hostname exceptions.
Paths follow `new URL(path, integration.url)`: `/api/...` starts at the origin root; `api/...` resolves relative to
the configured URL's directory. Absolute URLs, protocol-relative paths, credentials, fragments and redirects are rejected.
Supported integrations: Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd,
Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud. Other authentication schemes
are rejected. The built-in Assistant's default mutation approval applies to this tool, including GET calls.
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
@@ -11,6 +11,7 @@ import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useSectionContext } from "./section-context";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
@@ -25,6 +26,7 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const { section: parentSection } = useSectionContext();
const [isEditMode] = useEditMode();
const t = useI18n("section.container");
const tSection = useI18n("section");
@@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
let labelLeft = 8;
let labelRight = 8;
if (isEditMode) {
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
labelLeft = 36;
if (parentSection.kind === "container") labelLeft = 68;
} else if (options.showOpenAll) {
labelRight = 40;
}
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
const toggleLayout = isVisuallyCollapsed
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
: {
top: "calc(var(--mantine-spacing-xs) * -1)",
left: labelLeft,
w: "auto",
h: 20,
maw: `calc(100% - ${labelLeft + labelRight}px)`,
};
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
@@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
{...toggleLayout}
px={6}
ps={isVisuallyCollapsed ? labelLeft : 6}
pe={isVisuallyCollapsed ? labelRight : 6}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
leftSection={options.showLabel && toggleIcon}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -94,13 +104,7 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
{options.showLabel ? label : toggleIcon}
</Button>
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
@@ -133,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
}
.board-grid-placeholder[data-grid-placeholder-type="section"] {
clip-path: none;
background: none;
border: 0;
}
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
position: absolute;
inset: var(--board-container-inset, 0px);
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: inherit;
}
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
@@ -136,6 +136,7 @@ export const GridPreviewLayer = ({
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-placeholder-type={targetPlacement.type}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
@@ -10,9 +10,9 @@
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
kicks in when the property is entirely unset, not merely inherited. Reset it here so every
container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
@@ -62,9 +62,9 @@
overflow: visible;
}
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
/* SectionGrid fits its content to the same inset in both view and edit mode. */
.staticItem[data-type="section"] > .contentMount {
inset: 0;
inset: var(--board-container-inset, 0px);
}
.staticItem[data-type="item"] > .contentMount {
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
const GridContentScaleContext = createContext(1);
const CONTAINER_CARD_INSET = 5;
interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
@@ -50,6 +53,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useContext(GridContentScaleContext);
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -143,36 +147,37 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const parentScale = canvasScale * parentContentScale;
const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
// Match the card's inset without changing its persisted grid footprint. Include ancestor
// container zoom so equally sized nested containers keep distinct borders at every depth.
let outerCardInset = 0;
if (section.kind === "container") {
outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
}
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
// Fit square cells into the inset card. Scrollable grids use the visible row count,
// not their full content height. Collapse controls never change this geometry.
let containerContentScale = 1;
if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
containerContentScale = Math.max(
0.01,
Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
);
}
const contentScale = parentContentScale * containerContentScale;
const effectiveContentScale = effectiveCanvasScale * containerContentScale;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -268,9 +273,9 @@ export const SectionGrid = ({
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
"--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
} as CSSProperties
}
data-section-id={section.id}
@@ -289,14 +294,17 @@ export const SectionGrid = ({
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<GridContentScaleContext.Provider value={contentScale}>
<SectionContent />
</GridContentScaleContext.Provider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
@@ -304,9 +312,6 @@ export const SectionGrid = ({
);
};
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
@@ -0,0 +1,204 @@
import { Buffer } from "node:buffer";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { CustomWidgetDomainError, executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { protectedProcedure } from "../../trpc";
export const integrationRequestSchema = z.object({
integrationId: z.string().min(1).max(128),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1).max(8192),
body: z.json().optional(),
confirmed: z
.boolean()
.optional()
.describe("Required for DELETE after the user confirms the deletion and its effects"),
});
export function resolveIntegrationRequestUrl(baseUrl: string, path: string): URL {
// Reject parser normalization tricks as well as ordinary absolute URLs.
// eslint-disable-next-line no-control-regex -- Reject URL parser control-character normalization.
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//") || /[\\\s\u0000-\u001f\u007f]/u.test(path)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "path must be a relative URL on the integration origin" });
}
try {
const base = new URL(baseUrl);
const target = new URL(path, base);
if (
!["http:", "https:"].includes(base.protocol) ||
base.username ||
base.password ||
target.origin !== base.origin ||
target.username ||
target.password ||
target.hash
)
throw new Error();
return target;
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "path must resolve to the integration origin without credentials or a fragment",
});
}
}
// Match the existing adapters; never guess authentication from secret names alone.
const requestAuth = {
sonarr: "apiKeyHeader",
radarr: "apiKeyHeader",
lidarr: "apiKeyHeader",
readarr: "apiKeyHeader",
prowlarr: "apiKeyHeader",
overseerr: "apiKeyHeader",
jellyseerr: "apiKeyHeader",
seerr: "apiKeyHeader",
immich: "apiKeyHeader",
slskd: "apiKeyHeader",
homeAssistant: "bearer",
coolify: "bearer",
audiobookshelf: "bearer",
speedtestTracker: "bearer",
adGuardHome: "basic",
nextcloud: "basic",
} satisfies Partial<Record<IntegrationKind, string>>;
export function resolveIntegrationRequestAuth(
kind: IntegrationKind,
secrets: CustomWidgetAuthConfig["secrets"],
): CustomWidgetAuthConfig {
if (!(kind in requestAuth)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This integration's authentication is not supported by integration_request",
});
}
const type = requestAuth[kind as keyof typeof requestAuth];
let required = ["apiKey"];
if (type === "basic") required = ["username", "password"];
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration credentials are incomplete" });
}
return { type, secrets };
}
export function redactIntegrationResponse(data: unknown, secrets: CustomWidgetAuthConfig["secrets"]): unknown {
const values = secrets.map(({ value }) => value).filter(Boolean);
const username = secrets.find(({ kind }) => kind === "username")?.value;
const password = secrets.find(({ kind }) => kind === "password")?.value;
if (username && password) values.push(`${username}:${password}`);
const sensitive = [
...new Set(
values.flatMap((value) => [
value,
JSON.stringify(value).slice(1, -1),
encodeURIComponent(value),
Buffer.from(value).toString("base64"),
]),
),
].toSorted((a, b) => b.length - a.length);
const redact = (value: string) => {
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
return value;
};
const visit = (value: unknown): unknown => {
if (typeof value === "string") return redact(value);
if (
(typeof value === "number" || typeof value === "boolean" || value === null) &&
redact(String(value)) !== String(value)
)
return "[REDACTED]";
if (Array.isArray(value)) return value.map(visit);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
}
return value;
};
return visit(data);
}
export const integrationRequestProcedure = protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Make an authenticated HTTP request to a configured integration using its stored credentials. Get integrationId from integration_all. Every method requires hasFullAccess because arbitrary endpoints can expose service credentials or mutate state. DELETE requires confirmed=true after user confirmation, including effects such as deleting files. Returns {status,data}, including upstream error statuses. Use the service's API documentation to choose the method, relative path and optional JSON body; consult official docs with your browsing tools, or ask the user for API docs if unavailable. Absolute URLs and redirects are rejected. 15 second deadline, 1 MiB response, 10 KiB JSON body. Supports Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home and Nextcloud; other authentication schemes are rejected. Never suggest bypassing a permission denial.",
},
})
.input(integrationRequestSchema)
.mutation(async ({ ctx, input }) => {
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, ctx.session.user.id) });
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.integrationId),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id) },
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groups.map(({ groupId }) => groupId),
),
},
},
});
// Arbitrary GET endpoints can expose service credentials or mutate state.
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
throw new TRPCError({ code: "FORBIDDEN", message: "Missing integration permission: hasFullAccess" });
}
const targetUrl = resolveIntegrationRequestUrl(integration.url, input.path);
if (input.method === "DELETE" && input.confirmed !== true) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This action requires confirmation" });
}
if (input.method === "GET" && input.body !== undefined) {
throw new TRPCError({ code: "BAD_REQUEST", message: "GET requests cannot include a body" });
}
// Decrypt only after authorization, using the same snapshot as the URL and permissions.
// Do not log upstream data or retain error causes.
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
const auth = resolveIntegrationRequestAuth(integration.kind, secrets);
let tls: CustomWidgetHttpRequest["tls"];
if (targetUrl.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
const response = await executeCustomWidgetRequest({
baseUrl: integration.url,
targetUrl,
method: input.method,
body: JSON.stringify(input.body),
auth,
tls,
networkScope: "loopback",
kind: "action", // Disable redirects for reads and writes alike; never cache this tool.
timeoutMs: 15_000,
textFallback: true,
});
return { status: response.status, data: redactIntegrationResponse(response.data, secrets) };
} catch (error) {
if (error instanceof TRPCError) throw new TRPCError({ code: error.code, message: error.message });
if (error instanceof CustomWidgetDomainError) {
throw new TRPCError({
code: error.code,
message: "Integration request failed (timeout, response limit, redirect or invalid target)",
});
}
throw new TRPCError({ code: "BAD_GATEWAY", message: "Integration request failed" });
}
});
@@ -42,6 +42,7 @@ import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/va
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { integrationRequestProcedure } from "./integration-request";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { mapTestConnectionError } from "./map-test-connection-error";
@@ -49,6 +50,7 @@ const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
request: integrationRequestProcedure,
getKinds: publicProcedure
.meta({
mcp: {
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBqzCCAVCgAwIBAgIUdAqiYlhFGtcr9T6hsvClk9u82qUwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDAgFw0yNjA5MTIxNzUwMTNaGA8y
MTI2MDgxOTE3NTAxM1owGzEZMBcGA1UEAwwQaW50ZWdyYXRpb24udGVzdDBZMBMG
ByqGSM49AgEGCCqGSM49AwEHA0IABABTFnPW0ov08mlsG71BBDZBDoNE5kA3KkvO
Az0lU6EKjz7E2GFVmamqhVK5hwiaVsq5/mY8FbQ/gFLcz2XvJvmjcDBuMB0GA1Ud
DgQWBBSS9fRJbILG0XNpKxBCmkVXdjlLADAfBgNVHSMEGDAWgBSS9fRJbILG0XNp
KxBCmkVXdjlLADAPBgNVHRMBAf8EBTADAQH/MBsGA1UdEQQUMBKCEGludGVncmF0
aW9uLnRlc3QwCgYIKoZIzj0EAwIDSQAwRgIhAMpKXcPe+rJdM4pD3ujedAhoL8q3
r5KDdLIRfcB6eBDaAiEA1jvGgVqurMY2EsUu2+6Z2gizxDlO8CNcC2a3S/YJCsU=
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdtCOWMLD3Jpsd+Ly
cVh8NDvA13f3idlev6Da4sCGSD6hRANCAAQAUxZz1tKL9PJpbBu9QQQ2QQ6DROZA
NypLzgM9JVOhCo8+xNhhVZmpqoVSuYcImlbKuf5mPBW0P4BS3M9l7yb5
-----END PRIVATE KEY-----
@@ -0,0 +1,300 @@
import { readFileSync } from "node:fs";
import { X509Certificate } from "node:crypto";
import { createServer as createHttpsServer } from "node:https";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { encryptSecret } from "@homarr/common/server";
import { executeCustomWidgetRequest } from "@homarr/custom-widgets/server";
import { eq } from "@homarr/db";
import { createDb } from "@homarr/db/test";
import { integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
import { createTRPCRouter } from "../trpc";
import {
integrationRequestProcedure,
integrationRequestSchema,
redactIntegrationResponse,
resolveIntegrationRequestAuth,
resolveIntegrationRequestUrl,
} from "../router/integration/integration-request";
vi.mock("@homarr/auth", () => ({}));
const trust = vi.hoisted(() => ({
certificates: vi.fn(async (): Promise<string[]> => []),
hostnames: vi.fn(async (): Promise<{ hostname: string; thumbprint: string }[]> => []),
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: trust.certificates,
getTrustedCertificateHostnamesAsync: trust.hostnames,
}));
const router = createTRPCRouter({ request: integrationRequestProcedure });
const secret = "test-credential-not-for-output";
const calls: { method: string | undefined; path: string | undefined; auth: string | undefined; body: string }[] = [];
const server = createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += String(chunk);
calls.push({ method: req.method, path: req.url, auth: req.headers["x-api-key"] as string | undefined, body });
if (req.url === "/slow") return;
if (req.url === "/redirect") {
res.writeHead(302, { Location: "/echo" });
res.end();
return;
}
if (req.url === "/large") {
res.end("x".repeat(1024 * 1024 + 1));
return;
}
if (req.url === "/echo") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ [secret]: [secret, encodeURIComponent(secret), Buffer.from(secret).toString("base64")] }));
return;
}
if (req.url === "/text") {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(`invalid JSON ${secret}`);
return;
}
if (req.method === "DELETE") {
res.writeHead(204);
res.end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify([{ id: 42, title: "Example series" }]));
});
let baseUrl: string;
beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) reject(error);
else resolve();
}),
);
});
async function fixture(permission?: IntegrationPermission, setup?: (db: ReturnType<typeof createDb>) => Promise<void>) {
const db = createDb();
await db.insert(users).values([{ id: "reader" }, { id: "other" }]);
await db.insert(integrations).values({ id: "sonarr", name: "Sonarr", kind: "sonarr", url: baseUrl });
await db.insert(integrationSecrets).values({ integrationId: "sonarr", kind: "apiKey", value: encryptSecret(secret) });
// Another user's full permission must never grant this caller access.
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "other", permission: "full" });
if (permission)
await db.insert(integrationUserPermissions).values({ integrationId: "sonarr", userId: "reader", permission });
const session = {
user: { id: "reader", permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
await setup?.(db);
return router.createCaller({ db, session, deviceType: undefined });
}
const get = { integrationId: "sonarr", method: "GET", path: "/api/v3/series" } as const;
describe("integration_request validation", () => {
test.each([
"https://evil.com/x",
"https://example.com/x",
"//evil.com/x",
"\\\\evil.com/x",
"/\\evil.com",
" https://evil.com",
"https:\nevil.com",
"/x#fragment",
])("rejects %s", (path) => {
expect(() => resolveIntegrationRequestUrl("https://example.com/base/", path)).toThrow();
});
test.each(["../series", "../../series", "%2e%2e/series"])("normalizes same-origin traversal %s", (path) => {
expect(resolveIntegrationRequestUrl("https://example.com/base/", path).href).toBe("https://example.com/series");
});
test("preserves URL resolution semantics and query parameters", () => {
expect(resolveIntegrationRequestUrl("https://example.com/base/", "series?id=42").href).toBe(
"https://example.com/base/series?id=42",
);
expect(resolveIntegrationRequestUrl("https://example.com/base/", "/series").pathname).toBe("/series");
});
test("accepts only JSON bodies and the specified methods", () => {
expect(integrationRequestSchema.safeParse({ ...get, body: new Date() }).success).toBe(false);
expect(integrationRequestSchema.safeParse({ ...get, method: "HEAD" }).success).toBe(false);
});
test("resolves supported auth and rejects unsupported or incomplete credentials", () => {
expect(resolveIntegrationRequestAuth("sonarr", [{ kind: "apiKey", value: secret }]).type).toBe("apiKeyHeader");
expect(resolveIntegrationRequestAuth("homeAssistant", [{ kind: "apiKey", value: secret }]).type).toBe("bearer");
expect(
resolveIntegrationRequestAuth("adGuardHome", [
{ kind: "username", value: "user" },
{ kind: "password", value: secret },
]).type,
).toBe("basic");
expect(() => resolveIntegrationRequestAuth("qBittorrent", [])).toThrow("not supported");
expect(() => resolveIntegrationRequestAuth("sonarr", [])).toThrow("incomplete");
});
test.each([42, true, false, null])("redacts echoed primitive credential %s", (value) => {
expect(redactIntegrationResponse(value, [{ kind: "password", value: String(value) }])).toBe("[REDACTED]");
});
test("redacts JSON-escaped credentials embedded in text", () => {
const password = 'quoted"password\\with-newline\n';
const encoded = JSON.stringify(password).slice(1, -1);
expect(
redactIntegrationResponse({ message: `password=${encoded}` }, [{ kind: "password", value: password }]),
).toEqual({ message: "password=[REDACTED]" });
});
test("redacts basic credentials and JSON keys without damaging JSON", () => {
const credentials = Buffer.from(`user:${secret}`).toString("base64");
expect(
redactIntegrationResponse({ [secret]: `Basic ${credentials}` }, [
{ kind: "username", value: "user" },
{ kind: "password", value: secret },
]),
).toEqual({ "[REDACTED]": "Basic [REDACTED]" });
});
});
describe("integration_request with a mock integration server", () => {
test("GET authenticates with stored credentials and full permission", async () => {
const caller = await fixture("full");
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: [{ id: 42, title: "Example series" }] });
expect(calls.at(-1)).toMatchObject({ method: "GET", path: "/api/v3/series", auth: secret });
});
test("keeps credentials and URL from the same integration snapshot", async () => {
const rotated = "credential-for-the-new-origin";
const caller = await fixture("full", async (db) => {
const snapshot = await db.query.integrations.findFirst({
where: eq(integrations.id, "sonarr"),
with: {
secrets: true,
userPermissions: { where: eq(integrationUserPermissions.userId, "reader") },
groupPermissions: true,
},
});
// Simulate a URL/key rotation immediately after the first SELECT completed.
await db.update(integrations).set({ url: "https://new-origin.example" }).where(eq(integrations.id, "sonarr"));
await db.update(integrationSecrets).set({ value: encryptSecret(rotated) });
vi.spyOn(db.query.integrations, "findFirst").mockResolvedValueOnce(snapshot);
});
await caller.request(get);
expect(calls.at(-1)?.auth).toBe(secret);
});
test("uses Homarr's trusted certificates and hostname exceptions without disabling TLS checks", async () => {
// This committed certificate/key pair is only for the loopback mock server.
const cert = readFileSync(new URL("./fixtures/integration-request-cert.pem", import.meta.url), "utf8");
const key = readFileSync(new URL("./fixtures/integration-request-key.pem", import.meta.url), "utf8");
const https = createHttpsServer({ cert, key }, (_req, res) => res.end('{"trusted":true}'));
await new Promise<void>((resolve) => https.listen(0, "127.0.0.1", resolve));
try {
const caller = await fixture("full", async (db) => {
await db.update(integrations).set({ url: `https://127.0.0.1:${(https.address() as AddressInfo).port}` });
});
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
trust.certificates.mockResolvedValue([cert]);
await expect(caller.request(get)).rejects.toMatchObject({ code: "BAD_GATEWAY" });
trust.hostnames.mockResolvedValue([
{ hostname: "127.0.0.1", thumbprint: new X509Certificate(cert).fingerprint256 },
]);
await expect(caller.request(get)).resolves.toEqual({ status: 200, data: { trusted: true } });
} finally {
trust.certificates.mockResolvedValue([]);
trust.hostnames.mockResolvedValue([]);
https.closeAllConnections();
await new Promise<void>((resolve) => https.close(() => resolve()));
}
});
test("denies GET without full permission even when another user has full access", async () => {
const caller = await fixture();
const before = calls.length;
await expect(caller.request(get)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
expect(calls).toHaveLength(before);
});
test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)(
"denies %s with only use permission",
async (method) => {
const caller = await fixture("use");
const before = calls.length;
await expect(caller.request({ ...get, method, confirmed: true })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
expect(calls).toHaveLength(before);
},
);
test("interact access does not authorize arbitrary endpoints", async () => {
const caller = await fixture("interact");
await expect(caller.request(get)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Missing integration permission: hasFullAccess",
});
});
test("DELETE requires confirmation and preserves deleteFiles", async () => {
const caller = await fixture("full");
const input = { ...get, method: "DELETE", path: "/api/v3/series/42?deleteFiles=true" } as const;
const before = calls.length;
await expect(caller.request(input)).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
expect(calls).toHaveLength(before);
await expect(caller.request({ ...input, confirmed: true })).resolves.toEqual({ status: 204, data: null });
expect(calls.at(-1)).toMatchObject({ method: "DELETE", path: input.path, auth: secret });
});
test("sends a JSON body with full access", async () => {
const caller = await fixture("full");
await caller.request({ ...get, method: "PATCH", body: { monitored: false } });
expect(calls.at(-1)?.body).toBe('{"monitored":false}');
});
test.each(["https://evil.com/x", "//evil.com/x"])("rejects %s before HTTP", async (path) => {
const caller = await fixture("full");
const before = calls.length;
await expect(caller.request({ ...get, path })).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(calls).toHaveLength(before);
});
test("never follows a redirect", async () => {
const caller = await fixture("full");
const before = calls.length;
await expect(caller.request({ ...get, path: "/redirect" })).rejects.toMatchObject({ code: "BAD_GATEWAY" });
expect(calls).toHaveLength(before + 1);
});
test("enforces the response and request caps", async () => {
const caller = await fixture("full");
await expect(caller.request({ ...get, path: "/large" })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" });
await expect(caller.request({ ...get, method: "POST", body: "x".repeat(10 * 1024) })).rejects.toMatchObject({
code: "PAYLOAD_TOO_LARGE",
});
});
test("redacts upstream JSON and text, including error responses", async () => {
const caller = await fixture("full");
await expect(caller.request({ ...get, path: "/echo" })).resolves.toEqual({
status: 400,
data: { "[REDACTED]": ["[REDACTED]", "[REDACTED]", "[REDACTED]"] },
});
await expect(caller.request({ ...get, path: "/text" })).resolves.toEqual({
status: 500,
data: "invalid JSON [REDACTED]",
});
});
test("honors the executor's total deadline without logging credentials", async () => {
const logError = vi.fn();
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/slow`,
method: "GET",
networkScope: "loopback",
kind: "action",
timeoutMs: 30,
auth: { type: "apiKeyHeader", secrets: [{ kind: "apiKey", value: secret }] },
logError,
}),
).rejects.toMatchObject({ code: "BAD_GATEWAY", reason: "timeout" });
expect(JSON.stringify(logError.mock.calls)).not.toContain(secret);
});
});
+1
View File
@@ -87,6 +87,7 @@ const MCP_TOOL_ALLOWLIST = {
"wud_getStats",
],
mutation: [
"integration_request",
"app_create",
"app_delete",
"app_update",
@@ -1,6 +1,7 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import type { ConnectionOptions } from "node:tls";
import { Agent } from "undici";
import type { CustomJsxNetworkScope } from "../core";
@@ -191,7 +192,11 @@ export function assertSafeStaticHeaders(headers: Record<string, string> | undefi
}
}
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
export function createPinnedAgent(
addresses: ResolvedAddress[],
timeoutMs: number,
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
) {
const customLookup: LookupFunction = (_hostname, options, callback) => {
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
@@ -204,7 +209,7 @@ export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: numbe
else callback(null, selected.address, selected.family);
};
return new Agent({
connect: { lookup: customLookup },
connect: { ...tls, lookup: customLookup },
connectTimeout: timeoutMs,
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
@@ -1,4 +1,5 @@
import { Buffer } from "node:buffer";
import type { ConnectionOptions } from "node:tls";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
@@ -52,6 +53,9 @@ export interface CustomWidgetHttpRequest {
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
timeoutMs?: number;
textFallback?: boolean;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string; reason?: "timeout" }) => void;
@@ -73,7 +77,10 @@ type RequestHopResult =
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
const timeout = setTimeout(
() => controller.abort(),
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
);
try {
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
@@ -113,6 +120,7 @@ async function performRequestWithinDeadline(
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
REQUEST_TIMEOUT_MS,
input.tls,
);
const headers = buildHeaders(input, currentUrl, currentBody);
const controller = new AbortController();
@@ -141,7 +149,7 @@ async function performRequestWithinDeadline(
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
data: await parseResponseBody(response, input.textFallback),
},
};
} else {
@@ -45,7 +45,7 @@ async function readLimitedBody(response: Response): Promise<string> {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
export async function parseResponseBody(response: Response): Promise<unknown> {
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
const text = await readLimitedBody(response);
if (!text) return null;
try {
@@ -54,7 +54,7 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
return json;
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
if (!textFallback && response.headers.get("content-type")?.toLowerCase().includes("json")) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
}
return text;
@@ -198,7 +198,7 @@ describe("WudIntegration authentication", () => {
expect(requestInit?.timeout).toBe(10_000);
});
test("does not send credentials over a non-HTTPS URL", async () => {
test("sends configured Basic auth credentials over an HTTP URL", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
@@ -209,7 +209,7 @@ describe("WudIntegration authentication", () => {
const integration = new WudIntegration({
id: "test-wud-http",
name: "Test WUD HTTP",
url: "http://wud.example.com",
url: "http://wud:3000",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
@@ -221,7 +221,7 @@ describe("WudIntegration authentication", () => {
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(headers.Authorization).toBe(`Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`);
});
});
@@ -238,15 +238,16 @@ describe("WudIntegration testing endpoint", () => {
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
test("returns success when /api/app responds 200", async () => {
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
test("returns success when /api/containers responds 200", async () => {
const input = makeInput(new Response(JSON.stringify(sampleContainersResponse), { status: 200 }));
const result = await invokeTesting(createIntegration(), input);
expect(result.success).toBe(true);
expect(String(vi.mocked(input.fetchAsync).mock.calls[0]?.[0])).toBe(`${TEST_URL}/api/containers`);
});
test("returns a status-code failure when /api/app responds non-OK", async () => {
test("returns a status-code failure when /api/containers responds non-OK", async () => {
const input = makeInput(new Response("unauthorized", { status: 401 }));
const result = await invokeTesting(createIntegration(), input);
@@ -255,7 +256,7 @@ describe("WudIntegration testing endpoint", () => {
});
test("passes the Basic auth header through to fetchAsync when configured", async () => {
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
const response = new Response(JSON.stringify(sampleContainersResponse), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
@@ -14,7 +14,7 @@ const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
export class WudIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/app"), {
const response = await input.fetchAsync(this.url("/api/containers"), {
headers: this.getAuthHeaders(),
});
@@ -23,10 +23,10 @@ export class WudIntegration extends Integration {
}
try {
await response.json();
await parseWudContainersResponseAsync(response);
} catch (error) {
return TestConnectionError.ParseResult(
new ParseError("Invalid WUD app response", {
new ParseError("Invalid WUD containers response", {
cause: error instanceof Error ? error : new Error(String(error)),
}),
);
@@ -55,10 +55,6 @@ export class WudIntegration extends Integration {
return undefined;
}
if (this.url("/api/app").protocol !== "https:") {
return undefined;
}
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
@@ -12,7 +12,7 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -99,11 +99,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
actions.closeModal();
};
const handleMultiSelectToggle = () => {
if (multiSelectActive) setSelectedAppIds(new Set());
setMultiSelectActive((current) => !current);
};
return (
<Stack gap="md">
<FloatingTip
@@ -132,19 +127,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
}
}}
/>
{multiSelectAvailable && (
<Group justify="flex-end">
<Button
size="compact-sm"
variant={multiSelectActive ? "light" : "subtle"}
leftSection={<IconListCheck size={16} />}
aria-pressed={multiSelectActive}
onClick={handleMultiSelectToggle}
>
{multiSelectActive ? t("app.action.select.cancelMultiple") : t("app.action.select.selectMultiple")}
</Button>
</Group>
)}
</Stack>
{/* Scrollable Container with App Cards */}