272 lines
9.4 KiB
JavaScript
272 lines
9.4 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const chartDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const repoRoot = join(chartDir, "..", "..", "..");
|
|
const denDbDir = join(repoRoot, "ee", "packages", "den-db");
|
|
const denDbPackage = join(denDbDir, "package.json");
|
|
const denDbRequire = createRequire(denDbPackage);
|
|
const mysql = denDbRequire("mysql2/promise");
|
|
const bootstrapPath = join(denDbDir, "dist", "scripts", "bootstrap.js");
|
|
const currentSchemaPath = join(denDbDir, "dist", "current-schema.sql");
|
|
const denDbIndexPath = join(denDbDir, "dist", "index.js");
|
|
const image = process.env.OPENWORK_EVAL_MYSQL_IMAGE?.trim() || "mysql:8.4";
|
|
const containerName = `openwork-custom-ca-mysql-${process.pid}-${Date.now()}`;
|
|
const tmp = mkdtempSync(join(tmpdir(), "openwork-custom-ca-mysql-"));
|
|
const certsDir = join(tmp, "certs");
|
|
const caKey = join(certsDir, "ca.key");
|
|
const caCert = join(certsDir, "ca.crt");
|
|
const serverKey = join(certsDir, "server.key");
|
|
const serverCsr = join(certsDir, "server.csr");
|
|
const serverCert = join(certsDir, "server.crt");
|
|
const opensslConfig = join(tmp, "openssl.cnf");
|
|
const mysqlConfig = join(tmp, "tls.cnf");
|
|
const databaseName = "openwork_den";
|
|
const databaseUser = "openwork";
|
|
const databasePassword = "openwork_tls_test_password";
|
|
let containerStarted = false;
|
|
|
|
function run(command, args, options = {}) {
|
|
return spawnSync(command, args, {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
timeout: 180_000,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function ensureSuccess(result, description) {
|
|
if (result.status !== 0) {
|
|
throw new Error(`${description} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
}
|
|
}
|
|
|
|
function runOpenSsl(args) {
|
|
ensureSuccess(run("openssl", args), `openssl ${args.join(" ")}`);
|
|
}
|
|
|
|
function ensureBuildArtifacts() {
|
|
if (existsSync(bootstrapPath) && existsSync(currentSchemaPath) && existsSync(denDbIndexPath)) {
|
|
return;
|
|
}
|
|
|
|
const result = run("pnpm", ["--dir", denDbDir, "run", "build"], { timeout: 240_000 });
|
|
ensureSuccess(result, "pnpm --dir ee/packages/den-db run build");
|
|
console.log("Built @openwork-ee/den-db migration artifacts");
|
|
}
|
|
|
|
function generateCertificates() {
|
|
mkdirSync(certsDir, { recursive: true });
|
|
writeFileSync(
|
|
opensslConfig,
|
|
`[req]
|
|
distinguished_name = dn
|
|
prompt = no
|
|
req_extensions = v3_req
|
|
[dn]
|
|
CN = localhost
|
|
[v3_req]
|
|
basicConstraints = CA:FALSE
|
|
keyUsage = digitalSignature,keyEncipherment
|
|
extendedKeyUsage = serverAuth
|
|
subjectAltName = @alt_names
|
|
[alt_names]
|
|
DNS.1 = localhost
|
|
DNS.2 = mysql.private.test
|
|
IP.1 = 127.0.0.1
|
|
`,
|
|
);
|
|
|
|
runOpenSsl(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=OpenWork Test MySQL CA", "-keyout", caKey, "-out", caCert]);
|
|
runOpenSsl(["req", "-newkey", "rsa:2048", "-nodes", "-keyout", serverKey, "-out", serverCsr, "-config", opensslConfig]);
|
|
runOpenSsl(["x509", "-req", "-in", serverCsr, "-CA", caCert, "-CAkey", caKey, "-CAcreateserial", "-out", serverCert, "-days", "1", "-extensions", "v3_req", "-extfile", opensslConfig]);
|
|
chmodSync(serverKey, 0o644);
|
|
chmodSync(serverCert, 0o644);
|
|
chmodSync(caCert, 0o644);
|
|
|
|
writeFileSync(
|
|
mysqlConfig,
|
|
`[mysqld]
|
|
ssl-ca=/etc/mysql/certs/ca.crt
|
|
ssl-cert=/etc/mysql/certs/server.crt
|
|
ssl-key=/etc/mysql/certs/server.key
|
|
require_secure_transport=ON
|
|
tls_version=TLSv1.2,TLSv1.3
|
|
`,
|
|
);
|
|
chmodSync(mysqlConfig, 0o644);
|
|
console.log("Generated private CA and MySQL server certificate with SAN DNS:localhost, DNS:mysql.private.test, IP:127.0.0.1");
|
|
}
|
|
|
|
function docker(args, options = {}) {
|
|
return run("docker", args, { timeout: 240_000, ...options });
|
|
}
|
|
|
|
function startMysqlContainer() {
|
|
const result = docker([
|
|
"run",
|
|
"--rm",
|
|
"--detach",
|
|
"--name",
|
|
containerName,
|
|
"--publish",
|
|
"127.0.0.1::3306",
|
|
"--env",
|
|
"MYSQL_ROOT_PASSWORD=openwork_root_password",
|
|
"--env",
|
|
"MYSQL_ROOT_HOST=%",
|
|
"--env",
|
|
`MYSQL_DATABASE=${databaseName}`,
|
|
"--env",
|
|
`MYSQL_USER=${databaseUser}`,
|
|
"--env",
|
|
`MYSQL_PASSWORD=${databasePassword}`,
|
|
"--volume",
|
|
`${certsDir}:/etc/mysql/certs:ro`,
|
|
"--volume",
|
|
`${mysqlConfig}:/etc/mysql/conf.d/tls.cnf:ro`,
|
|
image,
|
|
]);
|
|
ensureSuccess(result, `docker run ${image}`);
|
|
containerStarted = true;
|
|
console.log(`Started TLS-enabled MySQL container ${containerName} from ${image}`);
|
|
}
|
|
|
|
function mappedPort() {
|
|
const result = docker(["port", containerName, "3306/tcp"]);
|
|
ensureSuccess(result, "docker port");
|
|
const endpoint = result.stdout.trim().split("\n").find(Boolean);
|
|
const port = endpoint?.split(":").pop();
|
|
if (!port) {
|
|
throw new Error(`Unable to resolve mapped MySQL port from: ${result.stdout}`);
|
|
}
|
|
return Number(port);
|
|
}
|
|
|
|
async function waitForMysql(port) {
|
|
const started = Date.now();
|
|
let lastError = "";
|
|
while (Date.now() - started < 90_000) {
|
|
try {
|
|
const connection = await mysql.createConnection({
|
|
host: "127.0.0.1",
|
|
port,
|
|
user: databaseUser,
|
|
password: databasePassword,
|
|
database: databaseName,
|
|
ssl: { rejectUnauthorized: false },
|
|
});
|
|
await connection.query("select 1");
|
|
await connection.end();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
}
|
|
}
|
|
|
|
const logs = docker(["logs", containerName], { timeout: 30_000 });
|
|
throw new Error(`MySQL container did not become ready: ${lastError}\n${logs.stdout}\n${logs.stderr}`);
|
|
}
|
|
|
|
function strictDatabaseUrl(port) {
|
|
return `mysql://${databaseUser}:${databasePassword}@127.0.0.1:${port}/${databaseName}?sslmode=verify-full`;
|
|
}
|
|
|
|
function redactedDatabaseUrl(port) {
|
|
return `mysql://${databaseUser}:REDACTED@127.0.0.1:${port}/${databaseName}?sslmode=verify-full`;
|
|
}
|
|
|
|
function childEnv(extra = {}) {
|
|
const env = { ...process.env, ...extra };
|
|
delete env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
return env;
|
|
}
|
|
|
|
function runOpenWorkMysqlQuery(databaseUrl, query, extraEnv = {}) {
|
|
const source = `
|
|
import { createRequire } from "node:module";
|
|
const mysql = createRequire(process.argv[1])("mysql2/promise");
|
|
const { parseMySqlConnectionConfig } = await import(process.argv[2]);
|
|
const connection = await mysql.createConnection(parseMySqlConnectionConfig(process.argv[3]));
|
|
try {
|
|
const [rows] = await connection.query(process.argv[4]);
|
|
console.log(JSON.stringify(rows));
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
`;
|
|
|
|
return run(process.execPath, ["--input-type=module", "-e", source, denDbPackage, pathToFileURL(denDbIndexPath).href, databaseUrl, query], {
|
|
env: childEnv(extraEnv),
|
|
timeout: 90_000,
|
|
});
|
|
}
|
|
|
|
function runBootstrap(databaseUrl) {
|
|
return run(process.execPath, [bootstrapPath], {
|
|
cwd: denDbDir,
|
|
env: childEnv({
|
|
DATABASE_URL: databaseUrl,
|
|
DEN_DB_ENCRYPTION_KEY: "openwork-custom-ca-mysql-tls-test-key-1234567890",
|
|
NODE_EXTRA_CA_CERTS: caCert,
|
|
OPENWORK_DEN_DB_ENV_PATH: join(tmp, "does-not-exist.env"),
|
|
}),
|
|
timeout: 180_000,
|
|
});
|
|
}
|
|
|
|
function cleanup() {
|
|
if (containerStarted) {
|
|
docker(["rm", "-f", containerName], { timeout: 30_000 });
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
try {
|
|
ensureBuildArtifacts();
|
|
generateCertificates();
|
|
startMysqlContainer();
|
|
const port = mappedPort();
|
|
await waitForMysql(port);
|
|
const databaseUrl = strictDatabaseUrl(port);
|
|
console.log(`TLS MySQL endpoint ready at ${redactedDatabaseUrl(port)}`);
|
|
|
|
const withoutCa = runOpenWorkMysqlQuery(databaseUrl, "select 1 as ok");
|
|
if (withoutCa.status === 0) {
|
|
throw new Error("Strict OpenWork/mysql2 unexpectedly connected without NODE_EXTRA_CA_CERTS");
|
|
}
|
|
const withoutCaError = `${withoutCa.stdout}\n${withoutCa.stderr}`.trim();
|
|
if (!/certificate|verify|self-signed|unable/i.test(withoutCaError)) {
|
|
throw new Error(`Strict OpenWork/mysql2 failed for a non-certificate reason:\n${withoutCaError}`);
|
|
}
|
|
const certificateError = withoutCaError.split("\n").find((line) => /certificate|verify|self-signed|unable/i.test(line)) ?? withoutCaError.split("\n").find(Boolean);
|
|
console.log(`Strict OpenWork/mysql2 without custom CA: rejected (${certificateError})`);
|
|
|
|
const bootstrap = runBootstrap(databaseUrl);
|
|
ensureSuccess(bootstrap, "OpenWork migration bootstrap with NODE_EXTRA_CA_CERTS");
|
|
console.log("OpenWork migration bootstrap completed with NODE_EXTRA_CA_CERTS and strict DATABASE_URL");
|
|
console.log(bootstrap.stdout.trim());
|
|
|
|
const query = runOpenWorkMysqlQuery(
|
|
databaseUrl,
|
|
"select (select count(*) from information_schema.tables where table_schema = database()) as table_count, (select count(*) from `__drizzle_migrations`) as migration_count",
|
|
{ NODE_EXTRA_CA_CERTS: caCert },
|
|
);
|
|
ensureSuccess(query, "OpenWork/mysql2 strict post-migration query with NODE_EXTRA_CA_CERTS");
|
|
const rows = JSON.parse(query.stdout.trim());
|
|
const tableCount = Number(rows[0]?.table_count ?? 0);
|
|
const migrationCount = Number(rows[0]?.migration_count ?? 0);
|
|
if (tableCount <= 0 || migrationCount <= 0) {
|
|
throw new Error(`Expected migrated OpenWork tables and migration ledger rows, got ${query.stdout}`);
|
|
}
|
|
console.log(`OpenWork/mysql2 strict query succeeded after migration: tables=${tableCount}, migrations=${migrationCount}`);
|
|
} finally {
|
|
cleanup();
|
|
}
|