diff --git a/src/lib/server/host-path-core.ts b/src/lib/server/host-path-core.ts new file mode 100644 index 0000000..49f9b57 --- /dev/null +++ b/src/lib/server/host-path-core.ts @@ -0,0 +1,22 @@ +/** + * Pure host-path helpers. NO db / docker import, so unit-testable directly + * (importing host-path.ts pulls in the DB layer and would boot the database). + */ + +/** + * Whether an environment can be used to inspect Dockhand's OWN container over a + * plain tcp:// address (the socket-proxy fallback, #1203/#1204). Only plain-http + * `direct` envs qualify: socket envs give no host:port (and we have no socket to + * reach anyway), https/mTLS envs need certs a bare tcp:// would drop, hawser envs + * are remote agents. + */ +export function isSelfInspectCandidate(env: { + connectionType?: string | null; + protocol?: string | null; + host?: string | null; + port?: number | null; +}): boolean { + return env.connectionType === 'direct' + && (env.protocol ?? 'http') === 'http' + && !!env.host && !!env.port; +} diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts index 218454a..4607459 100644 --- a/src/lib/server/host-path.ts +++ b/src/lib/server/host-path.ts @@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs'; import * as http from 'node:http'; import { resolve } from 'node:path'; +import { isSelfInspectCandidate } from './host-path-core'; // Cache the host data dir to avoid repeated API calls let cachedHostDataDir: string | null = null; @@ -30,9 +31,16 @@ 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 +// Cache Dockhand's own Docker access method (detected from container inspect). +// cachedOwnDockerHost holds ONLY a DOCKER_HOST the user set on Dockhand's own +// container. The scanner reads it to replicate how Dockhand reaches Docker, so it +// must stay exactly what 1.0.39 exposed: null unless the user set DOCKER_HOST. let cachedOwnDockerHost: string | null = null; +// cachedAutoTcpHost holds a tcp:// address DISCOVERED via a DB env (#1203, socket- +// proxy with no docker.sock and no DOCKER_HOST). It's for self-update ONLY - the +// scanner must NOT follow it, or it flips into TCP-mode and joins the wrong network +// on split-network hosts (#1204). Kept separate from cachedOwnDockerHost for that. +let cachedAutoTcpHost: string | null = null; let cachedOwnNetworkMode: string | null = null; let cachedOwnAllNetworks: string[] | null = null; let cachedOwnExtraHosts: string[] | null = null; @@ -73,6 +81,141 @@ export function getOwnContainerId(): string | null { return null; } +/** Docker container inspect fields we read to populate the caches. */ +interface OwnContainerInfo { + Mounts?: Array<{ Type?: string; Source: string; Destination: string }>; + Config?: { Env?: string[] }; + HostConfig?: { ExtraHosts?: string[] }; + NetworkSettings?: { Networks?: Record }; +} + +/** + * Populate the mounts / DOCKER_HOST / network / ExtraHosts caches from a + * container inspect result. Shared by the socket self-inspect and the DB-env + * fallback so both paths cache identically. `ownDockerHostOverride` sets + * cachedAutoTcpHost (self-update ONLY) when the inspect was reached over a DB env + * TCP route; the socket path instead reads a real DOCKER_HOST from the container's + * own env vars into cachedOwnDockerHost (which the scanner honors). + */ +function populateCachesFromInspect(info: OwnContainerInfo, ownDockerHostOverride?: string): void { + cachedMounts = (info.Mounts || []).map(m => ({ source: m.Source, destination: m.Destination })); + console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`); + + if (ownDockerHostOverride) { + // Discovered via a DB env (#1203). Self-update may use it, but the scanner + // must NOT (it would flip to TCP-mode and hit the wrong net, #1204), so this + // goes to the self-update-only cache, never cachedOwnDockerHost. + cachedAutoTcpHost = ownDockerHostOverride; + } else { + // Read DOCKER_HOST from Dockhand's own env vars (how it was configured). + for (const v of info.Config?.Env || []) { + if (v.startsWith('DOCKER_HOST=')) { + cachedOwnDockerHost = v.substring('DOCKER_HOST='.length); + console.log(`[HostPath] Detected own DOCKER_HOST: ${cachedOwnDockerHost}`); + break; + } + } + } + + // Networks: primary (custom net first, else bridge) + full list, so callers + // can warn on fragile split-network setups (#1011). + const networks = info.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; + cachedOwnAllNetworks = Object.keys(networks); + if (cachedOwnNetworkMode) { + console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`); + } + } + + cachedOwnExtraHosts = info.HostConfig?.ExtraHosts?.length ? [...info.HostConfig.ExtraHosts] : null; + if (cachedOwnExtraHosts) { + console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`); + } +} + +/** + * Resolve the host path for DATA_DIR from a container inspect result: honor an + * explicit HOST_DATA_DIR override, else match the DATA_DIR mount (or a parent + * mount). Sets and returns cachedHostDataDir, or null if no mount matches. + */ +function resolveDataDirFromInspect(info: OwnContainerInfo, dataDir: string): string | null { + if (cachedHostDataDir) return cachedHostDataDir; + + const dataMount = info.Mounts?.find(m => m.Destination === dataDir); + if (dataMount) { + cachedHostDataDir = dataMount.Source; + console.log(`[HostPath] Detected host path for ${dataDir}: ${cachedHostDataDir}`); + return cachedHostDataDir; + } + for (const mount of info.Mounts || []) { + if (dataDir.startsWith(mount.Destination + '/') || dataDir === mount.Destination) { + cachedHostDataDir = mount.Source + dataDir.substring(mount.Destination.length); + 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; +} + +/** + * Fallback for socket-proxy deployments (#1203/#1204): no docker.sock mounted and + * no DOCKER_HOST set, so the socket self-inspect above failed. Reach Docker + * through a DB-configured plain-http `direct` environment whose daemon actually + * hosts THIS container (a genuinely remote daemon 404s on our id and is skipped), + * and cache its tcp:// address + network so scanner/self-update sidecars work. + * Returns the resolved DATA_DIR host path, or null if no env hosts us. + */ +async function inspectOwnContainerViaDbEnv(containerId: string, dataDir: string): Promise { + // Lazy import: keep host-path.ts light at module load so it stays importable + // in unit tests without pulling in the DB/docker layers (better-sqlite3). + const { getEnvironments } = await import('./db'); + const { dockerFetch } = await import('./docker'); + + let envs; + try { + envs = await getEnvironments(); + } catch { + return null; + } + const candidates = envs.filter(isSelfInspectCandidate); + for (const env of candidates) { + try { + const res = await dockerFetch(`/containers/${containerId}/json`, {}, env.id); + if (!res.ok) { + await res.body?.cancel().catch(() => {}); + continue; + } + const info = await res.json() as OwnContainerInfo; + const tcpHost = `tcp://${env.host}:${env.port}`; + populateCachesFromInspect(info, tcpHost); + console.log(`[HostPath] Reached own container via direct env "${env.name}"; own DOCKER_HOST=${tcpHost}`); + return resolveDataDirFromInspect(info, dataDir); + } catch { + continue; + } + } + return null; +} + +/** + * Reset host detection so the next detectHostDataDir() re-inspects. Called when + * environments change (create/update), so a socket-proxy deployment that had no + * `direct` env at boot picks one up without a restart (#1203). + */ +export function resetHostDetection(): void { + detectionAttempted = false; + cachedHostDataDir = null; + cachedMounts = null; + cachedOwnDockerHost = null; + cachedAutoTcpHost = null; + cachedOwnNetworkMode = null; + cachedOwnAllNetworks = null; + cachedOwnExtraHosts = null; +} + /** * Get the host path for our DATA_DIR mount by inspecting our own container */ @@ -149,77 +292,23 @@ export async function detectHostDataDir(): Promise { }; }; - // 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 networks. Picks one as the primary networkMode - // (custom net first, falling back to bridge) and keeps the full list - // so callers can warn when a setup is fragile — e.g. socket-proxy - // living on a network other than the one the scanner joins (#1011). - 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; - cachedOwnAllNetworks = Object.keys(networks); - if (cachedOwnNetworkMode) { - console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`); - } - } - - cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length - ? [...containerInfo.HostConfig.ExtraHosts] - : null; - if (cachedOwnExtraHosts) { - console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`); - } - - // Explicit override wins for DATA_DIR path, but we still inspect to populate - // mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars. - if (cachedHostDataDir) { - return cachedHostDataDir; - } - - // 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; + // Populate mounts/DOCKER_HOST/network/ExtraHosts caches (from the container's + // own env vars, since this path reached Docker over socket/DOCKER_HOST). + populateCachesFromInspect(containerInfo); + return resolveDataDirFromInspect(containerInfo, dataDir); } catch (err) { - console.warn(`[HostPath] Failed to query Docker API: ${err}`); + console.warn(`[HostPath] Failed to query Docker API via socket/DOCKER_HOST: ${err}`); + // Socket-proxy fallback (#1203/#1204): no docker.sock and no DOCKER_HOST, so + // reach Docker through a configured plain-http direct env. Guarded on no + // DOCKER_HOST so a user who set an (unreachable) DOCKER_HOST is never rerouted. + if (!process.env.DOCKER_HOST) { + const viaEnv = await inspectOwnContainerViaDbEnv(containerId, dataDir); + if (viaEnv) return viaEnv; + } + // Nothing found. Do NOT keep detectionAttempted latched: a direct env added + // AFTER startup should heal on the next detect without a restart. Only a + // successful detection (above / socket path) leaves the caches populated. + detectionAttempted = false; return null; } } @@ -240,6 +329,15 @@ export function getOwnDockerHost(): string | null { return cachedOwnDockerHost; } +/** + * TCP address discovered via a DB env when Dockhand has no docker.sock and no + * DOCKER_HOST (#1203 socket-proxy). For self-update ONLY - the scanner deliberately + * does not read this, so it keeps using the host socket like 1.0.39 did (#1204). + */ +export function getAutoDetectedDockerHost(): string | null { + return cachedAutoTcpHost; +} + /** * Get the Docker network Dockhand is attached to. * Used to place scanner containers on the same network so they can reach diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index 31aa592..094c1fb 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -5,6 +5,7 @@ import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; +import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; @@ -125,6 +126,11 @@ export const POST: RequestHandler = async (event) => { // Notify event collectors to pick up the new environment refreshSubprocessEnvironments(); + // Re-run host detection: a socket-proxy deployment with no direct env at boot + // can now reach Docker through this env without a restart (#1203). Fire-and-forget. + resetHostDetection(); + void detectHostDataDir(); + // Auto-assign Admin role to creator (Enterprise only) if (auth.isEnterprise && auth.authEnabled && auth.isAuthenticated && !auth.isAdmin) { const user = auth.user; diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 9ba74cf..668c4e1 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -9,6 +9,7 @@ import { getStacksDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; +import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; @@ -155,6 +156,11 @@ export const PUT: RequestHandler = async (event) => { return json({ error: 'Environment not found' }, { status: 404 }); } + // Re-run host detection: editing an env (e.g. pointing it at a socket proxy) + // lets a socketless deployment reach Docker without a restart (#1203). + resetHostDetection(); + void detectHostDataDir(); + // Notify event collectors if collectActivity or collectMetrics setting changed if (data.collectActivity !== undefined || data.collectMetrics !== undefined) { refreshSubprocessEnvironments(); diff --git a/src/routes/api/self-update/+server.ts b/src/routes/api/self-update/+server.ts index 66e19e3..35084b8 100644 --- a/src/routes/api/self-update/+server.ts +++ b/src/routes/api/self-update/+server.ts @@ -5,6 +5,7 @@ import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, + getAutoDetectedDockerHost, getOwnExtraHosts, getOwnNetworkMode } from '$lib/server/host-path'; @@ -17,7 +18,7 @@ const UPDATER_LABEL = 'dockhand.updater'; /** Get TCP Docker host if configured, null otherwise. */ function getDockerTcpHost(): string | null { - const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost() || getAutoDetectedDockerHost(); return dockerHost?.startsWith('tcp://') ? dockerHost : null; } diff --git a/src/routes/api/self-update/check/+server.ts b/src/routes/api/self-update/check/+server.ts index 178f9e3..7681ead 100644 --- a/src/routes/api/self-update/check/+server.ts +++ b/src/routes/api/self-update/check/+server.ts @@ -1,13 +1,59 @@ import { json } from '@sveltejs/kit'; +import { existsSync } from 'node:fs'; import { authorize } from '$lib/server/authorize'; -import { getOwnContainerId, getOwnDockerHost } from '$lib/server/host-path'; -import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker'; +import { getOwnContainerId, getOwnDockerHost, getAutoDetectedDockerHost } from '$lib/server/host-path'; +import { getRegistryManifestDigest, unixSocketRequest, dockerFetch } from '$lib/server/docker'; +import { getEnvironments } from '$lib/server/db'; import { compareVersions } from '$lib/utils/version'; import type { RequestHandler } from './$types'; -/** Fetch from the local Docker directly (not through environment routing) */ -function localDockerFetch(path: string, options: RequestInit = {}): Promise { - const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); +/** + * When there is no local socket and no DOCKER_HOST (e.g. a socket-proxy setup + * that mounts no docker.sock, #1203), find the environment whose daemon actually + * runs the Dockhand container by inspecting our own container ID on each + * candidate. This is deterministic even with several `direct` envs, one of which + * is a genuinely remote host - the remote daemon returns 404 for our ID, so we + * never pick it. Memoized: the answer is stable for the process lifetime. + * Returns the env id, or null if none host us. + */ +let ownEnvIdMemo: number | null | undefined; +async function resolveOwnEnvId(containerId: string): Promise { + if (ownEnvIdMemo !== undefined) return ownEnvIdMemo; + + const envs = await getEnvironments(); + // Socket/local envs first (cheapest, almost always us), then direct. + const candidates = [ + ...envs.filter((e) => e.connectionType === 'socket' || !e.connectionType), + ...envs.filter((e) => e.connectionType === 'direct') + ]; + for (const env of candidates) { + try { + const res = await dockerFetch(`/containers/${containerId}/json`, {}, env.id); + if (res.ok) { + console.log(`[SelfUpdate] Dockhand runs on environment "${env.name}" (id ${env.id}, ${env.connectionType || 'socket'}); using it for update checks`); + ownEnvIdMemo = env.id; + return env.id; + } + } catch { + // Env unreachable or does not host us; try the next candidate. + } + } + console.log('[SelfUpdate] No configured environment hosts the Dockhand container; cannot reach Docker for update check'); + ownEnvIdMemo = null; + return null; +} + +/** + * Fetch from the Docker daemon running Dockhand itself (not via env routing, + * which fails on private-registry images - see the private-registry fix). + * + * Order: explicit DOCKER_HOST tcp -> local socket if present -> the environment + * that actually hosts our own container. The last path covers socket-proxy + * setups with no docker.sock mount and no DOCKER_HOST (#1203), so the user does + * not have to set DOCKER_HOST (which breaks scanner networking, #1204). + */ +async function localDockerFetch(path: string, options: RequestInit = {}): Promise { + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost() || getAutoDetectedDockerHost(); if (dockerHost?.startsWith('tcp://')) { // TCP connection (socat proxy, socket-proxy, remote Docker) @@ -15,8 +61,21 @@ function localDockerFetch(path: string, options: RequestInit = {}): Promise { - const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost() || getAutoDetectedDockerHost(); if (dockerHost?.startsWith('tcp://')) { return fetch(dockerHost.replace('tcp://', 'http://') + path); } diff --git a/src/routes/settings/about/AboutTab.svelte b/src/routes/settings/about/AboutTab.svelte index a8adfae..25a5f74 100644 --- a/src/routes/settings/about/AboutTab.svelte +++ b/src/routes/settings/about/AboutTab.svelte @@ -4,7 +4,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; - import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database, CircleArrowUp, Loader2, CheckCircle2 } from 'lucide-svelte'; + import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database, CircleArrowUp, Loader2, CheckCircle2, TriangleAlert } from 'lucide-svelte'; import * as Tabs from '$lib/components/ui/tabs'; import { onMount, onDestroy } from 'svelte'; import { licenseStore } from '$lib/stores/license'; @@ -512,8 +512,9 @@ Up to date {:else if updateCheckError} - {/if}