import crypto from 'node:crypto'; import fs from 'node:fs'; import https from 'node:https'; import path from 'node:path'; import { URL, pathToFileURL } from 'node:url'; import { ACCESS_KEY, BIND, BUCKET, DATA_DIR, HOST, PORT, REGION, SECRET_KEY, SERVER_CERT, SERVER_KEY, TMP_DIR, } from './config.mjs'; import { ensureCerts } from './ensure-certs.mjs'; import { etagFor, verifyRequest } from './sigv4.mjs'; const uploads = new Map(); function xml(body) { return `\n${body}`; } function send(res, status, body = '', headers = {}) { res.writeHead(status, { 'Content-Type': 'application/xml', ...headers }); res.end(body); } function readBody(req) { if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'DELETE') { return Promise.resolve(Buffer.alloc(0)); } return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function objectPath(key) { return path.join(DATA_DIR, key); } function ensureObjectDir(key) { fs.mkdirSync(path.dirname(objectPath(key)), { recursive: true }); } function listObjects(prefix = '') { if (!fs.existsSync(DATA_DIR)) return []; const out = []; const walk = (dir, rel = '') => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const relPath = rel ? `${rel}/${entry.name}` : entry.name; const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full, relPath); } else if (!prefix || relPath.startsWith(prefix)) { const stat = fs.statSync(full); out.push({ key: relPath, size: stat.size, mtime: stat.mtime }); } } }; walk(DATA_DIR); return out; } function parseRoute(urlString) { const url = new URL(urlString, 'https://local'); const parts = url.pathname.split('/').filter(Boolean); if (parts.length === 0) { return { type: 'root' }; } if (parts[0] !== BUCKET) { return { type: 'missing' }; } const key = parts.slice(1).join('/'); return { type: 'object', key, query: url.searchParams }; } function authOk(req, body) { if (!req.headers.authorization) return false; return verifyRequest(req, body, { accessKey: ACCESS_KEY, secretKey: SECRET_KEY, region: REGION, }); } function listBucketsXml() { return xml(` jtlsrvjtlsrv ${BUCKET} 2026-01-01T00:00:00.000Z `); } function listBucketXml(prefix) { const items = listObjects(prefix).map((item) => { const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key))); return ` ${item.key} ${item.mtime.toISOString()} ${etag} ${item.size} STANDARD `; }).join('\n'); return xml(` ${BUCKET} ${prefix} 1000 false ${items} `); } function initiateMultipart(key) { const uploadId = crypto.randomUUID(); const dir = path.join(TMP_DIR, uploadId); fs.mkdirSync(dir, { recursive: true }); uploads.set(uploadId, { key, dir, parts: new Map() }); return xml(` ${BUCKET} ${key} ${uploadId} `); } function savePart(uploadId, partNumber, body) { const upload = uploads.get(uploadId); if (!upload) return null; const partPath = path.join(upload.dir, String(partNumber).padStart(5, '0')); fs.writeFileSync(partPath, body); upload.parts.set(partNumber, partPath); return etagFor(body); } function completeMultipart(uploadId) { const upload = uploads.get(uploadId); if (!upload) return null; ensureObjectDir(upload.key); const partNumbers = [...upload.parts.keys()].sort((a, b) => a - b); const chunks = partNumbers.map((n) => fs.readFileSync(upload.parts.get(n))); const finalData = Buffer.concat(chunks); fs.writeFileSync(objectPath(upload.key), finalData); fs.rmSync(upload.dir, { recursive: true, force: true }); uploads.delete(uploadId); const etag = etagFor(finalData); return xml(` https://${HOST}:${PORT}/${BUCKET}/${upload.key} ${BUCKET} ${upload.key} ${etag} `); } function abortMultipart(uploadId) { const upload = uploads.get(uploadId); if (!upload) return false; fs.rmSync(upload.dir, { recursive: true, force: true }); uploads.delete(uploadId); return true; } function debug(...args) { if (process.env.S3_BACKUP_DEBUG) { console.error(...args); } } async function handle(req, res) { const body = await readBody(req); debug(`${req.method} ${req.url} len=${body.length}`); if (!authOk(req, body)) { debug(`auth failed ${req.method} ${req.url}`); return send(res, 403, xml('AccessDeniedAccess Denied')); } const route = parseRoute(req.url); if (route.type === 'missing') { return send(res, 404, xml('NoSuchBucketNot Found')); } if (route.type === 'root' && req.method === 'GET') { return send(res, 200, listBucketsXml()); } if (route.type !== 'object') { return send(res, 404, xml('NoSuchKeyNot Found')); } const { key, query } = route; if (req.method === 'GET' && !key) { return send(res, 200, listBucketXml(query.get('prefix') || '')); } if (req.method === 'HEAD' && !key) { return send(res, 200, ''); } if (req.method === 'GET' && key) { const file = objectPath(key); if (!fs.existsSync(file)) { return send(res, 404, xml('NoSuchKeyNot Found')); } const stat = fs.statSync(file); const etag = etagFor(fs.readFileSync(file)); const range = req.headers.range; if (range) { const match = /^bytes=(\d+)-(\d*)$/i.exec(range); if (match) { const start = Number(match[1]); const end = match[2] ? Number(match[2]) : stat.size - 1; if (start >= stat.size || end < start) { res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }); return res.end(); } const length = end - start + 1; res.writeHead(206, { 'Content-Type': 'application/octet-stream', 'Content-Length': length, 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Accept-Ranges': 'bytes', ETag: etag, }); return fs.createReadStream(file, { start, end }).pipe(res); } } res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': stat.size, 'Accept-Ranges': 'bytes', ETag: etag, }); return fs.createReadStream(file).pipe(res); } if (req.method === 'HEAD' && key) { const file = objectPath(key); if (!fs.existsSync(file)) { return send(res, 404, ''); } const stat = fs.statSync(file); return send(res, 200, '', { 'Content-Length': stat.size, 'Accept-Ranges': 'bytes', ETag: etagFor(fs.readFileSync(file)), }); } if (req.method === 'PUT' && key && query.has('uploadId') && query.has('partNumber')) { const etag = savePart(query.get('uploadId'), Number(query.get('partNumber')), body); if (!etag) { return send(res, 404, xml('NoSuchUploadNot Found')); } return send(res, 200, '', { ETag: etag }); } if (req.method === 'PUT' && key) { ensureObjectDir(key); fs.writeFileSync(objectPath(key), body); return send(res, 200, '', { ETag: etagFor(body) }); } if (req.method === 'POST' && key && query.has('uploads')) { return send(res, 200, initiateMultipart(key)); } if (req.method === 'POST' && key && query.has('uploadId')) { const result = completeMultipart(query.get('uploadId')); if (!result) { return send(res, 404, xml('NoSuchUploadNot Found')); } return send(res, 200, result); } if (req.method === 'DELETE' && key && query.has('uploadId')) { if (!abortMultipart(query.get('uploadId'))) { return send(res, 404, xml('NoSuchUploadNot Found')); } return send(res, 204, ''); } return send(res, 405, xml('MethodNotAllowedNot allowed')); } export function startServer() { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(TMP_DIR, { recursive: true }); const server = https.createServer( { key: fs.readFileSync(SERVER_KEY), cert: fs.readFileSync(SERVER_CERT) }, (req, res) => { handle(req, res).catch((err) => { console.error(err); send(res, 500, xml('InternalErrorServer error')); }); } ); return new Promise((resolve, reject) => { server.on('error', (err) => { if (err.code === 'EADDRINUSE') { reject(new Error(`Port ${PORT} is already in use`)); return; } reject(err); }); server.on('secureConnection', (tlsSocket) => { debug(`tls ${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`); }); server.listen(PORT, BIND, () => { console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`); resolve(server); }); }); } if (process.env.S3_BACKUP_CHILD || import.meta.url === pathToFileURL(process.argv[1]).href) { startServer().catch((err) => { console.error(err.message || err); process.exit(1); }); }