mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(docker): make storage writable under non-root/foreign UIDs (TrueNAS, OpenShift) (#299)
The entrypoint only fixed volume permissions when started as root (chown +
gosu-drop to snapotter). Launched under a non-root/foreign UID (TrueNAS app
user, Kubernetes runAsUser, OpenShift) it did no permission setup, so /data and
/tmp/workspace -- owned by uid 999 from the image -- were not writable by the
running user. Uploads and processing then failed with a cryptic EACCES
("workspace folder is not writable") and AI bundle installs failed the same way,
while health checks still reported the container healthy.
- entrypoint: source new entrypoint-lib.sh; verify writability up front when
non-root, and as snapotter after chown when root (catches root-squashed
mounts), failing fast with an actionable message (which dir, uid/gid, how to
fix) instead of a late, cryptic EACCES
- Dockerfile: own /data and /tmp/workspace as snapotter:0, group-writable with
setgid, so an arbitrary UID with the root supplementary group (OpenShift /
Kubernetes fsGroup) can write; keep /opt/venv world-readable for the AI venv
bootstrap under arbitrary UIDs
- api: assert storage writability at boot (lib/storage-writable.ts), failing
fast with the same guidance even when the entrypoint is bypassed
- docs: add a Storage permissions section (named volumes, bind mounts, TrueNAS,
Kubernetes/OpenShift) and cross-link it from the security guide
Fixes #230
This commit is contained in:
@@ -25,6 +25,7 @@ import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.j
|
||||
import { logger } from "./lib/logger.js";
|
||||
import { requestDuration } from "./lib/metrics.js";
|
||||
import { getSettingString } from "./lib/settings-helpers.js";
|
||||
import { assertStorageWritable } from "./lib/storage-writable.js";
|
||||
import { requirePermission } from "./permissions.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
@@ -87,6 +88,18 @@ try {
|
||||
}
|
||||
console.log("Redis connected");
|
||||
|
||||
// Verify the local storage directories are writable before serving. A non-root
|
||||
// container launched against a volume it cannot write (TrueNAS, Kubernetes
|
||||
// runAsUser / OpenShift, or a bind mount owned by another user) would otherwise
|
||||
// boot "healthy" and fail with a cryptic EACCES on the first file operation.
|
||||
try {
|
||||
await assertStorageWritable();
|
||||
console.log("Storage directories writable");
|
||||
} catch (err) {
|
||||
console.error(`FATAL: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auto-import 1.x SQLite database on first boot (before default user creation)
|
||||
if (env.SQLITE_MIGRATE_PATH) {
|
||||
const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { env } from "../config.js";
|
||||
|
||||
// Error codes that specifically mean "the running user may not write here".
|
||||
// Distinct from capacity (ENOSPC) or other I/O faults, which are handled
|
||||
// elsewhere and must not be misreported as a permissions problem.
|
||||
const NOT_WRITABLE_CODES = new Set(["EACCES", "EPERM", "EROFS"]);
|
||||
|
||||
/**
|
||||
* Returns true if `dir` can be created (when missing) and written to by the
|
||||
* current process. Creates the directory recursively, writes a short-lived
|
||||
* probe file, then removes it. Permission/read-only failures (EACCES, EPERM,
|
||||
* EROFS) resolve to false; any other error is rethrown so genuine faults are
|
||||
* not silently swallowed.
|
||||
*/
|
||||
export async function isDirWritable(dir: string): Promise<boolean> {
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const probe = join(dir, `.snapotter-write-probe-${randomUUID()}`);
|
||||
await writeFile(probe, "");
|
||||
await rm(probe, { force: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code && NOT_WRITABLE_CODES.has(code)) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort current uid/gid as strings ("?" where getuid is unavailable). */
|
||||
function currentIds(): { uid: string; gid: string } {
|
||||
const uid = typeof process.getuid === "function" ? String(process.getuid()) : "?";
|
||||
const gid = typeof process.getgid === "function" ? String(process.getgid()) : "?";
|
||||
return { uid, gid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Actionable error text for a storage directory the process cannot write to.
|
||||
* Names the directory and the running uid/gid, then lists the supported fixes
|
||||
* for the common "container runs under a foreign/non-root UID" deployments
|
||||
* (TrueNAS, Kubernetes runAsUser, OpenShift, bind mounts).
|
||||
*/
|
||||
export function storagePermissionMessage(dir: string): string {
|
||||
const { uid, gid } = currentIds();
|
||||
return [
|
||||
`Storage directory "${dir}" is not writable by the current user (uid=${uid} gid=${gid}).`,
|
||||
"SnapOtter cannot upload, process, or store files until this is fixed. Common fixes:",
|
||||
` - Host volume owned by another user: on the host run "chown -R ${uid}:${gid} <host-path>"` +
|
||||
" (or set the container user to match the volume's owner).",
|
||||
" - Running as a non-root user (TrueNAS, Kubernetes runAsUser, OpenShift): run the container" +
|
||||
" as root (the default entrypoint self-corrects), set PUID/PGID to match the volume, or grant" +
|
||||
" the process supplementary group 0 (Kubernetes fsGroup: 0).",
|
||||
" See https://docs.snapotter.com/guide/deployment#storage-permissions",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the local storage directories (WORKSPACE_PATH for processing,
|
||||
* FILES_STORAGE_PATH for the saved library) are writable, throwing an Error
|
||||
* with actionable remediation if not. No-op in S3 storage mode. Called at boot
|
||||
* so a permissions misconfiguration fails fast with a clear message instead of
|
||||
* surfacing as a cryptic EACCES on the first file operation.
|
||||
*/
|
||||
export async function assertStorageWritable(): Promise<void> {
|
||||
if (env.STORAGE_MODE === "s3") return;
|
||||
for (const dir of [env.WORKSPACE_PATH, env.FILES_STORAGE_PATH]) {
|
||||
if (!(await isDirWritable(dir))) {
|
||||
throw new Error(storagePermissionMessage(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user