diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 33cdf1b..61d160e 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -22,6 +22,7 @@ x-app-env: &app-env BROADCAST_DRIVER: log FILESYSTEM_DISK: ${FILESYSTEM_DISK:-local-ssd} STORAGE_ALERT_EMAIL: ${STORAGE_ALERT_EMAIL:-} + PHOTOBOOTH_CONTROL_BASE_URL: ${PHOTOBOOTH_CONTROL_BASE_URL:-http://photobooth-ftp:8080} x-app-build: &app-build context: . @@ -52,6 +53,9 @@ services: - app-storage:/var/www/html/storage - app-bootstrap-cache:/var/www/html/bootstrap/cache - photobooth-import:/var/www/html/storage/app/photobooth + networks: + - default + - photobooth-network depends_on: mysql: condition: service_healthy @@ -117,21 +121,32 @@ services: restart: unless-stopped photobooth-ftp: - image: delfer/alpine-ftp-server:latest + build: + context: ./docker/photobooth-control + image: registry.internal:5443/${PHOTOBOOTH_CONTROL_IMAGE:-fotospiel-photobooth-control:latest} env_file: - path: .env environment: - USERS: ${PHOTOBOOTH_FTP_USERS:-seed|changeme|/home/ftpusers/photobooth} - ADDRESS: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app} - MIN_PORT: ${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000} - MAX_PORT: ${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009} + CONTROL_TOKEN: ${PHOTOBOOTH_CONTROL_TOKEN} + FTP_PUBLIC_HOST: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app} + FTP_PORT: ${PHOTOBOOTH_FTP_PORT:-2121} + FTP_PASSIVE_MIN: ${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000} + FTP_PASSIVE_MAX: ${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009} + REQUIRE_FTPS: ${PHOTOBOOTH_REQUIRE_FTPS:-0} + PHOTOBOOTH_ROOT: /photobooth + FTP_SYSTEM_USER: ${PHOTOBOOTH_FTP_USER:-ftpuser} + FTP_SYSTEM_GROUP: ${PHOTOBOOTH_FTP_GROUP:-ftpgroup} + FTP_MAX_CLIENTS: ${PHOTOBOOTH_FTP_MAX_CLIENTS:-50} + FTP_MAX_PER_IP: ${PHOTOBOOTH_FTP_MAX_PER_IP:-10} volumes: - - photobooth-import:/home/ftpusers/photobooth + - photobooth-import:/photobooth + - photobooth-ftp-auth:/etc/pure-ftpd ports: - "${PHOTOBOOTH_FTP_PORT:-2121}:21" - "${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}:${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}" networks: - dokploy-network + - photobooth-network healthcheck: test: ["CMD", "nc", "-z", "localhost", "21"] interval: 30s @@ -151,6 +166,9 @@ services: - app-code:/var/www/html - app-storage:/var/www/html/storage - app-bootstrap-cache:/var/www/html/bootstrap/cache + networks: + - default + - photobooth-network depends_on: app: condition: service_healthy @@ -172,6 +190,9 @@ services: - app-code:/var/www/html - app-storage:/var/www/html/storage - app-bootstrap-cache:/var/www/html/bootstrap/cache + networks: + - default + - photobooth-network depends_on: app: condition: service_healthy @@ -192,6 +213,9 @@ services: - app-storage:/var/www/html/storage - app-bootstrap-cache:/var/www/html/bootstrap/cache - photobooth-import:/var/www/html/storage/app/photobooth + networks: + - default + - photobooth-network depends_on: app: condition: service_healthy @@ -209,6 +233,9 @@ services: - app-code:/var/www/html - app-storage:/var/www/html/storage - app-bootstrap-cache:/var/www/html/bootstrap/cache + networks: + - default + - photobooth-network depends_on: app: condition: service_healthy @@ -253,9 +280,12 @@ volumes: name: fotospiel-${APP_ENV:-prod}-storage app-bootstrap-cache: photobooth-import: + photobooth-ftp-auth: mysql-data: redis-data: networks: dokploy-network: external: true + photobooth-network: + name: fotospiel-${APP_ENV:-prod}-photobooth diff --git a/docker/photobooth-control/Dockerfile b/docker/photobooth-control/Dockerfile new file mode 100644 index 0000000..dd48d10 --- /dev/null +++ b/docker/photobooth-control/Dockerfile @@ -0,0 +1,22 @@ +FROM alpine:3.19 + +RUN set -eux \ + && apk add --no-cache \ + bash \ + ca-certificates \ + coreutils \ + nodejs \ + npm \ + pure-ftpd \ + && addgroup -g 2100 ftpgroup \ + && adduser -D -H -s /sbin/nologin -G ftpgroup -u 2100 ftpuser + +WORKDIR /app + +COPY control-service.js entrypoint.sh ./ + +RUN chmod +x /app/entrypoint.sh + +EXPOSE 21 8080 + +CMD ["/app/entrypoint.sh"] diff --git a/docker/photobooth-control/control-service.js b/docker/photobooth-control/control-service.js new file mode 100644 index 0000000..c9cbc4a --- /dev/null +++ b/docker/photobooth-control/control-service.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +const http = require('http'); +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const PORT = process.env.CONTROL_PORT || 8080; +const TOKEN = process.env.CONTROL_TOKEN || ''; +const FTP_ROOT = process.env.PHOTOBOOTH_ROOT || '/photobooth'; +const FTP_SYSTEM_USER = process.env.FTP_SYSTEM_USER || 'ftpuser'; +const FTP_SYSTEM_GROUP = process.env.FTP_SYSTEM_GROUP || 'ftpgroup'; +const FTP_SYSTEM_UID = process.env.FTP_SYSTEM_UID || '2100'; +const FTP_SYSTEM_GID = process.env.FTP_SYSTEM_GID || '2100'; +const FTP_PW_CMD = process.env.PURE_PW_CMD || '/usr/sbin/pure-pw'; + +const jsonResponse = (res, status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +}; + +const unauthorized = (res) => jsonResponse(res, 401, { error: { code: 'unauthorized', message: 'Missing or invalid token' } }); + +const parseBody = (req) => new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk; + if (data.length > 1_000_000) { + reject(new Error('Payload too large')); + req.connection.destroy(); + } + }); + req.on('end', () => { + if (!data) return resolve({}); + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Invalid JSON')); + } + }); +}); + +const run = (cmd, args) => new Promise((resolve, reject) => { + execFile(cmd, args, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + return reject(error); + } + resolve({ stdout, stderr }); + }); +}); + +const sanitizePath = (input) => { + if (typeof input !== 'string' || !input.trim()) { + throw new Error('path required'); + } + if (input.includes('..')) { + throw new Error('invalid path'); + } + const cleaned = input.trim().replace(/^\/+/, ''); + if (!cleaned) { + throw new Error('invalid path'); + } + return cleaned; +}; + +const sanitizeUsername = (input) => { + if (typeof input !== 'string' || !input.trim()) { + throw new Error('username required'); + } + const cleaned = input.trim(); + if (!/^[a-zA-Z0-9_-]{3,64}$/.test(cleaned)) { + throw new Error('invalid username'); + } + return cleaned; +}; + +const requirePassword = (input) => { + if (typeof input !== 'string' || input.length < 4) { + throw new Error('password required'); + } + return input; +}; + +const ensureDir = (absolutePath) => { + fs.mkdirSync(absolutePath, { recursive: true }); +}; + +const chownDir = (absolutePath) => new Promise((resolve, reject) => { + fs.chown(absolutePath, Number(process.env.FTP_SYSTEM_UID || 2100), Number(process.env.FTP_SYSTEM_GID || 2100), (err) => { + if (err) return reject(err); + resolve(); + }); +}); + +const addUser = async ({ username, password, relPath }) => { + const target = path.join(FTP_ROOT, relPath); + ensureDir(target); + await chownDir(target); + + const args = [ + 'useradd', + username, + '-m', + '-u', String(FTP_SYSTEM_UID), + '-g', String(FTP_SYSTEM_GID), + '-d', target, + '-p', password, + ]; + + await run(FTP_PW_CMD, args); +}; + +const rotateUser = async ({ username, password }) => { + const args = ['passwd', username, '-m', '-p', password]; + await run(FTP_PW_CMD, args); +}; + +const deleteUser = async ({ username }) => { + const args = ['userdel', username, '-m']; + await run(FTP_PW_CMD, args); +}; + +const notFound = (res) => jsonResponse(res, 404, { error: { code: 'not_found', message: 'Route not found' } }); + +const server = http.createServer(async (req, res) => { + if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) { + return unauthorized(res); + } + + try { + if (req.method === 'GET' && req.url === '/health') { + return jsonResponse(res, 200, { ok: true }); + } + + if (req.method === 'POST' && req.url === '/users') { + const body = await parseBody(req); + const username = sanitizeUsername(body.username); + const password = requirePassword(body.password); + const relPath = sanitizePath(body.path); + + await addUser({ username, password, relPath }); + return jsonResponse(res, 201, { ok: true }); + } + + if (req.method === 'POST' && req.url.startsWith('/users/') && req.url.endsWith('/rotate')) { + const username = sanitizeUsername(decodeURIComponent(req.url.split('/')[2] || '')); + const body = await parseBody(req); + const password = requirePassword(body.password); + + await rotateUser({ username, password }); + return jsonResponse(res, 200, { ok: true }); + } + + if (req.method === 'DELETE' && req.url.startsWith('/users/')) { + const username = sanitizeUsername(decodeURIComponent(req.url.split('/')[2] || '')); + try { + await deleteUser({ username }); + } catch (error) { + if (error.stderr && /No such user/.test(error.stderr)) { + return jsonResponse(res, 200, { ok: true, note: 'user_absent' }); + } + throw error; + } + return jsonResponse(res, 200, { ok: true }); + } + + if (req.method === 'POST' && req.url === '/config') { + return jsonResponse(res, 200, { ok: true }); + } + + return notFound(res); + } catch (error) { + console.error('[control] error', error.message); + return jsonResponse(res, 400, { + error: { + code: 'bad_request', + message: error.message, + }, + }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`[control] listening on ${PORT}`); +}); diff --git a/docker/photobooth-control/entrypoint.sh b/docker/photobooth-control/entrypoint.sh new file mode 100644 index 0000000..bd19738 --- /dev/null +++ b/docker/photobooth-control/entrypoint.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env sh +set -euo pipefail + +FTP_SYSTEM_USER=${FTP_SYSTEM_USER:-ftpuser} +FTP_SYSTEM_GROUP=${FTP_SYSTEM_GROUP:-ftpgroup} +FTP_ROOT=${PHOTOBOOTH_ROOT:-/photobooth} +FTP_PUBLIC_HOST=${FTP_PUBLIC_HOST:-localhost} +FTP_PORT=${FTP_PORT:-2121} +FTP_PASSIVE_MIN=${FTP_PASSIVE_MIN:-30000} +FTP_PASSIVE_MAX=${FTP_PASSIVE_MAX:-30009} +FTP_MAX_CLIENTS=${FTP_MAX_CLIENTS:-50} +FTP_MAX_PER_IP=${FTP_MAX_PER_IP:-10} +REQUIRE_FTPS=${REQUIRE_FTPS:-0} + +# Ensure group/user exist with stable IDs +if command -v getent >/dev/null 2>&1; then + if ! getent group "${FTP_SYSTEM_GROUP}" >/dev/null 2>&1; then + addgroup -g 2100 "${FTP_SYSTEM_GROUP}" || true + fi +else + addgroup -g 2100 "${FTP_SYSTEM_GROUP}" 2>/dev/null || true +fi + +if ! id -u "${FTP_SYSTEM_USER}" >/dev/null 2>&1; then + adduser -D -H -s /sbin/nologin -G "${FTP_SYSTEM_GROUP}" -u 2100 "${FTP_SYSTEM_USER}" || true +fi + +mkdir -p "${FTP_ROOT}" /var/log/pure-ftpd +chown -R "${FTP_SYSTEM_USER}:${FTP_SYSTEM_GROUP}" "${FTP_ROOT}" + +FTP_SYSTEM_UID=${FTP_SYSTEM_UID:-$(id -u "${FTP_SYSTEM_USER}")} +FTP_SYSTEM_GID=${FTP_SYSTEM_GID:-$(id -g "${FTP_SYSTEM_USER}")} +export FTP_SYSTEM_UID FTP_SYSTEM_GID FTP_SYSTEM_USER FTP_SYSTEM_GROUP FTP_ROOT + +TLS_FLAG="0" +if [ "${REQUIRE_FTPS}" = "1" ] || [ "${REQUIRE_FTPS}" = "true" ]; then + TLS_FLAG="2" +fi + +PURE_FLAGS="-c ${FTP_MAX_CLIENTS} -C ${FTP_MAX_PER_IP} -E -j -R -A -H -B -4 -p ${FTP_PASSIVE_MIN}:${FTP_PASSIVE_MAX} -P ${FTP_PUBLIC_HOST} -u 1000 -Y ${TLS_FLAG} -l puredb:/etc/pure-ftpd/pureftpd.pdb -O w3c:/var/log/pure-ftpd/transfer.log" + +echo "[control] Starting pure-ftpd with: ${PURE_FLAGS}" +pure-ftpd ${PURE_FLAGS} & +FTP_PID=$! + +echo "[control] Starting control API" +node /app/control-service.js & +API_PID=$! + +term_handler() { + echo "[control] Stopping..." + kill "${FTP_PID}" "${API_PID}" 2>/dev/null || true + wait +} + +trap term_handler INT TERM + +wait