fix: run as root by default to eliminate stack-folder permission failures (#501)

Every filesystem operation against user compose folders (save, create,
deploy, update, rollback, template install, fleet snapshot restore)
previously failed with EACCES whenever a stack container had chowned
its own bind mount to another UID, which is extremely common with
linuxserver/* images and anything that runs as root by default.

Running Sencho as root eliminates the entire class of permission bugs
at the source and matches the default posture of Portainer, Dockge,
Komodo, and Yacht. Mounting /var/run/docker.sock is already equivalent
to root-on-host, so the previous non-root hardening provided essentially
no additional isolation while breaking real features.

Changes:

- docker-entrypoint.sh: default path stays root, no GID dance, no
  privilege drop. Opt-out via SENCHO_USER=sencho restores the legacy
  behavior bit-for-bit (chown data dir, match Docker socket GID,
  su-exec to the user). Fails fast if SENCHO_USER names a nonexistent
  account. Kubernetes / OpenShift forced-non-root compat preserved via
  the existing id -u = 0 guard.
- FileSystemService: delete forceDeleteViaDocker (the ~40-line helper
  that shelled out to an alpine container to work around EACCES during
  deleteStack) and simplify deleteStack to a single fsPromises.rm call.
  Tests updated accordingly.
- Dockerfile: keep the sencho user+group pre-created so the opt-out
  path works out of the box; comments updated to document the new
  default.
- Docs: new "Container user" section in configuration.mdx documenting
  the root default and the SENCHO_USER opt-out; troubleshooting and
  self-hosting updated to match.
This commit is contained in:
Anso
2026-04-10 21:35:31 -04:00
committed by GitHub
parent f33c12fb36
commit 9eb945a6f0
8 changed files with 138 additions and 245 deletions
+12 -106
View File
@@ -1,23 +1,21 @@
/**
* Unit tests for FileSystemService.deleteStack() including the
* Docker-based fallback for permission-denied scenarios.
* Unit tests for FileSystemService.deleteStack().
*
* Sencho runs as root inside the container by default, so deleteStack only
* needs to wrap fsPromises.rm and translate ENOENT into a silent no-op.
* Permission errors are surfaced to the caller like any other failure
* (no Docker-helper fallback).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
import { EventEmitter } from 'events';
const { mockSpawn, mockRm, mockRmdir } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
const { mockRm } = vi.hoisted(() => ({
mockRm: vi.fn(),
mockRmdir: vi.fn(),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn }));
vi.mock('fs', () => ({
promises: {
rm: mockRm,
rmdir: mockRmdir,
mkdir: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
@@ -41,18 +39,6 @@ vi.mock('../services/NodeRegistry', () => ({
import { FileSystemService } from '../services/FileSystemService';
function createMockProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
return proc;
}
const expectedDir = path.join('/test/compose', 'my-stack');
describe('FileSystemService.deleteStack', () => {
@@ -75,101 +61,21 @@ describe('FileSystemService.deleteStack', () => {
await expect(service.deleteStack('gone-stack')).resolves.toBeUndefined();
});
it('falls back to Docker removal on EACCES', async () => {
it('throws on EACCES (running as root should make this rare)', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockResolvedValueOnce(undefined);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('restricted-stack');
// Emit close asynchronously so listeners are attached first
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['run', '--rm', '-v', expect.stringContaining(':/cleanup'), 'alpine', 'sh', '-c', 'find /cleanup -mindepth 1 -maxdepth 1 -exec rm -rf {} +']),
expect.objectContaining({ env: expect.any(Object) }),
);
await expect(service.deleteStack('restricted-stack')).rejects.toThrow(/permission denied/);
});
it('falls back to Docker removal on EPERM', async () => {
it('throws on EPERM', async () => {
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockResolvedValueOnce(undefined);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('eperm-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
await expect(service.deleteStack('eperm-stack')).rejects.toThrow(/operation not permitted/);
});
it('throws descriptive error when Docker fallback fails', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('stuck-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.stderr.emit('data', Buffer.from('container error'));
proc.emit('close', 1);
await expect(promise).rejects.toThrow(/Docker cleanup exited with code 1/);
});
it('throws descriptive error when Docker is unavailable', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('no-docker-stack');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('error', new Error('spawn docker ENOENT'));
await expect(promise).rejects.toThrow(/could not run Docker for cleanup/);
});
it('still succeeds if rmdir of empty shell fails after Docker cleanup', async () => {
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
mockRm.mockRejectedValueOnce(err);
mockRmdir.mockRejectedValueOnce(new Error('rmdir failed'));
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
const promise = service.deleteStack('partial-cleanup');
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled();
});
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
});
it('throws on unexpected errors (not ENOENT/EACCES/EPERM)', async () => {
it('throws on unexpected errors (e.g. EIO)', async () => {
const err = Object.assign(new Error('disk I/O error'), { code: 'EIO' });
mockRm.mockRejectedValueOnce(err);
await expect(service.deleteStack('io-error-stack')).rejects.toThrow(/disk I\/O error/);
expect(mockSpawn).not.toHaveBeenCalled();
});
});
+2 -61
View File
@@ -1,6 +1,5 @@
import path from 'path';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import { NodeRegistry } from './NodeRegistry';
/**
@@ -182,69 +181,11 @@ export class FileSystemService {
} catch (error: unknown) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') return;
if (fsError.code === 'EACCES' || fsError.code === 'EPERM') {
console.warn(
`[FileSystemService] Permission denied deleting ${stackName}, falling back to Docker-based removal`
);
await this.forceDeleteViaDocker(stackDir);
// Docker removes contents but can't remove its own mount point; clean up the empty shell
try {
await fsPromises.rmdir(stackDir);
} catch {
console.warn('[FileSystemService] Could not remove empty directory after Docker fallback — may need manual cleanup');
}
} else {
console.error('Error deleting stack directory:', fsError.message);
throw new Error(`Failed to delete stack directory: ${fsError.message}`);
}
console.error('Error deleting stack directory:', fsError.message);
throw new Error(`Failed to delete stack directory: ${fsError.message}`);
}
}
private forceDeleteViaDocker(dirPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = 30_000;
const child = spawn('docker', [
'run', '--rm',
'-v', `${dirPath}:/cleanup`,
'alpine',
'sh', '-c', 'find /cleanup -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
let stderr = '';
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
const timer = setTimeout(() => {
child.kill();
reject(new Error(
`Docker-based deletion timed out after 30s. You may need to manually remove the directory: ${dirPath}`
));
}, timeout);
child.on('close', (code: number | null) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(
`Failed to delete stack directory — Docker cleanup exited with code ${code}${stderr ? ': ' + stderr.trim() : ''}. ` +
`You may need to manually remove the directory: ${dirPath}`
));
});
child.on('error', (err: Error) => {
clearTimeout(timer);
reject(new Error(
`Failed to delete stack directory — could not run Docker for cleanup: ${err.message}. ` +
`You may need to manually remove the directory: ${dirPath}`
));
});
});
}
getBaseDir(): string {
return this.baseDir;
}