Files
homarr/packages/definitions/src/docker-service-discovery.ts
T

148 lines
4.6 KiB
TypeScript

import {
extractContainerImageName,
matchIntegrationKind,
matchIntegrationKindFromContainer,
} from "./docker-integration-match";
import type { IntegrationKind } from "./integration";
export type DockerServiceMatchConfidence = "high" | "medium";
export type DockerServiceMatchSource = "image" | "name";
export type DockerUrlCandidateSource = "publishedAddress" | "endpointHost" | "containerDns" | "manual";
export type DockerUrlCandidateScope = "browser" | "server";
export type DockerUrlCandidateReason =
| "publishedAddress"
| "dockerEndpointHost"
| "sharedContainerNetwork"
| "manualHostRequired";
export interface DockerServiceMatch {
kind: IntegrationKind;
confidence: DockerServiceMatchConfidence;
source: DockerServiceMatchSource;
}
export interface DockerServicePort {
IP?: string;
PrivatePort: number;
PublicPort?: number;
Type: string;
}
export interface DockerServiceUrlCandidate {
id: string;
url: string;
rank: number;
source: DockerUrlCandidateSource;
scopes: DockerUrlCandidateScope[];
reason: DockerUrlCandidateReason;
}
export const matchDockerService = (container: { image: string; name: string }): DockerServiceMatch | null => {
const kind = matchIntegrationKindFromContainer(container);
if (!kind) return null;
const imageMatch = matchIntegrationKind(extractContainerImageName(container.image));
return imageMatch === kind
? { kind, confidence: "high", source: "image" }
: { kind, confidence: "medium", source: "name" };
};
export const buildDockerServiceUrlCandidates = ({
containerName,
endpointHost,
ports,
preferredPort,
}: {
containerName: string;
endpointHost: string;
ports: DockerServicePort[] | undefined;
preferredPort?: number;
}): DockerServiceUrlCandidate[] => {
const candidates: DockerServiceUrlCandidate[] = [];
const endpointHostname = getEndpointHostname(endpointHost);
const tcpPorts = (ports ?? [])
.filter((port) => port.Type.toLowerCase() === "tcp")
.toSorted(
(left, right) => Number(right.PrivatePort === preferredPort) - Number(left.PrivatePort === preferredPort),
);
const addCandidate = (candidate: Omit<DockerServiceUrlCandidate, "id">) => {
if (candidates.some(({ url, scopes }) => url === candidate.url && scopes.join() === candidate.scopes.join()))
return;
candidates.push({
...candidate,
id: `${candidate.source}:${candidate.scopes.join("-")}:${candidate.url || "manual"}`,
});
};
for (const port of tcpPorts) {
if (port.PublicPort) {
const publishedHost = isUsablePublishedAddress(port.IP)
? port.IP
: isUsablePublishedAddress(endpointHostname ?? undefined)
? endpointHostname
: null;
if (publishedHost) {
addCandidate({
url: `http://${formatUrlHost(publishedHost)}:${port.PublicPort}`,
rank: port.PrivatePort === preferredPort ? 1 : 2,
source: isUsablePublishedAddress(port.IP) ? "publishedAddress" : "endpointHost",
scopes: ["browser", "server"],
reason: isUsablePublishedAddress(port.IP) ? "publishedAddress" : "dockerEndpointHost",
});
}
}
addCandidate({
url: `http://${containerName}:${port.PrivatePort}`,
rank: port.PrivatePort === preferredPort ? 3 : 4,
source: "containerDns",
scopes: ["server"],
reason: "sharedContainerNetwork",
});
}
addCandidate({
url: "",
rank: 99,
source: "manual",
scopes: ["browser"],
reason: "manualHostRequired",
});
return candidates.toSorted((left, right) => left.rank - right.rank);
};
export const normalizeDockerServiceUrl = (value: string | null | undefined) => {
if (!value) return null;
try {
const url = new URL(value);
url.hash = "";
return url.toString().replace(/\/$/, "").toLowerCase();
} catch {
return value.trim().replace(/\/$/, "").toLowerCase() || null;
}
};
const isUsablePublishedAddress = (value: string | undefined): value is string => {
if (!value) return false;
const host = value.toLowerCase().replace(/^\[|\]$/gu, "");
return (
!["0.0.0.0", "::", "::0", "::1", "localhost"].includes(host) &&
!host.endsWith(".localhost") &&
!/^127(?:\.\d{1,3}){3}$/.test(host) &&
!host.startsWith("::ffff:127.")
);
};
const getEndpointHostname = (host: string) => {
if (host === "socket" || host.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(host)) return null;
try {
return new URL(`http://${host}`).hostname || null;
} catch {
return null;
}
};
const formatUrlHost = (host: string) => (host.includes(":") && !host.startsWith("[") ? `[${host}]` : host);