Files
dockhand-upstream/src/lib/server/host-path.ts
T
2026-03-02 07:59:58 +01:00

404 lines
14 KiB
TypeScript

/**
* Host Path Resolution Module
*
* Dockhand runs inside a Docker container where paths differ from the host.
* This module detects the host paths for ALL container mounts, enabling proper
* volume path resolution for compose stacks (both internal and adopted/external).
*
* Problem:
* - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data)
* - User may also mount external directories (e.g., -v /host/stacks:/external-stacks)
* - Compose file says: ./ca.pem:/ca.pem (relative path)
* - docker-compose resolves this to container path (e.g., /external-stacks/.../ca.pem)
* - Docker daemon on HOST receives this path, but /external-stacks doesn't exist on host!
* - Docker creates a directory instead of mounting the file
*
* Solution:
* - Query Docker API to find ALL host source paths for our container mounts
* - Rewrite relative paths in compose files to use the correct host path
* - Works for both internal stacks (DATA_DIR) and adopted stacks (external mounts)
*/
import { readFileSync } from 'node:fs';
import * as http from 'node:http';
import { resolve } from 'node:path';
// Cache the host data dir to avoid repeated API calls
let cachedHostDataDir: string | null = null;
let detectionAttempted = false;
// Cache ALL mounts for path translation (not just DATA_DIR)
let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Cache Dockhand's own Docker access method (detected from container inspect)
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
/**
* Get our own container ID
*/
export function getOwnContainerId(): string | null {
// Method 1: From cgroup (works in most cases)
try {
const cgroup = readFileSync('/proc/self/cgroup', 'utf-8');
// Look for docker container ID (64 hex chars)
const match = cgroup.match(/[a-f0-9]{64}/);
if (match) {
return match[0];
}
} catch {
// Can't read cgroup
}
// Method 2: From mountinfo
try {
const mountinfo = readFileSync('/proc/self/mountinfo', 'utf-8');
const match = mountinfo.match(/\/docker\/containers\/([a-f0-9]{64})/);
if (match) {
return match[1];
}
} catch {
// Can't read mountinfo
}
// Method 3: HOSTNAME might be container ID (short form)
const hostname = process.env.HOSTNAME;
if (hostname && /^[a-f0-9]{12}$/.test(hostname)) {
return hostname;
}
return null;
}
/**
* Get the host path for our DATA_DIR mount by inspecting our own container
*/
export async function detectHostDataDir(): Promise<string | null> {
// Return cached value if already detected
if (detectionAttempted) {
return cachedHostDataDir;
}
detectionAttempted = true;
// Check if user explicitly set HOST_DATA_DIR
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID');
return null;
}
console.log(`[HostPath] Detected container ID: ${containerId.substring(0, 12)}`);
// Get DATA_DIR (inside container)
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
try {
// Query Docker API to inspect our own container
// Try unix socket first, fall back to TCP if DOCKER_HOST is set
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
const dockerHost = process.env.DOCKER_HOST;
const containerInfo = await new Promise<any>((resolvePromise, reject) => {
const reqOptions: http.RequestOptions = dockerHost?.startsWith('tcp://')
? (() => {
const u = new URL(dockerHost.replace('tcp://', 'http://'));
return { hostname: u.hostname, port: u.port, path: `/containers/${containerId}/json`, method: 'GET' };
})()
: { socketPath, path: `/containers/${containerId}/json`, method: 'GET' };
const req = http.request(reqOptions, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
if (res.statusCode === 200) {
try {
resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
} catch {
reject(new Error('Failed to parse container inspect response'));
}
} else {
reject(new Error(`Container inspect failed: ${res.statusCode}`));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.end();
}) as {
Mounts?: Array<{
Type: string;
Source: string;
Destination: string;
}>;
Config?: {
Env?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
};
// Cache ALL mounts for later path translation (used by rewriteComposeVolumePaths)
cachedMounts = (containerInfo.Mounts || []).map(m => ({
source: m.Source,
destination: m.Destination
}));
console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`);
// Cache DOCKER_HOST from Dockhand's own env vars (if set)
// This tells us how Dockhand was configured to reach Docker
const envVars = containerInfo.Config?.Env || [];
for (const v of envVars) {
if (v.startsWith('DOCKER_HOST=')) {
cachedOwnDockerHost = v.substring('DOCKER_HOST='.length);
console.log(`[HostPath] Detected own DOCKER_HOST: ${cachedOwnDockerHost}`);
break;
}
}
// Cache Dockhand's network (prefer non-default for service discovery)
const networks = containerInfo.NetworkSettings?.Networks;
if (networks) {
const custom = Object.keys(networks).filter(
n => n !== 'bridge' && n !== 'none' && n !== 'host'
);
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
: networks.bridge ? 'bridge' : null;
if (cachedOwnNetworkMode) {
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
}
}
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
if (dataMount) {
cachedHostDataDir = dataMount.Source;
console.log(`[HostPath] Detected host path for ${dataDir}: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
// Check if DATA_DIR is a subdirectory of a mount
for (const mount of containerInfo.Mounts || []) {
if (dataDir.startsWith(mount.Destination + '/') || dataDir === mount.Destination) {
const relativePath = dataDir.substring(mount.Destination.length);
cachedHostDataDir = mount.Source + relativePath;
console.log(`[HostPath] Detected host path for ${dataDir} via parent mount: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
}
console.warn(`[HostPath] Could not find mount for ${dataDir} in container mounts`);
return null;
} catch (err) {
console.warn(`[HostPath] Failed to query Docker API: ${err}`);
return null;
}
}
/**
* Get the cached host data dir (call detectHostDataDir first during startup)
*/
export function getHostDataDir(): string | null {
return cachedHostDataDir;
}
/**
* Get DOCKER_HOST from Dockhand's own container config (if set).
* Returns the TCP address (e.g., "tcp://socket-proxy:2375") or null.
* Populated by detectHostDataDir() at startup.
*/
export function getOwnDockerHost(): string | null {
return cachedOwnDockerHost;
}
/**
* Get the Docker network Dockhand is attached to.
* Used to place scanner containers on the same network so they can reach
* TCP-based Docker endpoints (e.g., socket proxy).
* Populated by detectHostDataDir() at startup.
*/
export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* Translate a container path to host path
*
* @param containerPath - Path inside the container (e.g., /app/data/stacks/mystack/file.txt)
* @returns Host path if translation is needed, or original path if not
*/
export function translateToHostPath(containerPath: string): string {
const hostDataDir = getHostDataDir();
if (!hostDataDir) {
return containerPath;
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if the path is under DATA_DIR
if (containerPath.startsWith(dataDir + '/') || containerPath === dataDir) {
const relativePath = containerPath.substring(dataDir.length);
return hostDataDir + relativePath;
}
return containerPath;
}
/**
* Translate any container path to host path using ALL cached mounts.
* This is more general than translateToHostPath() which only handles DATA_DIR.
*
* @param containerPath - Path inside the container (e.g., /external-stacks/mystack)
* @returns Host path if a matching mount is found, or null if no translation possible
*/
export function translateContainerPathViaMount(containerPath: string): string | null {
if (!cachedMounts || cachedMounts.length === 0) {
return null;
}
// Sort mounts by destination length (longest first) to match most specific mount
const sortedMounts = [...cachedMounts].sort(
(a, b) => b.destination.length - a.destination.length
);
for (const mount of sortedMounts) {
if (containerPath.startsWith(mount.destination + '/') ||
containerPath === mount.destination) {
const relativePath = containerPath.substring(mount.destination.length);
return mount.source + relativePath;
}
}
return null;
}
/**
* Get the host path for the Docker socket mount.
* This is needed for sibling containers (e.g., scanners) that need socket access.
*
* When Dockhand runs in Docker with a non-standard socket mount like:
* -v /var/run/user/1000/docker.sock:/var/run/docker.sock
*
* We need to detect the HOST path (/var/run/user/1000/docker.sock) so that
* scanner containers can bind-mount the correct path.
*
* @returns The host path to Docker socket, or '/var/run/docker.sock' as default
*/
export function getHostDockerSocket(): string {
// Priority 1: Explicit environment variable override
if (process.env.HOST_DOCKER_SOCKET) {
console.log(`[HostPath] Using HOST_DOCKER_SOCKET from env: ${process.env.HOST_DOCKER_SOCKET}`);
return process.env.HOST_DOCKER_SOCKET;
}
// Priority 2: Look up from cached mounts (populated by detectHostDataDir on startup)
if (cachedMounts && cachedMounts.length > 0) {
console.log(`[HostPath] Searching ${cachedMounts.length} cached mount(s) for Docker socket`);
// Find mount where destination is docker.sock
const socketMount = cachedMounts.find(m =>
m.destination === '/var/run/docker.sock' ||
m.destination === '/run/docker.sock' ||
m.destination.endsWith('/docker.sock')
);
if (socketMount) {
console.log(`[HostPath] Found Docker socket mount: ${socketMount.source} -> ${socketMount.destination}`);
return socketMount.source;
}
// Log available mounts for debugging
console.log(`[HostPath] No Docker socket mount found. Available mounts:`);
for (const m of cachedMounts) {
console.log(`[HostPath] ${m.source} -> ${m.destination}`);
}
} else {
console.log(`[HostPath] No cached mounts available (not running in Docker or detectHostDataDir not called)`);
}
// Priority 3: Default fallback (works for standard Docker setups)
console.log(`[HostPath] Using default Docker socket: /var/run/docker.sock`);
return '/var/run/docker.sock';
}
/**
* Extract UID from a user-specific Docker socket path.
* User-specific sockets are at /run/user/<uid>/docker.sock
*
* @param socketPath - The host Docker socket path
* @returns The UID as a string (e.g., "1000"), or null if not a user-specific path
*/
export function extractUidFromSocketPath(socketPath: string): string | null {
// Match patterns like /run/user/1000/docker.sock or /var/run/user/1000/docker.sock
const match = socketPath.match(/\/user\/(\d+)\/docker\.sock$/);
if (match) {
console.log(`[HostPath] Extracted UID ${match[1]} from socket path: ${socketPath}`);
return match[1];
}
return null;
}
/**
* Rewrite relative volume paths in a compose file to use absolute host paths.
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
*
* Transforms:
* ./config.toml:/config.toml -> /host/path/to/stack/config.toml:/config.toml
*
* @param composeContent - The compose file content
* @param workingDir - The working directory (container path) where the compose file is located
* @returns Modified compose content with absolute host paths, or original if no translation needed
*/
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const changes: string[] = [];
// Try to translate workingDir to host path using ANY cached mount
// This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks)
const hostWorkingDir = translateContainerPathViaMount(workingDir);
if (!hostWorkingDir) {
// Can't translate - workingDir is not under any known mount
return { content: composeContent, modified: false, changes };
}
// Parse compose content line by line to find and rewrite volume mounts
// We look for patterns like:
// - ./something:/container/path
// - "./something:/container/path"
// - './something:/container/path'
const lines = composeContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
// Match volume mount patterns with relative paths
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
if (volumeMatch) {
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
// Convert relative path to absolute host path
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
modifiedLines.push(line);
}
}
return {
content: modifiedLines.join('\n'),
modified: changes.length > 0,
changes
};
}