import { access, constants } from "node:fs/promises"; import { setTimeout } from "node:timers/promises"; import { type } from "arktype"; import { Client } from "undici"; import type { Headscale } 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 { 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 { 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(headscale: Headscale) { 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 headscale.health(); 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; } } } }