From ce12d7ae9f1b0ff19c5c3d27a9291ba8ba7f3fc5 Mon Sep 17 00:00:00 2001 From: jarek Date: Fri, 19 Jun 2026 08:21:22 +0200 Subject: [PATCH] 1.0.35 --- Dockerfile | 6 +- VERSION | 2 +- package.json | 6 +- src/lib/data/changelog.json | 13 ++ src/lib/data/dependencies.json | 2 +- src/lib/server/docker.ts | 70 ++++++++++- src/lib/server/scanner.ts | 26 +--- src/lib/server/stack-filename.ts | 22 ++++ src/lib/server/stacks.ts | 113 +++--------------- src/lib/stores/settings.ts | 15 ++- src/lib/utils/pangolin-urls.ts | 42 ++++--- src/lib/utils/shell-detection.ts | 16 ++- .../api/containers/[id]/shells/+server.ts | 69 +++++++---- src/routes/api/settings/general/+server.ts | 31 +++-- src/routes/settings/+page.svelte | 18 +-- .../environments/EnvironmentModal.svelte | 2 +- src/routes/settings/general/GeneralTab.svelte | 34 ++++-- 17 files changed, 284 insertions(+), 203 deletions(-) create mode 100644 src/lib/server/stack-filename.ts diff --git a/Dockerfile b/Dockerfile index 87b42b1..0a45c7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -166,7 +166,11 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:${PORT:-3000}/ || exit 1 + CMD if [ "$(echo "${HTTPS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" = "on" ]; then \ + curl -fsk https://localhost:${PORT:-3000}/ || exit 1; \ + else \ + curl -fs http://localhost:${PORT:-3000}/ || exit 1; \ + fi ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD [] diff --git a/VERSION b/VERSION index 00f7928..a4ea962 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.34 +v1.0.35 diff --git a/package.json b/package.json index 82178c6..1bea040 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.34", + "version": "1.0.35", "type": "module", "scripts": { "dev": "npx vite dev", @@ -80,7 +80,7 @@ "devalue": "5.8.1", "drizzle-orm": "0.45.2", "fast-xml-parser": "5.7.3", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "ldapts": "8.1.3", "nodemailer": "8.0.9", "otpauth": "9.4.1", @@ -88,7 +88,7 @@ "qrcode": "1.5.4", "rollup": "4.60.0", "svelte-sonner": "1.0.7", - "undici": "7.24.5", + "undici": "7.28.0", "ws": "8.21.0" }, "devDependencies": { diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 5068741..944158c 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,17 @@ [ + { + "version": "1.0.35", + "date": "2026-06-19", + "changes": [ + { "type": "feature", "text": "image prune skips Dockhand's scanner images (grype, trivy), configurable (#625)" }, + { "type": "fix", "text": "\"No archive\" download format selection not persisted (#1180)" }, + { "type": "fix", "text": "regression: vulnerability scans on direct-TCP envs (#1195)" }, + { "type": "fix", "text": "Pangolin labels — recognise the real `public-resources` / `private-resources` namespaces (#2)" }, + { "type": "fix", "text": "healthcheck uses HTTPS probe when HTTPS_MODE=on (#1191)" }, + { "type": "fix", "text": "shell detection improved for containers with non-standard PATH or shell locations (#1189)" } + ], + "imageTag": "fnsys/dockhand:v1.0.35" + }, { "version": "1.0.34", "date": "2026-06-17", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 7edfff6..42017fa 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -547,7 +547,7 @@ }, { "name": "js-yaml", - "version": "4.1.1", + "version": "4.2.0", "license": "MIT", "repository": "https://github.com/nodeca/js-yaml" }, diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index b457c64..3a9bf28 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -13,7 +13,7 @@ import * as https from 'node:https'; import * as tls from 'node:tls'; import { createHash } from 'node:crypto'; import type { Environment } from './db'; -import { getStackEnvVarsAsRecord } from './db'; +import { getStackEnvVarsAsRecord, getSetting } from './db'; import { getAdditionalVolumeBinds } from './mount-dedupe'; import { encodeRegistryAuth } from './registry-auth'; import { isSystemContainer } from './scheduler/tasks/update-utils'; @@ -4271,11 +4271,69 @@ export async function pruneContainers(envId?: number | null) { } export async function pruneImages(dangling = true, envId?: number | null) { - // dangling=true: only remove untagged images (default Docker behavior) - // dangling=false: remove ALL unused images including tagged ones - // Docker API quirk: to remove all unused, we pass dangling=false filter - const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}'; - return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId); + // dangling=true: only remove untagged images. Pass straight through — + // scanner images are always tagged so they can't be dangling anyway. + if (dangling) { + return dockerJsonRequest( + `/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`, + { method: 'POST' }, + envId + ); + } + + // dangling=false: "prune all unused." When the scanner-protection setting + // is on, shield grype + trivy with stopped holder containers (Docker's + // "in use" check keeps them) then tear them down in a finally (#625). + const protect = (await getSetting('protect_scanner_images')) !== false; + if (!protect) { + return dockerJsonRequest( + `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, + { method: 'POST' }, + envId + ); + } + + const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner'); + const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE; + const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE; + + const holderIds: string[] = []; + for (const image of [grypeImg, trivyImg]) { + try { + const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`; + const created = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(safeName)}`, + { + method: 'POST', + body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] }) + }, + envId + ); + holderIds.push(created.Id); + } catch { + // Image not present locally → nothing to protect → no-op + } + } + + try { + return await dockerJsonRequest( + `/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`, + { method: 'POST' }, + envId + ); + } finally { + for (const id of holderIds) { + try { + await dockerJsonRequest( + `/containers/${id}?force=true`, + { method: 'DELETE' }, + envId + ); + } catch { + // Best-effort cleanup; orphan holders are visible but harmless + } + } + } } export async function pruneVolumes(envId?: number | null) { diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index c356007..d020862 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -669,31 +669,9 @@ async function runScannerContainerCore( // detection failure — no regression for stock Docker hosts. hostSocketPath = await detectRemoteSocketPath(envId); console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`); - } else if (connectionType === 'direct' && env?.host) { - // Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP). - // The scanner container is created on the REMOTE daemon, not on - // Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's - // host into a remote-daemon container is nonsense — on remote Docker - // it silently creates an empty dir (scans fall back to registry); on - // rootless Podman it errors with "mkdir /var/run/docker.sock: - // permission denied" (#1076, #1011). Instead, tell the scanner to - // talk to the daemon over the same TCP endpoint Dockhand uses. - // `host.containers.internal` resolves to the daemon's host from - // inside the scanner container on both Docker (with `host-gateway`) - // and Podman (built-in). - scannerDockerHost = `tcp://host.containers.internal:${env.port}`; - // Add the host-gateway mapping so Docker honours the hostname. - // Podman recognises host.containers.internal natively; the extra - // mapping is harmless there. - scannerExtraHosts = [ - ...(scannerExtraHosts ?? []), - 'host.containers.internal:host-gateway' - ]; - console.log( - `[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)` - ); } else { - // Local socket — detect host socket path (handles rootless Docker) + // Local socket — handles rootless Docker and direct-TCP envs + // (the latter via the same socket bind; see #1195, #1076). hostSocketPath = getHostDockerSocket(); console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`); diff --git a/src/lib/server/stack-filename.ts b/src/lib/server/stack-filename.ts new file mode 100644 index 0000000..a251b92 --- /dev/null +++ b/src/lib/server/stack-filename.ts @@ -0,0 +1,22 @@ +/** + * Filename gate for stack writes (#1196). + * + * Lives in its own file so unit tests can import the helper without pulling + * in stacks.ts (which transitively imports the DB layer and won't load + * under bun:test). + * + * v1.0.34 locked compose/env paths to a hardcoded set (docker-compose.yml, + * compose.yml, .env) which broke users who name their compose files after + * the service (e.g. `headscale.yml`). v1.0.35 relaxes to any .yml / .yaml + * file plus the `.env` family. The load-bearing security control is the + * containment + symlink-realpath + traversal checks in validateStackPath() — + * this gate is belt-and-suspenders to keep the write path producing + * stack-shaped files. Still blocks authorized_keys, evil.sh, /etc/cron.d/x. + */ + +// Matches: foo.yml, foo.yaml, .env, foo.env, .env.local, prod.env.staging +export const STACK_FILENAME_RE = /\.ya?ml$|(^|\.)env(\.|$)/; + +export function isAllowedStackFilename(filename: string): boolean { + return STACK_FILENAME_RE.test(filename); +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 1b3e849..4e7eaf0 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -18,6 +18,7 @@ import { type DeletionApplyResult, type DeletionSkipReason } from './git-deletions'; +import { isAllowedStackFilename } from './stack-filename'; import { getEnvironment, getSecretEnvVarsAsRecord, @@ -34,9 +35,7 @@ import { removePendingContainerUpdate, deleteAutoUpdateSchedule, getAutoUpdateSetting, - getStackSourceByComposePath, - getExternalStackPaths, - addExternalStackPath + getStackSourceByComposePath } from './db'; import { unregisterSchedule } from './scheduler'; import { deleteGitStackFiles, parseEnvFileContent } from './git'; @@ -350,19 +349,6 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro return join(stacksDir, stackName); } -/** - * Filenames a stack is allowed to write. Compose files in the conventional - * names + .env. Anything else is rejected even when the directory is in - * the allowlist, so this code path can only ever produce stack-shaped files. - */ -const ALLOWED_STACK_FILENAMES = new Set([ - 'docker-compose.yml', - 'docker-compose.yaml', - 'compose.yml', - 'compose.yaml', - '.env' -]); - /** * Resolve a path against the parent's realpath when the parent exists, so * symlinks resolve to their canonical location. We can't realpath the leaf @@ -381,13 +367,6 @@ function resolveStackPath(input: string): string { return abs; } -function isInside(child: string, parent: string): boolean { - const c = pathNormalize(child); - const p = pathNormalize(parent); - if (c === p) return true; - return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep); -} - export interface StackPathValidation { ok: boolean; error?: string; @@ -396,15 +375,10 @@ export interface StackPathValidation { /** * Validate that a custom compose or env file path is writable by this code - * path. A path is accepted when it lives inside getStacksDir() or any - * directory in the external_stack_paths allowlist (admin-controlled, set - * in Settings → General), its filename is one of the conventional - * compose/env names, and it does not contain .. segments. The parent is - * resolved via realpath so a symlinked component inside an allowlisted dir - * cannot point elsewhere. - * - * Callers that need to grandfather an existing admin-configured path should - * call validateStackPathWithGrandfather() instead. + * path. A path is accepted when: + * - filename matches the stack-filename gate (.yml/.yaml/.env family) + * - normalized form contains no .. segments (parent directory resolved + * via realpath so a symlinked component can't smuggle traversal in) */ export async function validateStackPath(input: string): Promise { if (!input || typeof input !== 'string') { @@ -420,53 +394,14 @@ export async function validateStackPath(input: string): Promise { - const first = await validateStackPath(input); - if (first.ok || !isPreExisting) return first; - - const parent = dirname(resolveStackPath(input)); - const added = await addExternalStackPath(parent); - if (added) { - console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`); - } - return validateStackPath(input); + return { ok: true, resolved: resolvedPath }; } /** @@ -675,28 +610,16 @@ export async function saveStackComposeFile( const source = await getStackSource(name, envId); const composePath = options?.composePath || source?.composePath; - // Path-allowlist validation. Pre-existing stored paths grandfather into - // the allowlist; new caller-supplied paths must already be inside it. + // Validate every caller-supplied or stored path before any disk write. // See validateStackPath() docs. - if (composePath) { - const fromDb = !options?.composePath && !!source?.composePath; - const v = await validateStackPathWithGrandfather(composePath, fromDb); - if (!v.ok) return { success: false, error: v.error }; - } - if (options?.envPath) { - const v = await validateStackPath(options.envPath); - if (!v.ok) return { success: false, error: v.error }; - } else if (source?.envPath && !options?.envPath) { - // Grandfather a DB-stored env path that may have been configured before validation. - const v = await validateStackPathWithGrandfather(source.envPath, true); - if (!v.ok) return { success: false, error: v.error }; - } - if (options?.oldComposePath) { - const v = await validateStackPathWithGrandfather(options.oldComposePath, true); - if (!v.ok) return { success: false, error: v.error }; - } - if (options?.oldEnvPath) { - const v = await validateStackPathWithGrandfather(options.oldEnvPath, true); + const pathsToCheck = [ + composePath, + options?.envPath ?? source?.envPath, + options?.oldComposePath, + options?.oldEnvPath + ].filter((p): p is string => !!p); + for (const path of pathsToCheck) { + const v = await validateStackPath(path); if (!v.ok) return { success: false, error: v.error }; } diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 7a91ab1..120ee08 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -41,6 +41,7 @@ export interface AppSettings { labelFilterMode: LabelFilterMode; honorProxyLabels: boolean; showImageChangelogLinks: boolean; + protectScannerImages: boolean; } const DEFAULT_SETTINGS: AppSettings = { @@ -76,6 +77,7 @@ const DEFAULT_SETTINGS: AppSettings = { labelFilterMode: 'any', honorProxyLabels: true, showImageChangelogLinks: true, + protectScannerImages: true, defaultComposeTemplate: `version: "3.8" services: @@ -157,7 +159,8 @@ function createSettingsStore() { defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, - showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks + showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, + protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages }); } } catch { @@ -210,7 +213,8 @@ function createSettingsStore() { defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, - showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks + showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, + protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages }); } } catch (error) { @@ -461,6 +465,13 @@ function createSettingsStore() { return newSettings; }); }, + setProtectScannerImages: (value: boolean) => { + update((current) => { + const newSettings = { ...current, protectScannerImages: value }; + saveSettings({ protectScannerImages: value }); + return newSettings; + }); + }, setShowImageChangelogLinks: (value: boolean) => { update((current) => { const newSettings = { ...current, showImageChangelogLinks: value }; diff --git a/src/lib/utils/pangolin-urls.ts b/src/lib/utils/pangolin-urls.ts index 9af6eab..4c171e1 100644 --- a/src/lib/utils/pangolin-urls.ts +++ b/src/lib/utils/pangolin-urls.ts @@ -2,16 +2,20 @@ * Pangolin label → public URL extraction (#2 follow-up). * * Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates - * a container with one or more proxy resources. The relevant labels for URL - * extraction are: + * a container with one or more resources, scoped public OR private. The + * relevant labels for URL extraction are: * - * pangolin.proxy-resources..name human-friendly label - * pangolin.proxy-resources..full-domain public hostname (mandatory) - * pangolin.proxy-resources..protocol http | https (defaults to https) + * pangolin.public-resources..name human-friendly label + * pangolin.public-resources..full-domain public hostname (mandatory) + * pangolin.public-resources..protocol http | https (defaults to https) + * pangolin.private-resources..{name,full-domain,protocol} private equivalents + * + * Both scopes resolve to a URL the user can click. The scope (`public`/ + * `private`) is preserved on the result so callers can label or filter. * * The `targets[N].port` family is intentionally ignored — Pangolin terminates - * the public connection at full-domain; the internal target port is not part - * of the URL a user sees. + * the connection at full-domain; the internal target port is not part of the + * URL a user sees. * * Returns one URL per resource that declares a full-domain. Multiple * resources on the same container yield multiple URLs. Identical URLs across @@ -24,32 +28,37 @@ export interface PangolinUrl { url: string; /** The Pangolin resource key (the `` in the label key). */ resource: string; + /** Scope from the label namespace — public or private. */ + scope: 'public' | 'private'; /** Optional human-friendly name from the `.name` label, if set. */ displayName?: string; } const RESOURCE_KEY_RE = - /^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/; + /^pangolin\.(public|private)-resources\.([^.]+)\.(full-domain|protocol|name)$/; export function extractPangolinUrls( labels: Record | undefined | null ): PangolinUrl[] { if (!labels) return []; - // Group label values by resource key. + // Group label values by (scope, resource) key. A single resource name can + // legitimately appear under both scopes (rare but allowed by Pangolin); + // they're treated as independent resources here. const byResource = new Map< string, - { fullDomain?: string; protocol?: string; name?: string } + { scope: 'public' | 'private'; resource: string; fullDomain?: string; protocol?: string; name?: string } >(); for (const [key, value] of Object.entries(labels)) { const m = key.match(RESOURCE_KEY_RE); if (!m) continue; - const [, resource, field] = m; - let entry = byResource.get(resource); + const [, scope, resource, field] = m as unknown as [string, 'public' | 'private', string, string]; + const groupKey = `${scope}:${resource}`; + let entry = byResource.get(groupKey); if (!entry) { - entry = {}; - byResource.set(resource, entry); + entry = { scope, resource }; + byResource.set(groupKey, entry); } const v = (value ?? '').trim(); if (!v) continue; @@ -61,7 +70,7 @@ export function extractPangolinUrls( const out: PangolinUrl[] = []; const seen = new Set(); - for (const [resource, entry] of byResource) { + for (const entry of byResource.values()) { if (!entry.fullDomain) continue; const proto = @@ -75,7 +84,8 @@ export function extractPangolinUrls( out.push({ url, - resource, + resource: entry.resource, + scope: entry.scope, displayName: entry.name }); } diff --git a/src/lib/utils/shell-detection.ts b/src/lib/utils/shell-detection.ts index d4d2a2a..e19372f 100644 --- a/src/lib/utils/shell-detection.ts +++ b/src/lib/utils/shell-detection.ts @@ -135,16 +135,24 @@ export async function detectShells( /** * Get the best available shell from the detection result * Returns the user's preferred shell if available, otherwise the default + * + * Preference matching is by shell name (basename), not full path — so a + * preference of `/bin/bash` will still match when the server resolved bash + * to a non-standard path like `/usr/bin/bash` (issue #1189). */ export function getBestShell( result: ShellDetectionResult, preferredShell: string ): string | null { - // If preferred shell is available, use it - if (result.shells.includes(preferredShell)) { - return preferredShell; + const exact = result.shells.find(s => s === preferredShell); + if (exact) return exact; + + const preferredName = preferredShell.split('/').pop(); + if (preferredName) { + const byName = result.shells.find(s => s.split('/').pop() === preferredName); + if (byName) return byName; } - // Otherwise use the default shell + return result.defaultShell; } diff --git a/src/routes/api/containers/[id]/shells/+server.ts b/src/routes/api/containers/[id]/shells/+server.ts index e33fcec..b6fa751 100644 --- a/src/routes/api/containers/[id]/shells/+server.ts +++ b/src/routes/api/containers/[id]/shells/+server.ts @@ -33,47 +33,64 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const containerId = params.id; - const availableShells: string[] = []; + const shellNames = SHELLS_TO_CHECK.map(s => s.path.split('/').pop()!); + const namedPaths = new Map(); // name → resolved absolute path - // Check each shell by testing if the file exists and is executable - // Use a single command to check all shells at once for efficiency - const checkCommand = SHELLS_TO_CHECK.map(s => - `test -x ${s.path} && echo "${s.path}"` - ).join('; '); + // Resolve each shell through the container's own PATH using `command -v`, + // so that images with shells at non-standard locations (e.g. /usr/bin/sh + // where /bin/sh isn't a symlink, or PATH ordering that shadows the + // shell-builtin `test`) still report correctly. `exit 0` at the end + // keeps the exec exit code clean even when some shells are absent — + // otherwise execInContainer treats non-zero as a thrown error and the + // valid output is discarded (issue #1189). + const probe = + `for s in ${shellNames.join(' ')}; do ` + + `p=$(command -v $s 2>/dev/null) && [ -n "$p" ] && echo "$s:$p"; ` + + `done; exit 0`; try { const output = await execInContainer( containerId, - ['sh', '-c', checkCommand], + ['sh', '-c', probe], envIdNum ); - // Parse output - each line is an available shell path - const lines = output.trim().split('\n').filter(Boolean); - availableShells.push(...lines); + for (const line of output.trim().split('\n').filter(Boolean)) { + const colon = line.indexOf(':'); + if (colon < 0) continue; + const name = line.slice(0, colon); + const resolved = line.slice(colon + 1); + if (resolved.startsWith('/')) namedPaths.set(name, resolved); + } } catch { - // If even sh fails, try checking with test commands individually - // This handles edge cases where sh might not be available + // `sh` itself was not invocable. Fall back to probing the canonical + // paths directly with a non-shell-resolved test — exec each one + // against its absolute path and rely on docker exec's own + // "executable not found" failure to indicate absence. This handles + // the rare image where there's no `sh` at all but bash exists. for (const shell of SHELLS_TO_CHECK) { try { await execInContainer( containerId, - ['test', '-x', shell.path], + [shell.path, '-c', 'exit 0'], envIdNum ); - availableShells.push(shell.path); + const name = shell.path.split('/').pop()!; + namedPaths.set(name, shell.path); } catch { - // Shell not available, continue to next + // Shell not available at this path; try the next. } } } + const availableShells = Array.from(namedPaths.values()); + // Determine default shell - prefer bash, then sh, then first available let defaultShell: string | null = null; - if (availableShells.includes('/bin/bash')) { - defaultShell = '/bin/bash'; - } else if (availableShells.includes('/bin/sh')) { - defaultShell = '/bin/sh'; + if (namedPaths.has('bash')) { + defaultShell = namedPaths.get('bash')!; + } else if (namedPaths.has('sh')) { + defaultShell = namedPaths.get('sh')!; } else if (availableShells.length > 0) { defaultShell = availableShells[0]; } @@ -81,11 +98,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json({ shells: availableShells, defaultShell, - allShells: SHELLS_TO_CHECK.map(s => ({ - path: s.path, - label: s.label, - available: availableShells.includes(s.path) - })) + allShells: SHELLS_TO_CHECK.map(s => { + const name = s.path.split('/').pop()!; + const resolved = namedPaths.get(name); + return { + path: resolved ?? s.path, + label: s.label, + available: namedPaths.has(name) + }; + }) }); } catch (error) { console.error('Error detecting shells:', error); diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 810752a..5d921a2 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -38,7 +38,7 @@ import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner'; export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; -export type DownloadFormat = 'tar' | 'tar.gz'; +export type DownloadFormat = 'tar' | 'tar.gz' | 'raw'; export type EventCollectionMode = 'stream' | 'poll'; export interface GeneralSettings { @@ -91,7 +91,8 @@ export interface GeneralSettings { // Label filter mode labelFilterMode: 'any' | 'all'; // Whether to surface URLs inferred from reverse-proxy labels — currently - // Traefik (traefik.http.routers.*) and Pangolin (pangolin.proxy-resources.*). + // Traefik (traefik.http.routers.*) and Pangolin + // (pangolin.{public,private}-resources.*). // When false both parsers are bypassed and no proxy-derived pills are rendered. honorProxyLabels: boolean; // Whether to surface a "view changelog" link next to the update badge. @@ -99,6 +100,8 @@ export interface GeneralSettings { showImageChangelogLinks: boolean; // Whether spinning icons (animate-spin etc.) are animated (#1169) animateIcons: boolean; + // Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625) + protectScannerImages: boolean; } const DEFAULT_SETTINGS: Omit = { @@ -134,6 +137,7 @@ const DEFAULT_SETTINGS: Omit { labelFilterMode, honorProxyLabels, showImageChangelogLinks, - animateIcons + animateIcons, + protectScannerImages ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -256,7 +261,8 @@ export const GET: RequestHandler = async ({ cookies }) => { getSetting('label_filter_mode'), getSetting('honor_proxy_labels'), getSetting('show_image_changelog_links'), - getSetting('animate_icons') + getSetting('animate_icons'), + getSetting('protect_scanner_images') ]); const settings: GeneralSettings = { @@ -302,7 +308,8 @@ export const GET: RequestHandler = async ({ cookies }) => { labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, - animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons + animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons, + protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages }; return json(settings); @@ -320,7 +327,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -337,7 +344,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (dateFormat !== undefined && VALID_DATE_FORMATS.includes(dateFormat)) { await setSetting('date_format', dateFormat); } - if (downloadFormat !== undefined && (downloadFormat === 'tar' || downloadFormat === 'tar.gz')) { + if (downloadFormat !== undefined && (downloadFormat === 'tar' || downloadFormat === 'tar.gz' || downloadFormat === 'raw')) { await setSetting('download_format', downloadFormat); } if (defaultGrypeArgs !== undefined && typeof defaultGrypeArgs === 'string') { @@ -469,6 +476,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (animateIcons !== undefined && typeof animateIcons === 'boolean') { await setSetting('animate_icons', animateIcons); } + if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') { + await setSetting('protect_scanner_images', protectScannerImages); + } // Fetch all settings in parallel for the response const [ @@ -512,7 +522,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => { labelFilterModeVal, honorProxyLabelsVal, showImageChangelogLinksVal, - animateIconsVal + animateIconsVal, + protectScannerImagesVal ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -554,7 +565,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getSetting('label_filter_mode'), getSetting('honor_proxy_labels'), getSetting('show_image_changelog_links'), - getSetting('animate_icons') + getSetting('animate_icons'), + getSetting('protect_scanner_images') ]); const settings: GeneralSettings = { @@ -599,6 +611,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate, labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels, + protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages, showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks, animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons }; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 1610e68..7c10760 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -91,31 +91,31 @@ - + {#if activeTab === 'general'}{/if} - + {#if activeTab === 'environments'}{/if} - + {#if activeTab === 'labels'}{/if} - + {#if activeTab === 'registries'}{/if} - + {#if activeTab === 'git'}{/if} - + {#if activeTab === 'config-sets'}{/if} - + {#if activeTab === 'notifications'}{/if} @@ -123,11 +123,11 @@ {#if activeTab === 'auth'}{/if} - + {#if activeTab === 'license'}{/if} - + {#if activeTab === 'about'}{/if} diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 17461f1..a3af7ca 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -1586,7 +1586,7 @@ -
+
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index a1ecb22..a65aeb0 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -309,7 +309,7 @@ services: - +

Surface a release-notes link next to the image name on rows with updates available. The link is resolved from the image's org.opencontainers.image.source label, from the ghcr.io registry path, or from an explicit dockhand.changelog.url label override.

@@ -345,7 +345,7 @@ services: - +

Shows internal container ports (from EXPOSE directives) that are not published to the host. These appear in the container list with an amber badge to distinguish them from published port mappings.

@@ -367,8 +367,8 @@ services: - -

Parse traefik.http.routers.<name>.rule and pangolin.proxy-resources.<name>.full-domain labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit dockhand.url labels are shown.

+ +

Parse traefik.http.routers.<name>.rule, pangolin.public-resources.<name>.full-domain, and pangolin.private-resources.<name>.full-domain labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit dockhand.url labels are shown.

-
-

Remove cached vulnerability databases to reclaim disk space

-
+

Remove cached vulnerability databases to reclaim disk space

+
+
+
+ + + + + + +

When ON, "Prune all unused" skips Dockhand's grype and trivy scanner images so the next scan doesn't have to re-pull them (and re-download the ~100MB vuln database). When OFF, prune behaves like vanilla Docker and may remove them.

+
+
+ { + appSettings.setProtectScannerImages(checked); + toast.success(checked ? 'Scanner images will be skipped during prune' : 'Scanner images will be pruned with everything else'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
+

Skip grype and trivy images during "Prune all unused"