404 lines
14 KiB
TypeScript
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
|
|
};
|
|
}
|