added new photobooth ftp control service, switched ftp image
This commit is contained in:
@@ -22,6 +22,7 @@ x-app-env: &app-env
|
|||||||
BROADCAST_DRIVER: log
|
BROADCAST_DRIVER: log
|
||||||
FILESYSTEM_DISK: ${FILESYSTEM_DISK:-local-ssd}
|
FILESYSTEM_DISK: ${FILESYSTEM_DISK:-local-ssd}
|
||||||
STORAGE_ALERT_EMAIL: ${STORAGE_ALERT_EMAIL:-}
|
STORAGE_ALERT_EMAIL: ${STORAGE_ALERT_EMAIL:-}
|
||||||
|
PHOTOBOOTH_CONTROL_BASE_URL: ${PHOTOBOOTH_CONTROL_BASE_URL:-http://photobooth-ftp:8080}
|
||||||
|
|
||||||
x-app-build: &app-build
|
x-app-build: &app-build
|
||||||
context: .
|
context: .
|
||||||
@@ -52,6 +53,9 @@ services:
|
|||||||
- app-storage:/var/www/html/storage
|
- app-storage:/var/www/html/storage
|
||||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
- photobooth-import:/var/www/html/storage/app/photobooth
|
- photobooth-import:/var/www/html/storage/app/photobooth
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -117,21 +121,32 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
photobooth-ftp:
|
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:
|
env_file:
|
||||||
- path: .env
|
- path: .env
|
||||||
environment:
|
environment:
|
||||||
USERS: ${PHOTOBOOTH_FTP_USERS:-seed|changeme|/home/ftpusers/photobooth}
|
CONTROL_TOKEN: ${PHOTOBOOTH_CONTROL_TOKEN}
|
||||||
ADDRESS: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app}
|
FTP_PUBLIC_HOST: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app}
|
||||||
MIN_PORT: ${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}
|
FTP_PORT: ${PHOTOBOOTH_FTP_PORT:-2121}
|
||||||
MAX_PORT: ${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}
|
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:
|
volumes:
|
||||||
- photobooth-import:/home/ftpusers/photobooth
|
- photobooth-import:/photobooth
|
||||||
|
- photobooth-ftp-auth:/etc/pure-ftpd
|
||||||
ports:
|
ports:
|
||||||
- "${PHOTOBOOTH_FTP_PORT:-2121}:21"
|
- "${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}"
|
- "${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:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
- photobooth-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "localhost", "21"]
|
test: ["CMD", "nc", "-z", "localhost", "21"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -151,6 +166,9 @@ services:
|
|||||||
- app-code:/var/www/html
|
- app-code:/var/www/html
|
||||||
- app-storage:/var/www/html/storage
|
- app-storage:/var/www/html/storage
|
||||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -172,6 +190,9 @@ services:
|
|||||||
- app-code:/var/www/html
|
- app-code:/var/www/html
|
||||||
- app-storage:/var/www/html/storage
|
- app-storage:/var/www/html/storage
|
||||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -192,6 +213,9 @@ services:
|
|||||||
- app-storage:/var/www/html/storage
|
- app-storage:/var/www/html/storage
|
||||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
- photobooth-import:/var/www/html/storage/app/photobooth
|
- photobooth-import:/var/www/html/storage/app/photobooth
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -209,6 +233,9 @@ services:
|
|||||||
- app-code:/var/www/html
|
- app-code:/var/www/html
|
||||||
- app-storage:/var/www/html/storage
|
- app-storage:/var/www/html/storage
|
||||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- photobooth-network
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -253,9 +280,12 @@ volumes:
|
|||||||
name: fotospiel-${APP_ENV:-prod}-storage
|
name: fotospiel-${APP_ENV:-prod}-storage
|
||||||
app-bootstrap-cache:
|
app-bootstrap-cache:
|
||||||
photobooth-import:
|
photobooth-import:
|
||||||
|
photobooth-ftp-auth:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
photobooth-network:
|
||||||
|
name: fotospiel-${APP_ENV:-prod}-photobooth
|
||||||
|
|||||||
22
docker/photobooth-control/Dockerfile
Normal file
22
docker/photobooth-control/Dockerfile
Normal file
@@ -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"]
|
||||||
187
docker/photobooth-control/control-service.js
Normal file
187
docker/photobooth-control/control-service.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
58
docker/photobooth-control/entrypoint.sh
Normal file
58
docker/photobooth-control/entrypoint.sh
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user