added new photobooth ftp control service, switched ftp image

This commit is contained in:
Codex Agent
2025-12-04 20:30:46 +01:00
parent 4ede5351be
commit a5b4feb57e
4 changed files with 303 additions and 6 deletions

View 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"]

View 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}`);
});

View 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