Files
sencho/backend/src/services/VolumeBrowserService.ts
T
Anso 49d775c61f feat(volumes): add read-only volume browser (#926)
* feat(volumes): add read-only volume browser

Adds a browser for the contents of any Docker named volume. Click the
folder icon on a volume row (admin only) to open a sheet with a directory
tree on the left and a file viewer on the right.

Backend
-------
New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container
with the target volume mounted read-only at /v. The container runs as
nobody (65534:65534) with a read-only rootfs, no network, all caps
dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The
helper image is pulled on first use per node.

Listing and stat use a portable busybox-compatible shell loop (find
-printf is not available on Alpine). Reads use head -c with an
explicit -- separator; the helper's working directory is /v so user
paths are passed as ./<path> argv elements and never as flags. The
container lifecycle is managed manually (create, attach, start, wait,
remove) to avoid the AutoRemove race where dockerode sees a 404 on
its post-exit container lookup.

Path safety: relative paths are sanitized server-side, rejecting
parent-escape segments, absolute paths, null bytes, and oversized
input. Symlinks are listed but never followed on read. Files larger
than 5 MB are truncated; binary content is detected via null-byte
scan and returned base64-encoded. Non-zero helper exits map to
404, 403, or 500 by classifying stderr.

Routes mounted at /api/volumes:
- GET /:name/list?path=
- GET /:name/stat?path=
- GET /:name/read?path=

All three require admin. The read endpoint always inserts an audit
log row (success or failure) with the actual response status code,
volume name, and relative path.

Frontend
--------
FileTree generalized to take a loadDir callback and a sourceKey
instead of a hard-coded stackName. The single existing consumer
(StackFileExplorer) was updated and its tests rewritten. The loader
is read through a ref so re-creating the arrow on every parent
render does not re-trigger the root fetch effect.

New VolumeBrowserSheet renders the tree against the volume API,
shows file content (hex view for binaries), and surfaces truncation.
Rapid sheet open and reopen on different volumes is generation-
checked to avoid stomping the visible result with a stale read.

A persistent footnote reminds the user that file reads are recorded
in the audit log, and the docs page warns about the typical contents
of database volumes.

Tests
-----
15 new vitest cases cover the pure helpers (path traversal, volume
name validation, binary detection). The Docker-facing exec path is
exercised by manual end-to-end via curl against a seeded volume.

* fix(volumes): truncate long volume names in browser sheet header

Wide volume names overlapped the close X. Reserve right padding on
the header, set min-w-0 on the flex title, mark the icon and refresh
button shrink-0, and truncate the name span.

* fix(volumes): satisfy lint on volume browser additions

prefer-const on sanitizeRelPath's local; drop unused FileTree entry
arg from the file-select callback (variance lets the arrow take fewer
params than the contract).
2026-05-05 00:09:40 -04:00

353 lines
13 KiB
TypeScript

import { Writable } from 'stream';
import path from 'path';
import DockerController from './DockerController';
const HELPER_IMAGE = 'alpine:3.20';
const VOLUME_MOUNT = '/v';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 30_000;
// Portable shell scripts that work with BusyBox sh + stat (Alpine) and GNU
// coreutils alike. The user-supplied relative path arrives as $1; we cd into
// it before iterating, so user input never lands as an argv element to a
// command that might interpret it as a flag.
const LIST_SCRIPT = `set -e
cd -- "$1" 2>/dev/null || { echo "cd: $1: No such file or directory" >&2; exit 1; }
for entry in * .[!.]* ..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
if [ -L "$entry" ]; then t=l; link=$(readlink -- "$entry" 2>/dev/null || echo "")
elif [ -d "$entry" ]; then t=d; link=""
elif [ -f "$entry" ]; then t=f; link=""
else t=o; link=""
fi
size=$(stat -c '%s' -- "$entry" 2>/dev/null || echo 0)
mtime=$(stat -c '%Y' -- "$entry" 2>/dev/null || echo 0)
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$entry" "$link"
done`;
const STAT_SCRIPT = `set -e
target="$1"
[ -e "$target" ] || [ -L "$target" ] || { echo "cannot access $target" >&2; exit 1; }
if [ -L "$target" ]; then t=l; link=$(readlink -- "$target" 2>/dev/null || echo "")
elif [ -d "$target" ]; then t=d; link=""
elif [ -f "$target" ]; then t=f; link=""
else t=o; link=""
fi
size=$(stat -c '%s' -- "$target" 2>/dev/null || echo 0)
mtime=$(stat -c '%Y' -- "$target" 2>/dev/null || echo 0)
name=$(basename -- "$target")
printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$t" "$size" "$mtime" "$name" "$link"`;
export interface VolumeEntry {
name: string;
type: 'file' | 'directory' | 'symlink' | 'other';
size: number;
mtime: number;
isProtected: boolean;
symlinkTarget?: string;
}
export interface VolumeFileResult {
content: string;
encoding: 'utf-8' | 'base64';
binary: boolean;
truncated: boolean;
size: number;
mime: string;
}
export type VolumeStat = VolumeEntry;
export class PathTraversalError extends Error {
status = 400;
constructor() { super('Path escapes volume root'); this.name = 'PathTraversalError'; }
}
export class VolumeNotFoundError extends Error {
status = 404;
constructor(name: string) { super(`Volume '${name}' not found`); this.name = 'VolumeNotFoundError'; }
}
export class HelperImageError extends Error {
status = 503;
constructor(reason: string) { super(`Volume browser helper unavailable: ${reason}`); this.name = 'HelperImageError'; }
}
export class ExecError extends Error {
status: number;
constructor(message: string, status = 500) { super(message); this.status = status; this.name = 'ExecError'; }
}
const helperImageReady = new Map<number, boolean>();
export class VolumeBrowserService {
private nodeId: number;
private constructor(nodeId: number) { this.nodeId = nodeId; }
static getInstance(nodeId?: number): VolumeBrowserService {
return new VolumeBrowserService(nodeId ?? 1);
}
async listDir(volumeName: string, relPath: string): Promise<VolumeEntry[]> {
const safe = sanitizeRelPath(relPath);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
// Portable across BusyBox (Alpine) and GNU coreutils. Lists each
// direct child with a tab-separated row: type<TAB>size<TAB>mtime<TAB>
// name<TAB>symlinkTarget. We chdir to /v/<safe> first so user input is
// never an argv element passed to find/stat.
const script = LIST_SCRIPT;
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c', script, 'sh', `./${safe || ''}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/No such file or directory|cannot access|cd:.*?:/i.test(msg)) throw new ExecError('Path not found', 404);
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
throw new ExecError(`Listing failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const entries: VolumeEntry[] = [];
const lines = stdout.toString('utf-8').split('\n').filter(Boolean);
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 4) continue;
const [shType, sizeStr, mtimeStr, name, link = ''] = parts;
if (!name) continue;
const type: VolumeEntry['type'] =
shType === 'd' ? 'directory'
: shType === 'f' ? 'file'
: shType === 'l' ? 'symlink'
: 'other';
entries.push({
name,
type,
size: Number(sizeStr) || 0,
mtime: Math.floor(Number(mtimeStr) || 0),
isProtected: false,
...(type === 'symlink' && link ? { symlinkTarget: link } : {}),
});
}
entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
return entries;
}
async stat(volumeName: string, relPath: string): Promise<VolumeStat> {
const safe = sanitizeRelPath(relPath);
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c', STAT_SCRIPT, 'sh', `./${safe || ''}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/No such file or directory|cannot access/i.test(msg)) throw new ExecError('Path not found', 404);
throw new ExecError(`Stat failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const line = stdout.toString('utf-8').split('\n').filter(Boolean)[0] ?? '';
const parts = line.split('\t');
const [shType, sizeStr, mtimeStr, name, link = ''] = parts;
const type: VolumeEntry['type'] =
shType === 'd' ? 'directory'
: shType === 'f' ? 'file'
: shType === 'l' ? 'symlink'
: 'other';
return {
name: name || path.posix.basename(safe || '/'),
type,
size: Number(sizeStr) || 0,
mtime: Math.floor(Number(mtimeStr) || 0),
isProtected: false,
...(type === 'symlink' && link ? { symlinkTarget: link } : {}),
};
}
async readFile(volumeName: string, relPath: string, opts: { maxBytes?: number } = {}): Promise<VolumeFileResult> {
const safe = sanitizeRelPath(relPath);
if (!safe) throw new ExecError('Cannot read volume root', 400);
const maxBytes = Math.max(1, Math.min(opts.maxBytes ?? DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES));
await this.assertVolumeExists(volumeName);
await this.ensureHelperImage();
const meta = await this.stat(volumeName, safe);
if (meta.type === 'symlink') throw new ExecError('Refusing to follow symlink', 400);
if (meta.type !== 'file') throw new ExecError('Not a regular file', 400);
// Read up to maxBytes+1 to detect truncation precisely. The path is
// passed as $1 (an argv element, never concatenated) and read with
// head -c -- "$1" so a leading-dash filename is never parsed as a flag.
const { stdout, stderr, exitCode } = await this.runHelper(volumeName, [
'sh', '-c',
`head -c ${maxBytes + 1} -- "$1"`,
'sh', `./${safe}`,
]);
if (exitCode !== 0) {
const msg = stderr.toString('utf-8').trim();
if (/Permission denied/i.test(msg)) throw new ExecError('Permission denied', 403);
throw new ExecError(`Read failed: ${msg.substring(0, 200) || 'unknown error'}`);
}
const truncated = stdout.length > maxBytes;
const buf = truncated ? stdout.subarray(0, maxBytes) : stdout;
const binary = isBinaryBuffer(buf);
const mime = binary ? 'application/octet-stream' : 'text/plain';
return {
content: binary ? buf.toString('base64') : buf.toString('utf-8'),
encoding: binary ? 'base64' : 'utf-8',
binary,
truncated,
size: meta.size,
mime,
};
}
// --- internals -----------------------------------------------------------
private async assertVolumeExists(volumeName: string): Promise<void> {
if (!isValidVolumeName(volumeName)) throw new ExecError('Invalid volume name', 400);
const docker = DockerController.getInstance(this.nodeId).getDocker();
try {
await docker.getVolume(volumeName).inspect();
} catch (err: unknown) {
const e = err as { statusCode?: number; message?: string };
if (e.statusCode === 404 || /no such volume/i.test(e.message ?? '')) {
throw new VolumeNotFoundError(volumeName);
}
throw new ExecError(`Volume inspect failed: ${e.message ?? 'unknown'}`);
}
}
private async ensureHelperImage(): Promise<void> {
if (helperImageReady.get(this.nodeId)) return;
const docker = DockerController.getInstance(this.nodeId).getDocker();
try {
await docker.getImage(HELPER_IMAGE).inspect();
helperImageReady.set(this.nodeId, true);
return;
} catch { /* not present, pull it */ }
try {
await new Promise<void>((resolve, reject) => {
docker.pull(HELPER_IMAGE, (err: Error | null, stream: NodeJS.ReadableStream | null) => {
if (err || !stream) { reject(err ?? new Error('pull stream missing')); return; }
docker.modem.followProgress(stream, (finishErr: Error | null) => {
if (finishErr) reject(finishErr); else resolve();
});
});
});
helperImageReady.set(this.nodeId, true);
} catch (err) {
throw new HelperImageError((err as Error).message ?? 'pull failed');
}
}
private async runHelper(volumeName: string, cmd: string[]): Promise<{ stdout: Buffer; stderr: Buffer; exitCode: number }> {
const docker = DockerController.getInstance(this.nodeId).getDocker();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutStream = new Writable({ write(chunk, _enc, cb) { stdoutChunks.push(chunk); cb(); } });
const stderrStream = new Writable({ write(chunk, _enc, cb) { stderrChunks.push(chunk); cb(); } });
// Manual lifecycle (create -> attach -> start -> wait -> remove). Using
// dockerode's docker.run() with AutoRemove races: Docker can delete the
// container before run()'s internal wait() callback fires, surfacing as
// a 404 "no such container" from docker-modem.
const container = await docker.createContainer({
Image: HELPER_IMAGE,
Cmd: cmd,
Tty: false,
User: '65534:65534',
WorkingDir: VOLUME_MOUNT,
AttachStdout: true,
AttachStderr: true,
HostConfig: {
ReadonlyRootfs: true,
NetworkMode: 'none',
CapDrop: ['ALL'],
Privileged: false,
SecurityOpt: ['no-new-privileges:true'],
PidsLimit: 64,
Memory: 128 * 1024 * 1024,
Mounts: [{
Type: 'volume',
Source: volumeName,
Target: VOLUME_MOUNT,
ReadOnly: true,
}],
},
});
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new ExecError('Helper exec timed out', 504)), EXEC_TIMEOUT_MS);
});
const runPromise = (async () => {
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
const streamEnded = new Promise<void>((resolve) => {
stream.once('end', () => resolve());
stream.once('close', () => resolve());
});
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
await container.start();
const exitInfo = await container.wait();
// Wait for the attach stream to finish flushing demuxed output.
await streamEnded;
return {
stdout: Buffer.concat(stdoutChunks),
stderr: Buffer.concat(stderrChunks),
exitCode: typeof exitInfo?.StatusCode === 'number' ? exitInfo.StatusCode : 0,
};
})();
try {
return await Promise.race([runPromise, timeout]);
} finally {
if (timer) clearTimeout(timer);
// Always tear down the helper, regardless of how runPromise resolved.
try { await container.remove({ force: true }); } catch { /* container may have been killed by timeout already */ }
}
}
}
// --- helpers -----------------------------------------------------------------
const VOLUME_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/;
export function isValidVolumeName(name: string): boolean {
return typeof name === 'string' && VOLUME_NAME_RE.test(name);
}
export function sanitizeRelPath(relPath: string): string {
if (typeof relPath !== 'string') throw new PathTraversalError();
if (relPath.length > 1024) throw new PathTraversalError();
if (relPath.includes('\0')) throw new PathTraversalError();
// Normalize away leading slashes; reject absolute and parent-escape.
const p = relPath.replace(/^\/+/, '');
if (p === '' || p === '.') return '';
if (p.split('/').some((seg) => seg === '..')) throw new PathTraversalError();
// posix-resolve and re-check that it stays under the mount root.
const resolved = path.posix.resolve(VOLUME_MOUNT, p);
if (resolved !== VOLUME_MOUNT && !resolved.startsWith(`${VOLUME_MOUNT}/`)) {
throw new PathTraversalError();
}
// Return the trailing portion (without the /v prefix) so callers can re-join.
return resolved === VOLUME_MOUNT ? '' : resolved.slice(VOLUME_MOUNT.length + 1);
}
export function isBinaryBuffer(buf: Buffer): boolean {
const limit = Math.min(buf.length, 8192);
for (let i = 0; i < limit; i++) {
if (buf[i] === 0) return true;
}
return false;
}