mirror of
https://github.com/tale/headplane.git
synced 2026-08-04 03:07:43 +00:00
0512565f8e
Apparently I didn't use my brain cells and rely on the /version endpoint that Headscale has exposed since 0.26 (our lowest supported version). Switching to that significantly simplifies the API surface.
313 lines
9.1 KiB
TypeScript
313 lines
9.1 KiB
TypeScript
import { access, constants } from "node:fs/promises";
|
|
import { setTimeout } from "node:timers/promises";
|
|
|
|
import { type } from "arktype";
|
|
import { Client } from "undici";
|
|
|
|
import type { HeadscaleClient } from "~/server/headscale/api";
|
|
import log from "~/utils/log";
|
|
|
|
import { Integration } from "./abstract";
|
|
|
|
interface DockerContainer {
|
|
Id: string;
|
|
Names: string[];
|
|
}
|
|
|
|
interface DockerVersionInfo {
|
|
ApiVersion?: string;
|
|
}
|
|
|
|
const REQUIRED_DOCKER_API_VERSION = "1.44";
|
|
|
|
function compareApiVersions(current: string, required: string) {
|
|
const currentParts = current.split(".").map(Number);
|
|
const requiredParts = required.split(".").map(Number);
|
|
|
|
if (
|
|
currentParts.some((part) => Number.isNaN(part)) ||
|
|
requiredParts.some((part) => Number.isNaN(part))
|
|
) {
|
|
throw new Error("Invalid Docker API version format");
|
|
}
|
|
|
|
const length = Math.max(currentParts.length, requiredParts.length);
|
|
|
|
for (let index = 0; index < length; index++) {
|
|
const currentPart = currentParts[index] ?? 0;
|
|
const requiredPart = requiredParts[index] ?? 0;
|
|
|
|
if (currentPart > requiredPart) {
|
|
return 1;
|
|
}
|
|
|
|
if (currentPart < requiredPart) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function isSupportedDockerApiVersion(apiVersion: string) {
|
|
return compareApiVersions(apiVersion, REQUIRED_DOCKER_API_VERSION) >= 0;
|
|
}
|
|
|
|
const configSchema = {
|
|
full: type({
|
|
enabled: "boolean",
|
|
container_name: "string?",
|
|
container_label: 'string = "me.tale.headplane.target=headscale"',
|
|
socket: 'string = "unix:///var/run/docker.sock"',
|
|
}),
|
|
|
|
partial: type({
|
|
enabled: "boolean?",
|
|
container_name: "string?",
|
|
container_label: "string?",
|
|
socket: "string?",
|
|
}).partial(),
|
|
};
|
|
|
|
export default class DockerIntegration extends Integration<typeof configSchema.full.infer> {
|
|
private maxAttempts = 10;
|
|
private client: Client | undefined;
|
|
private containerId: string | undefined;
|
|
|
|
get name() {
|
|
return "Docker";
|
|
}
|
|
|
|
static get configSchema() {
|
|
return configSchema;
|
|
}
|
|
|
|
async getContainerName(label: string, value: string): Promise<string> {
|
|
if (!this.client) {
|
|
throw new Error("Docker client is not initialized");
|
|
}
|
|
|
|
const filters = encodeURIComponent(
|
|
JSON.stringify({
|
|
label: [`${label}=${value}`],
|
|
}),
|
|
);
|
|
const { body } = await this.client.request({
|
|
method: "GET",
|
|
path: `/containers/json?filters=${filters}`,
|
|
});
|
|
const containers: DockerContainer[] = (await body.json()) as DockerContainer[];
|
|
if (containers.length > 1) {
|
|
throw new Error(
|
|
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
|
|
);
|
|
}
|
|
if (containers.length === 0) {
|
|
throw new Error(`No Docker containers found matching label: ${label}=${value}`);
|
|
}
|
|
log.info("config", "Found Docker container matching label: %s=%s", label, value);
|
|
return containers[0].Id;
|
|
}
|
|
|
|
async isAvailable() {
|
|
log.info("config", "Requiring Docker API version %s or newer", REQUIRED_DOCKER_API_VERSION);
|
|
|
|
// Basic configuration check, the name overrides the container_label
|
|
// selector because of legacy support.
|
|
const { container_name, container_label } = this.context;
|
|
if (container_name?.length === 0 && container_label.length === 0) {
|
|
log.error("config", "Missing a Docker `container_name` or `container_label`");
|
|
return false;
|
|
}
|
|
|
|
// Verify that Docker socket is reachable
|
|
let url: URL | undefined;
|
|
try {
|
|
url = new URL(this.context.socket);
|
|
} catch {
|
|
log.error("config", "Invalid Docker socket path: %s", this.context.socket);
|
|
return false;
|
|
}
|
|
|
|
if (url.protocol !== "tcp:" && url.protocol !== "unix:") {
|
|
log.error("config", "Invalid Docker socket protocol: %s", url.protocol);
|
|
return false;
|
|
}
|
|
|
|
// The API is available as an HTTP endpoint and this
|
|
// will simplify the fetching logic in undici
|
|
if (url.protocol === "tcp:") {
|
|
// Apparently setting url.protocol doesn't work anymore?
|
|
const fetchU = url.href.replace(url.protocol, "http:");
|
|
|
|
try {
|
|
log.info("config", "Checking API: %s", fetchU);
|
|
await fetch(new URL("/version", fetchU).href);
|
|
} catch (error) {
|
|
log.error("config", "Failed to connect to Docker API: %s", error);
|
|
log.debug("config", "Connection error: %o", error);
|
|
return false;
|
|
}
|
|
|
|
this.client = new Client(fetchU);
|
|
}
|
|
|
|
// Check if the socket is accessible
|
|
if (url.protocol === "unix:") {
|
|
try {
|
|
log.info("config", "Checking socket: %s", url.pathname);
|
|
await access(url.pathname, constants.R_OK);
|
|
} catch (error) {
|
|
log.error("config", "Failed to access Docker socket: %s", url.pathname);
|
|
log.debug("config", "Access error: %o", error);
|
|
return false;
|
|
}
|
|
|
|
this.client = new Client("http://localhost", {
|
|
socketPath: url.pathname,
|
|
});
|
|
}
|
|
|
|
if (this.client === undefined) {
|
|
log.error("config", "Failed to create Docker client");
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const versionRes = await this.client.request({
|
|
method: "GET",
|
|
path: "/version",
|
|
});
|
|
|
|
if (versionRes.statusCode !== 200) {
|
|
log.error("config", "Could not request Docker API version");
|
|
log.debug("config", "Error Details: %o", await versionRes.body.json());
|
|
return false;
|
|
}
|
|
|
|
const versionInfo = (await versionRes.body.json()) as DockerVersionInfo;
|
|
if (!versionInfo.ApiVersion) {
|
|
log.error("config", "Docker API version response is missing `ApiVersion`");
|
|
return false;
|
|
}
|
|
|
|
log.info("config", "Detected Docker API version %s", versionInfo.ApiVersion);
|
|
|
|
if (!isSupportedDockerApiVersion(versionInfo.ApiVersion)) {
|
|
log.error(
|
|
"config",
|
|
"Docker API version %s is too old, require %s or newer",
|
|
versionInfo.ApiVersion,
|
|
REQUIRED_DOCKER_API_VERSION,
|
|
);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
log.error("config", "Failed to validate Docker API version: %s", error);
|
|
log.debug("config", "Version check error: %o", error);
|
|
return false;
|
|
}
|
|
|
|
const qp = new URLSearchParams({
|
|
filters: JSON.stringify(
|
|
container_name != null && container_name.length > 0
|
|
? { name: [container_name] }
|
|
: { label: [container_label] },
|
|
),
|
|
});
|
|
|
|
log.debug("config", "Requesting Docker containers with filters: %s", qp.toString());
|
|
const res = await this.client.request({
|
|
method: "GET",
|
|
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/json?${qp.toString()}`,
|
|
});
|
|
|
|
if (res.statusCode !== 200) {
|
|
log.error("config", "Could not request available Docker containers");
|
|
log.debug("config", "Error Details: %o", await res.body.json());
|
|
return false;
|
|
}
|
|
|
|
const data = (await res.body.json()) as DockerContainer[];
|
|
if (data.length > 1) {
|
|
if (container_name != null && container_name.length > 0) {
|
|
log.error("config", `Found multiple containers with name ${container_name}`);
|
|
} else {
|
|
log.error("config", `Found multiple containers with label ${container_label}`);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (data.length === 0) {
|
|
if (container_name != null && container_name.length > 0) {
|
|
log.error("config", `No container found with the name ${container_name}`);
|
|
} else {
|
|
log.error("config", `No container found with the label ${container_label}`);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
this.containerId = data[0].Id;
|
|
log.info("config", "Using container: %s (ID: %s)", data[0].Names[0], this.containerId);
|
|
|
|
return this.client !== undefined && this.containerId !== undefined;
|
|
}
|
|
|
|
async onConfigChange(client: HeadscaleClient) {
|
|
if (!this.client) {
|
|
return;
|
|
}
|
|
|
|
log.info("config", "Restarting Headscale via Docker");
|
|
|
|
let attempts = 0;
|
|
while (attempts <= this.maxAttempts) {
|
|
log.debug("config", "Restarting container: %s (attempt %d)", this.containerId, attempts);
|
|
|
|
const response = await this.client.request({
|
|
method: "POST",
|
|
path: `/v${REQUIRED_DOCKER_API_VERSION}/containers/${this.containerId}/restart`,
|
|
});
|
|
|
|
if (response.statusCode !== 204) {
|
|
if (attempts < this.maxAttempts) {
|
|
attempts++;
|
|
await setTimeout(1000);
|
|
continue;
|
|
}
|
|
|
|
const stringCode = response.statusCode.toString();
|
|
const body = await response.body.text();
|
|
throw new Error(`API request failed: ${stringCode} ${body}`);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
attempts = 0;
|
|
while (attempts <= this.maxAttempts) {
|
|
try {
|
|
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
|
|
const status = await client.isHealthy();
|
|
if (status === false) {
|
|
throw new Error("Headscale is not running");
|
|
}
|
|
|
|
log.info("config", "Headscale is up and running");
|
|
return;
|
|
} catch {
|
|
if (attempts < this.maxAttempts) {
|
|
attempts++;
|
|
await setTimeout(1000);
|
|
continue;
|
|
}
|
|
|
|
log.error("config", "Missed restart deadline for %s", this.containerId);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|