This commit is contained in:
jarek committed 2026-06-19 08:21:22 +02:00
1 parent efb634701c
commit ce12d7ae9f
17 files changed
+284 -203

No files matched your search

+64 -6
View File
@@ -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) {
+2 -24
View File
@@ -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}`);
+22
View File
@@ -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);
}
+18 -95
View File
@@ -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<StackPathValidation> {
if (!input || typeof input !== 'string') {
@@ -420,53 +394,14 @@ export async function validateStackPath(input: string): Promise<StackPathValidat
}
const filename = basename(resolvedPath);
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
if (!isAllowedStackFilename(filename)) {
return {
ok: false,
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
error: `File "${filename}" is not an allowed stack filename (must end in .yml, .yaml, or .env)`
};
}
const stacksDir = getStacksDir();
if (isInside(resolvedPath, stacksDir)) {
return { ok: true, resolved: resolvedPath };
}
const allowlist = await getExternalStackPaths();
for (const dir of allowlist) {
if (!dir) continue;
const dirResolved = resolve(dir);
if (isInside(resolvedPath, dirResolved)) {
return { ok: true, resolved: resolvedPath };
}
}
return {
ok: false,
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
};
}
/**
* Same as validateStackPath, but when the path comes from an existing
* stack source row (a stored custom path the admin set previously) and
* fails the allowlist check, add its parent dir to external_stack_paths
* and re-validate. Lets old custom-path configurations keep working
* without operator intervention.
*/
export async function validateStackPathWithGrandfather(
input: string,
isPreExisting: boolean
): Promise<StackPathValidation> {
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 };
}