Files
Productier/scripts/check-production-env.mjs
Tomas Dvorak 3cb40adb23 first commit
2026-04-10 12:04:09 +02:00

277 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import path from "node:path";
const targetPath = path.resolve(process.cwd(), process.argv[2] || ".env.production");
const requiredKeys = [
"PUBLIC_DOMAIN",
"PUBLIC_URL",
"TLS_EMAIL",
"BETTER_AUTH_SECRET",
"MAIL_ENCRYPTION_KEY",
"CORS_ALLOW_ORIGINS",
"AUTH_MAGIC_LINK_PROVIDER",
"AUTH_MAIL_FROM",
"AUTH_SMTP_HOST",
"AUTH_SMTP_PORT",
"AUTH_SMTP_SECURE",
"AUTH_SMTP_USER",
"AUTH_SMTP_PASSWORD",
"POSTGRES_PASSWORD",
"S3_REGION",
"S3_BUCKET",
"S3_ACCESS_KEY",
"S3_SECRET_KEY"
];
const secretKeys = [
"BETTER_AUTH_SECRET",
"MAIL_ENCRYPTION_KEY",
"AUTH_SMTP_PASSWORD",
"POSTGRES_PASSWORD",
"S3_ACCESS_KEY",
"S3_SECRET_KEY"
];
const optionalSecretKeys = [
"METRICS_AUTH_TOKEN"
];
const insecureSecretValues = new Set([
"",
"changeme",
"change-me",
"replace-me",
"replace-with-a-long-random-secret",
"replace-with-a-different-long-random-secret",
"replace-with-smtp-password",
"replace-with-strong-password",
"replace-with-access-key",
"replace-with-secret-key",
"replace-with-metrics-token"
]);
function stripOptionalQuotes(value) {
const trimmed = value.trim();
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseEnv(raw) {
const result = new Map();
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const normalized = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
const equalsIndex = normalized.indexOf("=");
if (equalsIndex <= 0) {
continue;
}
const key = normalized.slice(0, equalsIndex).trim();
const value = stripOptionalQuotes(normalized.slice(equalsIndex + 1));
result.set(key, value);
}
return result;
}
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase();
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function parseAbsoluteURL(value, envName, errors) {
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
errors.push(`${envName} must use http or https`);
return null;
}
return parsed;
} catch {
errors.push(`${envName} must be a valid absolute URL`);
return null;
}
}
function isLikelyEmail(value) {
const normalized = String(value || "").trim();
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized);
}
function parseBoolean(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no") {
return false;
}
return null;
}
function normalizeOrigin(url) {
return `${url.protocol}//${url.host}`.toLowerCase();
}
async function main() {
const errors = [];
const warnings = [];
let raw;
try {
raw = await readFile(targetPath, "utf8");
} catch (error) {
console.error(`[error] failed to read ${targetPath}: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const env = parseEnv(raw);
for (const key of requiredKeys) {
const value = env.get(key);
if (!value || !value.trim()) {
errors.push(`${key} is required`);
}
}
for (const key of secretKeys) {
const value = String(env.get(key) || "").trim();
const normalized = value.toLowerCase();
if (insecureSecretValues.has(normalized) || value.length < 16) {
errors.push(`${key} must be a strong non-placeholder secret (minimum 16 characters)`);
}
}
for (const key of optionalSecretKeys) {
const value = String(env.get(key) || "").trim();
if (!value) {
continue;
}
const normalized = value.toLowerCase();
if (insecureSecretValues.has(normalized) || value.length < 16) {
errors.push(`${key} must be a strong non-placeholder secret (minimum 16 characters) when set`);
}
}
const publicURL = env.get("PUBLIC_URL");
const publicDomain = String(env.get("PUBLIC_DOMAIN") || "").trim().toLowerCase();
let normalizedPublicOrigin = "";
if (publicURL) {
const parsedPublicURL = parseAbsoluteURL(publicURL, "PUBLIC_URL", errors);
if (parsedPublicURL) {
normalizedPublicOrigin = normalizeOrigin(parsedPublicURL);
if (!isLocalHost(parsedPublicURL.hostname) && parsedPublicURL.protocol !== "https:") {
errors.push("PUBLIC_URL must use https for non-local deployments");
}
if (publicDomain && parsedPublicURL.hostname.toLowerCase() !== publicDomain) {
errors.push(`PUBLIC_URL host (${parsedPublicURL.hostname}) must match PUBLIC_DOMAIN (${publicDomain})`);
}
}
}
const corsOrigins = env.get("CORS_ALLOW_ORIGINS");
if (corsOrigins) {
if (corsOrigins.includes("*")) {
errors.push("CORS_ALLOW_ORIGINS cannot include '*'");
}
const normalizedCorsOrigins = new Set();
for (const rawOrigin of corsOrigins.split(",")) {
const origin = rawOrigin.trim();
if (!origin) {
continue;
}
const parsedOrigin = parseAbsoluteURL(origin, "CORS_ALLOW_ORIGINS", errors);
if (!parsedOrigin) {
continue;
}
if ((parsedOrigin.pathname && parsedOrigin.pathname !== "/") || parsedOrigin.search || parsedOrigin.hash) {
errors.push(`CORS_ALLOW_ORIGINS origin must not include path/query/fragment: ${origin}`);
}
if (!isLocalHost(parsedOrigin.hostname) && parsedOrigin.protocol !== "https:") {
errors.push(`CORS_ALLOW_ORIGINS origin must use https for non-local deployments: ${origin}`);
}
if (publicDomain !== "localhost" && isLocalHost(parsedOrigin.hostname)) {
errors.push(`CORS_ALLOW_ORIGINS cannot include localhost in production: ${origin}`);
}
normalizedCorsOrigins.add(normalizeOrigin(parsedOrigin));
}
if (normalizedPublicOrigin && !normalizedCorsOrigins.has(normalizedPublicOrigin)) {
errors.push(`CORS_ALLOW_ORIGINS must include PUBLIC_URL origin (${normalizedPublicOrigin})`);
}
}
const magicLinkProvider = String(env.get("AUTH_MAGIC_LINK_PROVIDER") || "").trim().toLowerCase();
if (magicLinkProvider !== "smtp") {
errors.push("AUTH_MAGIC_LINK_PROVIDER must be smtp for production deployments");
}
const authDevMailboxEnabled = String(env.get("AUTH_DEV_MAILBOX_ENABLED") || "").trim().toLowerCase();
if (authDevMailboxEnabled === "true" || authDevMailboxEnabled === "1" || authDevMailboxEnabled === "yes") {
errors.push("AUTH_DEV_MAILBOX_ENABLED must be false in production");
}
const smtpPort = Number.parseInt(String(env.get("AUTH_SMTP_PORT") || ""), 10);
if (Number.isNaN(smtpPort) || smtpPort < 1 || smtpPort > 65535) {
errors.push("AUTH_SMTP_PORT must be a valid TCP port");
}
const smtpSecure = parseBoolean(env.get("AUTH_SMTP_SECURE"));
if (smtpSecure === null) {
errors.push("AUTH_SMTP_SECURE must be true/false");
} else if (smtpSecure === false && smtpPort === 465) {
errors.push("AUTH_SMTP_SECURE should be true when AUTH_SMTP_PORT=465");
}
const smtpSkipVerify = parseBoolean(env.get("AUTH_SMTP_SKIP_VERIFY"));
if (smtpSkipVerify === true) {
errors.push("AUTH_SMTP_SKIP_VERIFY must not be enabled in production");
}
const smtpRejectUnauthorized = parseBoolean(env.get("AUTH_SMTP_TLS_REJECT_UNAUTHORIZED"));
if (smtpRejectUnauthorized === false) {
errors.push("AUTH_SMTP_TLS_REJECT_UNAUTHORIZED must not be false in production");
}
const tlsEmail = String(env.get("TLS_EMAIL") || "").trim();
if (!isLikelyEmail(tlsEmail)) {
errors.push("TLS_EMAIL must be a valid email address for ACME certificate registration");
}
const authMailFrom = String(env.get("AUTH_MAIL_FROM") || "").trim();
if (!isLikelyEmail(authMailFrom)) {
errors.push("AUTH_MAIL_FROM must be a valid sender email address");
}
if (String(env.get("BETTER_AUTH_SECRET") || "") === String(env.get("MAIL_ENCRYPTION_KEY") || "")) {
errors.push("BETTER_AUTH_SECRET and MAIL_ENCRYPTION_KEY must be different secrets");
}
if (publicDomain === "localhost") {
warnings.push("PUBLIC_DOMAIN is localhost; use a real domain for external production traffic.");
}
if (warnings.length) {
for (const warning of warnings) {
console.warn(`[warn] ${warning}`);
}
}
if (errors.length) {
for (const error of errors) {
console.error(`[error] ${error}`);
}
process.exit(1);
}
console.log(`[ok] ${targetPath} passed production environment validation.`);
}
void main();