u
This commit is contained in:
154
scripts/s3-backup/backup.mjs
Normal file
154
scripts/s3-backup/backup.mjs
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import {
|
||||
ACCESS_KEY,
|
||||
BUCKET,
|
||||
DATA_DIR,
|
||||
HOST,
|
||||
PORT,
|
||||
SECRET_KEY,
|
||||
s3BaseUrl,
|
||||
} from './config.mjs';
|
||||
import { ensureCerts, installCaTrust } from './ensure-certs.mjs';
|
||||
import { startServer } from './server.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
dotenv.config({ path: path.join(root, '.env') });
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const databases = args.has('--all')
|
||||
? ['eazybusiness', 'Mandant_3']
|
||||
: [process.env.MSSQL_DATABASE || 'eazybusiness'];
|
||||
|
||||
const serverOnly = args.has('--server-only');
|
||||
const skipTrust = args.has('--skip-trust');
|
||||
|
||||
function sqlcmd(query) {
|
||||
const server = process.env.MSSQL_SERVER || 'localhost';
|
||||
const port = process.env.MSSQL_PORT || '1433';
|
||||
const user = process.env.MSSQL_USER || 'sa';
|
||||
const password = process.env.MSSQL_PASSWORD || '';
|
||||
const result = spawnSync(
|
||||
'sqlcmd',
|
||||
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`;
|
||||
if (result.status !== 0 || /^\s*Msg \d+,/m.test(output)) {
|
||||
throw new Error(output.trim() || 'sqlcmd failed');
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const req = https.request(
|
||||
{ host: HOST, port: PORT, path: '/', method: 'GET', rejectUnauthorized: false },
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 403 || res.statusCode === 200);
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.end();
|
||||
});
|
||||
if (ok) return;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
|
||||
}
|
||||
|
||||
function ensureCredential() {
|
||||
const cred = s3BaseUrl();
|
||||
sqlcmd(`
|
||||
IF EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
|
||||
DROP CREDENTIAL [${cred}];
|
||||
CREATE CREDENTIAL [${cred}]
|
||||
WITH IDENTITY = 'S3 Access Key',
|
||||
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
|
||||
`);
|
||||
}
|
||||
|
||||
function backupDatabase(db) {
|
||||
const file = `${db}-${timestamp()}.bak`;
|
||||
const url = `${s3BaseUrl()}/${file}`;
|
||||
console.log(`Backing up ${db} -> ${url}`);
|
||||
sqlcmd(`
|
||||
BACKUP DATABASE [${db}]
|
||||
TO URL = '${url}'
|
||||
WITH FORMAT, COMPRESSION, STATS = 10;
|
||||
`);
|
||||
const onDisk = path.join(DATA_DIR, file);
|
||||
if (!fs.existsSync(onDisk)) {
|
||||
throw new Error(`Backup finished but file missing on disk: ${onDisk}`);
|
||||
}
|
||||
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
|
||||
console.log(`Saved ${onDisk} (${mb} MB)`);
|
||||
return onDisk;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureCerts(HOST);
|
||||
if (!skipTrust) {
|
||||
const trusted = installCaTrust();
|
||||
if (!trusted) {
|
||||
console.warn('Could not install CA into MSSQL container. Run:');
|
||||
console.warn(` docker cp scripts/s3-backup/certs/ca.pem mssql:/tmp/jtlsrv-s3-ca.pem`);
|
||||
console.warn(' docker exec mssql bash -lc "cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates"');
|
||||
} else {
|
||||
console.log('Installed S3 CA into MSSQL container');
|
||||
}
|
||||
}
|
||||
|
||||
await startServer();
|
||||
await waitForServer();
|
||||
|
||||
if (serverOnly) {
|
||||
console.log('Server running (--server-only). Ctrl+C to stop.');
|
||||
return;
|
||||
}
|
||||
|
||||
ensureCredential();
|
||||
const saved = [];
|
||||
for (const db of databases) {
|
||||
saved.push(backupDatabase(db));
|
||||
}
|
||||
|
||||
console.log('\nBackups on disk:');
|
||||
for (const file of saved) {
|
||||
console.log(` ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.has('--help')) {
|
||||
console.log(`Usage: node scripts/s3-backup/backup.mjs [options]
|
||||
|
||||
Starts a local S3-compatible HTTPS endpoint and backs up MSSQL to disk.
|
||||
|
||||
Options:
|
||||
--all Backup eazybusiness and Mandant_3
|
||||
--server-only Start endpoint only, no backup
|
||||
--skip-trust Skip installing CA cert into system trust store
|
||||
|
||||
Reads MSSQL_* from .env in repo root.
|
||||
Backups land in scripts/s3-backup/data/${BUCKET}/
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
40
scripts/s3-backup/config.mjs
Normal file
40
scripts/s3-backup/config.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function dockerGateway() {
|
||||
try {
|
||||
const out = execSync("ip -4 route show default dev docker0 2>/dev/null | awk '{print $3}'", {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (out) return out;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '172.17.0.1';
|
||||
}
|
||||
|
||||
export const HOST = process.env.S3_BACKUP_HOST || dockerGateway();
|
||||
export const BIND = process.env.S3_BACKUP_BIND || '0.0.0.0';
|
||||
export const PORT = Number(process.env.S3_BACKUP_PORT || 19443);
|
||||
export const BUCKET = process.env.S3_BACKUP_BUCKET || 'sqlbackups';
|
||||
export const ACCESS_KEY = process.env.S3_BACKUP_ACCESS_KEY || 'jtlsrv-s3';
|
||||
export const SECRET_KEY = process.env.S3_BACKUP_SECRET_KEY || 'jtlsrv-s3-secret';
|
||||
export const REGION = process.env.S3_BACKUP_REGION || 'us-east-1';
|
||||
|
||||
export const DATA_DIR = path.join(root, 'data', BUCKET);
|
||||
export const TMP_DIR = path.join(root, 'tmp');
|
||||
export const CERTS_DIR = path.join(root, 'certs');
|
||||
export const CA_CERT = path.join(CERTS_DIR, 'ca.pem');
|
||||
export const SERVER_KEY = path.join(CERTS_DIR, 'server-key.pem');
|
||||
export const SERVER_CERT = path.join(CERTS_DIR, 'server-cert.pem');
|
||||
|
||||
export function s3BaseUrl() {
|
||||
return `s3://${HOST}:${PORT}/${BUCKET}`;
|
||||
}
|
||||
|
||||
export function httpsBaseUrl() {
|
||||
return `https://${HOST}:${PORT}`;
|
||||
}
|
||||
109
scripts/s3-backup/ensure-certs.mjs
Normal file
109
scripts/s3-backup/ensure-certs.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { CA_CERT, CERTS_DIR, HOST, SERVER_CERT, SERVER_KEY } from './config.mjs';
|
||||
|
||||
export function certHosts(host) {
|
||||
const hosts = new Set([host, '127.0.0.1', 'localhost']);
|
||||
for (const iface of Object.values(os.networkInterfaces())) {
|
||||
for (const addr of iface || []) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
hosts.add(addr.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...hosts];
|
||||
}
|
||||
|
||||
export function ensureCerts(host = HOST) {
|
||||
fs.mkdirSync(CERTS_DIR, { recursive: true });
|
||||
const hosts = certHosts(host);
|
||||
const marker = path.join(CERTS_DIR, 'hosts.txt');
|
||||
const hostList = hosts.join('\n');
|
||||
if (
|
||||
fs.existsSync(SERVER_CERT) &&
|
||||
fs.existsSync(SERVER_KEY) &&
|
||||
fs.existsSync(CA_CERT) &&
|
||||
fs.existsSync(marker) &&
|
||||
fs.readFileSync(marker, 'utf8') === hostList
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cnf = `${CERTS_DIR}/openssl.cnf`;
|
||||
const altNames = hosts
|
||||
.map((h, i) => (/^\d+\./.test(h) ? `IP.${i + 1} = ${h}` : `DNS.${i + 1} = ${h}`))
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(
|
||||
cnf,
|
||||
`[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = ${host}
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
|
||||
[alt_names]
|
||||
${altNames}
|
||||
`
|
||||
);
|
||||
|
||||
execSync(
|
||||
`openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-keyout "${CERTS_DIR}/ca-key.pem" -out "${CA_CERT}" \
|
||||
-subj "/CN=JTL S3 Backup CA/O=JTL/C=DE"`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`openssl req -newkey rsa:2048 -nodes \
|
||||
-keyout "${SERVER_KEY}" -out "${CERTS_DIR}/server.csr" \
|
||||
-config "${cnf}"`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`openssl x509 -req -in "${CERTS_DIR}/server.csr" \
|
||||
-CA "${CA_CERT}" -CAkey "${CERTS_DIR}/ca-key.pem" -CAcreateserial \
|
||||
-out "${SERVER_CERT}" -days 3650 -extensions v3_req -extfile "${cnf}"`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
fs.writeFileSync(marker, hostList);
|
||||
}
|
||||
|
||||
export function installCaTrust(container = process.env.MSSQL_DOCKER_CONTAINER || 'mssql') {
|
||||
if (!fs.existsSync(CA_CERT)) {
|
||||
ensureCerts();
|
||||
}
|
||||
|
||||
const copied = spawnSync('docker', ['cp', CA_CERT, `${container}:/tmp/jtlsrv-s3-ca.pem`], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
if (copied.status !== 0) {
|
||||
return false;
|
||||
}
|
||||
const installed = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
'exec',
|
||||
'-u',
|
||||
'root',
|
||||
container,
|
||||
'bash',
|
||||
'-lc',
|
||||
'cp /tmp/jtlsrv-s3-ca.pem /usr/local/share/ca-certificates/jtlsrv-s3.crt && update-ca-certificates',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
return installed.status === 0;
|
||||
}
|
||||
285
scripts/s3-backup/server.mjs
Normal file
285
scripts/s3-backup/server.mjs
Normal file
@@ -0,0 +1,285 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { URL } 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 `<?xml version="1.0" encoding="UTF-8"?>\n${body}`;
|
||||
}
|
||||
|
||||
function send(res, status, body = '', headers = {}) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/xml', ...headers });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
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(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Owner><ID>jtlsrv</ID><DisplayName>jtlsrv</DisplayName></Owner>
|
||||
<Buckets>
|
||||
<Bucket>
|
||||
<Name>${BUCKET}</Name>
|
||||
<CreationDate>2026-01-01T00:00:00.000Z</CreationDate>
|
||||
</Bucket>
|
||||
</Buckets>
|
||||
</ListAllMyBucketsResult>`);
|
||||
}
|
||||
|
||||
function listBucketXml(prefix) {
|
||||
const items = listObjects(prefix).map((item) => {
|
||||
const etag = etagFor(fs.readFileSync(path.join(DATA_DIR, item.key)));
|
||||
return `<Contents>
|
||||
<Key>${item.key}</Key>
|
||||
<LastModified>${item.mtime.toISOString()}</LastModified>
|
||||
<ETag>${etag}</ETag>
|
||||
<Size>${item.size}</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Contents>`;
|
||||
}).join('\n');
|
||||
return xml(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>${BUCKET}</Name>
|
||||
<Prefix>${prefix}</Prefix>
|
||||
<MaxKeys>1000</MaxKeys>
|
||||
<IsTruncated>false</IsTruncated>
|
||||
${items}
|
||||
</ListBucketResult>`);
|
||||
}
|
||||
|
||||
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(`<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${BUCKET}</Bucket>
|
||||
<Key>${key}</Key>
|
||||
<UploadId>${uploadId}</UploadId>
|
||||
</InitiateMultipartUploadResult>`);
|
||||
}
|
||||
|
||||
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(`<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Location>https://${HOST}:${PORT}/${BUCKET}/${upload.key}</Location>
|
||||
<Bucket>${BUCKET}</Bucket>
|
||||
<Key>${upload.key}</Key>
|
||||
<ETag>${etag}</ETag>
|
||||
</CompleteMultipartUploadResult>`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const body = await readBody(req);
|
||||
if (!authOk(req, body)) {
|
||||
console.error(`${req.method} ${req.url} -> 403 auth failed`);
|
||||
return send(res, 403, xml('<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>'));
|
||||
}
|
||||
|
||||
const route = parseRoute(req.url);
|
||||
if (route.type === 'missing') {
|
||||
return send(res, 404, xml('<Error><Code>NoSuchBucket</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
|
||||
if (route.type === 'root' && req.method === 'GET') {
|
||||
return send(res, 200, listBucketsXml());
|
||||
}
|
||||
|
||||
if (route.type !== 'object') {
|
||||
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
|
||||
const { key, query } = route;
|
||||
|
||||
if (req.method === 'GET' && !key) {
|
||||
return send(res, 200, listBucketXml(query.get('prefix') || ''));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && key) {
|
||||
const file = objectPath(key);
|
||||
if (!fs.existsSync(file)) {
|
||||
return send(res, 404, xml('<Error><Code>NoSuchKey</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
const data = fs.readFileSync(file);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': data.length,
|
||||
ETag: etagFor(data),
|
||||
});
|
||||
return res.end(data);
|
||||
}
|
||||
|
||||
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,
|
||||
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
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('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
return send(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && key && query.has('uploadId')) {
|
||||
if (!abortMultipart(query.get('uploadId'))) {
|
||||
return send(res, 404, xml('<Error><Code>NoSuchUpload</Code><Message>Not Found</Message></Error>'));
|
||||
}
|
||||
return send(res, 204, '');
|
||||
}
|
||||
|
||||
return send(res, 405, xml('<Error><Code>MethodNotAllowed</Code><Message>Not allowed</Message></Error>'));
|
||||
}
|
||||
|
||||
export function startServer() {
|
||||
ensureCerts(HOST);
|
||||
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('<Error><Code>InternalError</Code><Message>Server error</Message></Error>'));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(PORT, BIND, () => {
|
||||
console.log(`S3 endpoint https://${HOST}:${PORT}/${BUCKET} -> ${DATA_DIR}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
startServer();
|
||||
}
|
||||
90
scripts/s3-backup/sigv4.mjs
Normal file
90
scripts/s3-backup/sigv4.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function hmac(key, data, encoding) {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest(encoding);
|
||||
}
|
||||
|
||||
function hash(data) {
|
||||
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function parseAuthHeader(header) {
|
||||
const parts = Object.fromEntries(
|
||||
header.replace(/^AWS4-HMAC-SHA256\s+/, '').split(',').map((part) => {
|
||||
const idx = part.indexOf('=');
|
||||
const key = part.slice(0, idx).trim();
|
||||
const value = part.slice(idx + 1).trim().replace(/^"|"$/g, '');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
const credential = parts.Credential.split('/');
|
||||
return {
|
||||
accessKey: credential[0],
|
||||
date: credential[1],
|
||||
region: credential[2],
|
||||
service: credential[3],
|
||||
signedHeaders: parts.SignedHeaders.split(';'),
|
||||
signature: parts.Signature,
|
||||
};
|
||||
}
|
||||
|
||||
function getHeader(req, name) {
|
||||
return req.headers[name.toLowerCase()] || '';
|
||||
}
|
||||
|
||||
function canonicalQuery(query) {
|
||||
if (!query) return '';
|
||||
const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
|
||||
return [...params.entries()]
|
||||
.map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)])
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function canonicalHeaders(req, signedHeaders) {
|
||||
return signedHeaders
|
||||
.map((name) => `${name}:${getHeader(req, name).trim().replace(/\s+/g, ' ')}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function verifyRequest(req, body, { accessKey, secretKey, region = 'us-east-1' }) {
|
||||
const auth = getHeader(req, 'authorization');
|
||||
if (!auth.startsWith('AWS4-HMAC-SHA256')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseAuthHeader(auth);
|
||||
if (parsed.accessKey !== accessKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const amzDate = getHeader(req, 'x-amz-date');
|
||||
const declaredPayload = getHeader(req, 'x-amz-content-sha256');
|
||||
const payloadHash =
|
||||
declaredPayload === 'UNSIGNED-PAYLOAD' ? 'UNSIGNED-PAYLOAD' : declaredPayload || hash(body);
|
||||
const canonical = [
|
||||
req.method,
|
||||
req.url.split('?')[0] || '/',
|
||||
canonicalQuery(req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : ''),
|
||||
`${canonicalHeaders(req, parsed.signedHeaders)}\n`,
|
||||
parsed.signedHeaders.join(';'),
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const scope = `${parsed.date}/${region}/s3/aws4_request`;
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, hash(canonical)].join('\n');
|
||||
const signingKey = hmac(
|
||||
hmac(
|
||||
hmac(hmac(`AWS4${secretKey}`, parsed.date), region),
|
||||
's3'
|
||||
),
|
||||
'aws4_request'
|
||||
);
|
||||
const expected = hmac(signingKey, stringToSign, 'hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parsed.signature, 'hex'));
|
||||
}
|
||||
|
||||
export function etagFor(data) {
|
||||
return `"${crypto.createHash('md5').update(data).digest('hex')}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user