reach own docker daemon over tcp on socket-proxy setups; re-run host detection when an environment is added or edited; keep scanner on the host socket

This commit is contained in:
jarek committed 2026-07-31 10:10:56 +02:00
1 parent 0a9d4ad10f
commit 6a6e82a481
8 files changed
+277 -84

No files matched your search

+22
View File
@@ -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;
}
+170 -72
View File
@@ -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<string, unknown> };
}
/**
* 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<string | null> {
// 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<string | null> {
};
};
// 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
+6
View File
@@ -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;
@@ -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();
+2 -1
View File
@@ -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;
}
+65 -6
View File
@@ -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<Response> {
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<number | null> {
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<Response> {
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<Resp
return fetch(url, options);
}
// Unix socket (default)
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
if (existsSync(socketPath)) {
return unixSocketRequest(socketPath, path, options);
}
const containerId = getOwnContainerId();
if (containerId) {
const ownEnvId = await resolveOwnEnvId(containerId);
if (ownEnvId !== null) {
return dockerFetch(path, options, ownEnvId);
}
}
// Nothing usable: fall through to the socket path so the caller gets the
// original ENOENT rather than a silent success.
return unixSocketRequest(socketPath, path, options);
}
@@ -1,12 +1,12 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnDockerHost } from '$lib/server/host-path';
import { getOwnDockerHost, getAutoDetectedDockerHost } from '$lib/server/host-path';
import { unixSocketRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
/** Fetch from the local Docker directly. Supports TCP and Unix socket. */
function localDockerFetch(path: string): Promise<Response> {
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);
}
+4 -3
View File
@@ -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
</button>
{:else if updateCheckError}
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" onclick={checkForUpdates} title={updateCheckError}>
Check for updates
<button class="flex items-center gap-1 text-xs text-foreground hover:text-muted-foreground transition-colors" onclick={checkForUpdates} title={updateCheckError}>
<TriangleAlert class="w-3.5 h-3.5 shrink-0" />
Update check unavailable — click to retry
</button>
{/if}
</div>