mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -27,6 +27,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
* **runtime:** Sencho now runs as `root` inside the container by default. 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 as root eliminates the entire class of permission bugs at the source, matching 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. The brittle entrypoint logic that tried to dynamically match the host's Docker socket GID is removed from the default path, and the `forceDeleteViaDocker` fallback in `FileSystemService.deleteStack` is no longer needed. Files Sencho creates inside your compose folder will now be owned by `root` on the host; use `sudo` or `chown` if you need to edit them outside Sencho, same as Portainer.
|
||||
|
||||
### Added
|
||||
|
||||
* **runtime:** `SENCHO_USER` env var opt-out for operators who need a non-root container (compliance scanners, rootless Docker with UID mapping, organisational policy). Set `SENCHO_USER=sencho` at runtime and the entrypoint will chown `/app/data` to the sencho user, match the host Docker socket GID, and drop privileges via `su-exec` before Node starts. This restores the previous hardened behavior bit-for-bit. Operators opting in accept that filesystem writes to compose folders owned by another UID will fail with `EACCES`; this is the documented limitation the default mode exists to avoid.
|
||||
|
||||
### Fixed
|
||||
|
||||
* **compose:** fix atomic backup `EACCES` for stacks whose compose folder is owned by another UID. The Skipper/Admiral atomic deploy/update path used to create `.sencho-backup/` *inside* the user's stack folder, which silently failed (and broke auto-rollback and the manual rollback endpoint) for any stack where a container had chowned its bind mount (e.g. `swag`, `tautulli`, `linuxserver/*` images). Stack backups now live under `<DATA_DIR>/backups/<stackName>/` next to `sencho.db`, which is always writable by the Sencho user. **One-time transition note:** any pre-existing `.sencho-backup/` folders inside compose dirs become orphaned (safe to delete manually); rollback for the most recent pre-upgrade deploy is unavailable until the next deploy/update creates a new backup in the new location.
|
||||
|
||||
+17
-11
@@ -150,22 +150,28 @@ COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create a non-root user and ensure the data/compose directories are writable.
|
||||
# The actual volume paths are mounted at runtime, so we only pre-create the
|
||||
# default data dir here; the compose dir is user-supplied via COMPOSE_DIR.
|
||||
# Pre-create the sencho user and group so the SENCHO_USER=sencho opt-out path
|
||||
# in docker-entrypoint.sh works out of the box. The default runtime is root;
|
||||
# this user only becomes relevant when an operator explicitly sets
|
||||
# SENCHO_USER at runtime to drop privileges.
|
||||
RUN addgroup -S sencho && adduser -S -G sencho sencho \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R sencho:sencho /app
|
||||
|
||||
# Copy the entrypoint script that fixes data-volume ownership at startup and
|
||||
# then drops privileges to the sencho user via su-exec (the idiomatic Alpine
|
||||
# equivalent of gosu). This mirrors the pattern used by official Docker images
|
||||
# such as PostgreSQL, Redis, and MariaDB.
|
||||
# Sencho runs as root by default. Docker management tools like Portainer,
|
||||
# Dockge, Komodo, and Yacht all ship this way because mounting
|
||||
# /var/run/docker.sock is already equivalent to root-on-host; a non-root
|
||||
# container user buys essentially no extra isolation while breaking
|
||||
# filesystem operations against bind mounts that user stacks have chowned.
|
||||
#
|
||||
# NOTE: USER directive is intentionally absent here. The entrypoint starts as
|
||||
# root so it can chown the mounted data volume, then exec's as sencho. Static
|
||||
# security scanners (Trivy, Clair) may flag "running as root" - this is a known
|
||||
# and accepted trade-off for self-hosted apps with user-supplied volume mounts.
|
||||
# Operators who need a non-root container (compliance scanners, rootless
|
||||
# Docker with UID mapping, organisational policy) can set SENCHO_USER=sencho
|
||||
# at runtime. The entrypoint handles the privilege drop, data-volume
|
||||
# ownership, and Docker socket GID matching in that path.
|
||||
#
|
||||
# USER directive intentionally absent so the entrypoint controls the runtime
|
||||
# user. Static security scanners (Trivy, Docker Scout) may flag this as
|
||||
# "running as root" which is the documented and intended default.
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
# Strip Windows CRLF line endings that can sneak in on Windows dev machines
|
||||
# even with .gitattributes eol=lf, then make executable. A shell script with
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+48
-46
@@ -4,62 +4,64 @@ set -e
|
||||
# Resolve the data directory, mirroring DatabaseService.ts logic.
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
|
||||
# If running as root (the default Docker container start), fix volume ownership,
|
||||
# fix Docker socket group access, then drop privileges before executing the app.
|
||||
# Sencho runs as root by default inside the container. It needs access to
|
||||
# /var/run/docker.sock (which is equivalent to root on the host regardless
|
||||
# of the user Sencho itself runs as) and to user-supplied bind mounts that
|
||||
# containers in those stacks commonly chown to arbitrary UIDs. Running as
|
||||
# root is the same posture used by Portainer, Dockge, Komodo, and Yacht.
|
||||
#
|
||||
# This is the industry-standard pattern used by the official PostgreSQL, Redis,
|
||||
# and MariaDB Docker images, and by Docker management tools like Portainer and
|
||||
# Dockge that also require access to /var/run/docker.sock as a non-root user.
|
||||
# Users who need the container to drop privileges (organisational policy,
|
||||
# compliance scanners, rootless Docker with UID mapping) can set
|
||||
# SENCHO_USER=sencho at runtime to opt into the legacy non-root mode. The
|
||||
# opt-out path does all the work the old entrypoint used to do: fix data
|
||||
# volume ownership, match the Docker socket GID, and exec via su-exec.
|
||||
#
|
||||
# The UID guard also ensures compatibility with strict environments like
|
||||
# Kubernetes (runAsNonRoot: true) or OpenShift, where the container is forced to
|
||||
# run as a random high UID. In that case both blocks are skipped and the app
|
||||
# exec's directly without crashing.
|
||||
# The "id -u = 0" guard keeps Kubernetes / OpenShift forced-non-root
|
||||
# deployments (runAsNonRoot: true with a random high UID) working: the
|
||||
# entire setup block is skipped and the app exec's directly.
|
||||
if [ "$(id -u)" = '0' ]; then
|
||||
|
||||
# 1. Fix data volume ownership.
|
||||
# Handles host volumes previously created by root or a different UID, which
|
||||
# would cause SQLITE_READONLY errors when the non-root sencho user starts.
|
||||
# Only touches files with wrong user OR group (efficient on large dirs).
|
||||
mkdir -p "$DATA_DIR"
|
||||
find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \
|
||||
-exec chown sencho:sencho '{}' +
|
||||
# Restrict encryption key to owner-only access (rw-------)
|
||||
# Restrict encryption key to owner-only access regardless of runtime user.
|
||||
[ -f "$DATA_DIR/encryption.key" ] && chmod 600 "$DATA_DIR/encryption.key"
|
||||
echo "[entrypoint] Data directory ownership ensured: $DATA_DIR"
|
||||
|
||||
# 2. Fix Docker socket group access.
|
||||
# The Docker socket on the host is owned by the host's docker group, whose
|
||||
# GID varies by Linux distribution and does not match any group inside the
|
||||
# container by default.
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
DOCKER_SOCK_MODE=$(stat -c '%a' /var/run/docker.sock)
|
||||
echo "[entrypoint] Docker socket found: GID=$DOCKER_SOCK_GID mode=$DOCKER_SOCK_MODE"
|
||||
|
||||
if [ "$DOCKER_SOCK_GID" = "0" ]; then
|
||||
echo "[entrypoint] WARNING: Docker socket is root:root -- adding sencho to root group"
|
||||
addgroup sencho root 2>/dev/null || true
|
||||
else
|
||||
if ! getent group "$DOCKER_SOCK_GID" > /dev/null 2>&1; then
|
||||
addgroup -S -g "$DOCKER_SOCK_GID" docker-host
|
||||
echo "[entrypoint] Created group docker-host with GID $DOCKER_SOCK_GID"
|
||||
fi
|
||||
DOCKER_GROUP=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1)
|
||||
addgroup sencho "$DOCKER_GROUP" 2>/dev/null || true
|
||||
echo "[entrypoint] Added sencho to group '$DOCKER_GROUP' (GID $DOCKER_SOCK_GID)"
|
||||
if [ -n "$SENCHO_USER" ]; then
|
||||
# Fail fast if the opted-in user does not exist inside the container.
|
||||
# Without this, the su-exec call below would error out with a cryptic
|
||||
# "su-exec: getpwnam($SENCHO_USER): No such file or directory" and the
|
||||
# operator would have no hint that the variable itself is the cause.
|
||||
if ! id "$SENCHO_USER" >/dev/null 2>&1; then
|
||||
echo "[entrypoint] ERROR: SENCHO_USER=$SENCHO_USER does not exist inside the container." >&2
|
||||
echo "[entrypoint] Use 'sencho' (pre-created) or unset SENCHO_USER to run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[entrypoint] WARNING: /var/run/docker.sock not found -- Docker features unavailable"
|
||||
|
||||
# Re-own the data dir so SQLite and the encryption key are readable
|
||||
# after the privilege drop. `|| true` tolerates a read-only /app/data
|
||||
# (rare but possible with some bind-mount configurations); chown
|
||||
# errors still surface in the log so operators see them.
|
||||
find "$DATA_DIR" \( \! -user "$SENCHO_USER" -o \! -group "$SENCHO_USER" \) \
|
||||
-exec chown "$SENCHO_USER:$SENCHO_USER" '{}' + || true
|
||||
|
||||
# Match the Docker socket GID so the dropped user can reach Docker.
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
if [ "$DOCKER_SOCK_GID" = "0" ]; then
|
||||
addgroup "$SENCHO_USER" root 2>/dev/null || true
|
||||
else
|
||||
if ! getent group "$DOCKER_SOCK_GID" > /dev/null 2>&1; then
|
||||
addgroup -S -g "$DOCKER_SOCK_GID" docker-host
|
||||
fi
|
||||
DOCKER_GROUP=$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1)
|
||||
addgroup "$SENCHO_USER" "$DOCKER_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[entrypoint] SENCHO_USER=$SENCHO_USER set; dropping privileges."
|
||||
exec su-exec "$SENCHO_USER" "$@"
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Dropping privileges to sencho (uid=$(id -u sencho))"
|
||||
|
||||
# 3. Drop privileges.
|
||||
# Replace this shell with su-exec so Node becomes PID 1 and receives
|
||||
# SIGTERM/SIGINT directly. su-exec calls getgrouplist() for named users,
|
||||
# so all supplementary groups added above are inherited by the process.
|
||||
exec su-exec sencho "$@"
|
||||
echo "[entrypoint] Running as root. Set SENCHO_USER=sencho to drop privileges."
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -27,6 +27,37 @@ When you point `COMPOSE_DIR` at a directory, Sencho expects each stack to live i
|
||||
| `DATA_DIR` | `/app/data` | Directory where Sencho stores its SQLite database, node registry, and cached metrics. |
|
||||
| `FRONTEND_URL` | *(empty)* | Frontend origin for CORS. Only needed if the UI is served from a different domain than the API. Leave empty for same-origin setups. |
|
||||
| `NODE_ENV` | `production` | Set automatically in the Docker image. Only change this for local development. |
|
||||
| `SENCHO_USER` | *(unset)* | Optional. When set to a username present inside the container (`sencho` is pre-created for this purpose), the entrypoint drops privileges to that user at startup instead of running as `root`. See [Running as a non-root user](#running-as-a-non-root-user) below. |
|
||||
|
||||
## Container user
|
||||
|
||||
Sencho runs as `root` inside the container by default, matching Portainer, Dockge, Komodo, and Yacht. This is required so Sencho can always write to your compose folders, even when a stack container (commonly anything from `linuxserver/*`) has chowned its own bind mount to another UID. Mounting `/var/run/docker.sock` already grants root-equivalent access to the host, so running the Sencho process itself as root does not change the effective privilege boundary.
|
||||
|
||||
**What this means for you:**
|
||||
|
||||
- Files Sencho creates inside your compose directory (new stacks, edited `compose.yaml`, generated `.env` files) will be owned by `root` on the host. If you edit those files outside Sencho using your own editor, you will need `sudo` or a one-time `chown`.
|
||||
- The `/app/data` directory is internal to Sencho; its ownership does not matter for host-side tooling.
|
||||
|
||||
### Running as a non-root user
|
||||
|
||||
If organisational policy, compliance scanners, or a rootless Docker setup requires the container to drop privileges, set `SENCHO_USER=sencho` in the environment. The entrypoint will chown `/app/data` to the sencho user, match the host's Docker socket group, and exec Node under that user.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sencho:
|
||||
image: saelix/sencho:latest
|
||||
environment:
|
||||
- COMPOSE_DIR=/opt/compose
|
||||
- SENCHO_USER=sencho # opt into non-root mode
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/compose:/opt/compose
|
||||
- ./sencho-data:/app/data
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Non-root mode can cause filesystem write failures for any stack whose compose folder is owned by a UID other than `sencho`. If you hit "Failed to save stack" or similar errors after opting in, either `chown` the affected stack folder to the sencho user inside the container, or unset `SENCHO_USER` to return to the default (root) mode.
|
||||
</Warning>
|
||||
|
||||
## SSO environment variables
|
||||
|
||||
|
||||
@@ -43,19 +43,16 @@ Sencho requires three volume mounts to function correctly:
|
||||
|
||||
## Docker socket security
|
||||
|
||||
Mounting the Docker socket (`/var/run/docker.sock`) grants the container the ability to manage all containers, images, volumes, and networks on the host. This is equivalent to root access on the host machine.
|
||||
Mounting the Docker socket (`/var/run/docker.sock`) grants the container the ability to manage all containers, images, volumes, and networks on the host. This is equivalent to root access on the host machine, regardless of which user the Sencho process itself runs as.
|
||||
|
||||
Sencho mitigates this with privilege dropping:
|
||||
Because the Docker socket is already the effective privilege boundary, Sencho runs as `root` inside the container by default. This matches Portainer, Dockge, Komodo, and Yacht, and ensures Sencho can always write to your compose folders, even when a stack container (for example anything from `linuxserver/*`) has chowned its own bind mount. See [Configuration: Container user](/getting-started/configuration#container-user) for details on the default and the `SENCHO_USER` opt-out.
|
||||
|
||||
1. The container starts as root to fix volume ownership and resolve Docker socket group permissions
|
||||
2. The entrypoint script then drops to a non-root `sencho` user
|
||||
3. All application code runs as the `sencho` user
|
||||
If your environment requires stricter isolation, the Docker socket itself is the right place to focus:
|
||||
|
||||
If your environment requires stricter isolation, consider:
|
||||
|
||||
- Running Sencho on a dedicated Docker host
|
||||
- Using Docker's `--userns-remap` for user namespace isolation
|
||||
- Placing Sencho behind a reverse proxy with authentication (see [Configuration](/getting-started/configuration#reverse-proxy-setup))
|
||||
- Running Sencho on a dedicated Docker host so a compromise cannot reach unrelated workloads
|
||||
- Using Docker's `--userns-remap` for user namespace isolation at the daemon level
|
||||
- Running Sencho behind a reverse proxy with authentication (see [Configuration](/getting-started/configuration#reverse-proxy-setup)) so the UI is not exposed to untrusted networks
|
||||
- Setting `SENCHO_USER=sencho` to drop privileges inside the container as a defense-in-depth measure, accepting that filesystem writes to permission-restricted stack folders will fail in that mode
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,35 +54,37 @@ volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
On Linux, the Docker socket is owned by the `docker` group. The Sencho entrypoint detects the socket's GID automatically and adds the internal `sencho` user to the matching group. If you see permission errors despite a correct mount, check that the socket file is readable:
|
||||
If you see permission errors despite a correct mount, check that the socket file exists and is a socket:
|
||||
|
||||
```bash
|
||||
ls -la /var/run/docker.sock
|
||||
# Expected: srw-rw---- 1 root docker ...
|
||||
```
|
||||
|
||||
If the group is not `docker`, the auto-detection still works. Sencho reads the GID from the socket file at startup.
|
||||
Because Sencho runs as `root` inside the container by default, it can read the socket regardless of which group owns it on the host. Permission errors here usually mean the socket is not mounted at all, or it is mounted read-only.
|
||||
|
||||
<Note>
|
||||
If you have opted into non-root mode via `SENCHO_USER=sencho`, the entrypoint will detect the socket's GID and add the sencho user to the matching group automatically. Socket errors in that mode most often mean the `SENCHO_USER` was set to an account that does not exist inside the container.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## "Permission denied" when deleting a stack
|
||||
## "Permission denied" when editing or deleting a stack
|
||||
|
||||
**Symptom:** Clicking **Delete** on a stack fails with a permission error.
|
||||
**Symptom:** Saving `compose.yaml`, creating a new stack, or deleting a stack fails with a permission error.
|
||||
|
||||
**Cause:** Stack directories often contain files owned by root, for example when Docker Compose was run with `sudo`, or when containers write config/data files into the stack directory. Since Sencho runs as a non-root user, the OS denies the removal.
|
||||
Sencho runs as `root` inside the container by default specifically to avoid this class of failure. If you are seeing permission errors, one of the following is true:
|
||||
|
||||
This is especially common when installing Sencho on a server where stacks were originally created outside of Sencho (e.g. via `sudo docker compose up`). Those directories and their contents are root-owned, but Sencho can still delete them.
|
||||
- **You are running in non-root mode** (`SENCHO_USER` is set). In this mode, filesystem writes to stack folders owned by a different UID will fail. Either `chown` the affected stack folder to the opted-in user inside the container, or unset `SENCHO_USER` to return to the default root mode.
|
||||
- **The compose directory mount is read-only.** Check your Sencho container's volume mounts for a trailing `:ro` on the compose dir bind.
|
||||
- **The underlying filesystem is read-only** (e.g. a full disk, a failed disk, or a remote filesystem that lost connectivity).
|
||||
|
||||
**How Sencho handles it:** Sencho automatically detects permission errors during deletion and falls back to a Docker-based cleanup. It spawns a short-lived container that bind-mounts the stack directory and removes the root-owned files. This happens transparently, with no manual intervention needed in the standard Docker setup.
|
||||
|
||||
**If automatic cleanup fails:** The error message will include the directory path. Remove it manually:
|
||||
If you need to clean up a leftover stack directory manually:
|
||||
|
||||
```bash
|
||||
sudo rm -rf /path/to/your/compose/dir/stack-name
|
||||
```
|
||||
|
||||
**Prerequisites:** The Docker socket must be mounted (standard setup). If Sencho cannot access Docker, the fallback will not work. See ["Permission denied" on the Docker socket](#permission-denied-on-the-docker-socket).
|
||||
|
||||
---
|
||||
|
||||
## Login page shows "Something went wrong"
|
||||
|
||||
Reference in New Issue
Block a user