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:
SnapOtter
2026-06-22 16:58:59 +08:00
committed by GitHub
parent 5a6368db89
commit 1fec97111b
9 changed files with 392 additions and 3 deletions
+13
View File
@@ -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`);
+73
View File
@@ -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));
}
}
}