From affd5dad4584438f7dd141c75d119faf18e83663 Mon Sep 17 00:00:00 2001 From: Joe Wesch Date: Sat, 2 May 2026 17:54:47 -0500 Subject: [PATCH] Changed filenames to include first domain or inbound port --- backend/internal/nginx.js | 137 +++++++--- backend/internal/nginx.test.js | 55 ++++ backend/lib/nginx_host_logs.js | 340 ++++++++++++++++++++++++ backend/lib/nginx_host_logs.test.js | 218 +++++++++++++++ backend/lib/utils.js | 161 ++++++++++- backend/lib/utils.test.js | 112 ++++++++ backend/package.json | 4 +- backend/scripts/migrate-host-logs.js | 98 +++++++ backend/templates/dead_host.conf | 4 +- backend/templates/proxy_host.conf | 4 +- backend/templates/redirection_host.conf | 4 +- backend/templates/stream.conf | 8 +- docs/src/upgrading/index.md | 4 + scripts/ci/test-and-build | 2 +- 14 files changed, 1105 insertions(+), 46 deletions(-) create mode 100644 backend/internal/nginx.test.js create mode 100644 backend/lib/nginx_host_logs.js create mode 100644 backend/lib/nginx_host_logs.test.js create mode 100644 backend/lib/utils.test.js create mode 100644 backend/scripts/migrate-host-logs.js diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..fb502e7808 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -1,8 +1,9 @@ import fs from "node:fs"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import _ from "lodash"; import errs from "../lib/error.js"; +import { DEFAULT_LOG_DIR, logPrefixForHostType, renameStaleLogsToCurrent } from "../lib/nginx_host_logs.js"; import utils from "../lib/utils.js"; import { debug, nginx as logger } from "../logger.js"; @@ -15,7 +16,7 @@ const internalNginx = { * - test the nginx config first to make sure it's OK * - create / recreate the config for the host * - test again - * - IF OK: update the meta with online status + * - IF OK: update the meta with online status and rename stale log filenames * - IF BAD: update the meta with offline status and remove the config entirely * - then reload nginx * @@ -34,7 +35,7 @@ const internalNginx = { // We're deleting this config regardless. // Don't throw errors, as the file may not exist at all // Delete the .err file too - return internalNginx.deleteConfig(host_type, host, false, true); + return internalNginx.deleteConfig(host_type, host, false); }) .then(() => { return internalNginx.generateConfig(host_type, host); @@ -50,9 +51,15 @@ const internalNginx = { nginx_err: null, }); - return model.query().where("id", host.id).patch({ - meta: combined_meta, - }); + return model + .query() + .where("id", host.id) + .patch({ + meta: combined_meta, + }) + .then(() => { + internalNginx.renameStaleLogsAfterConfigWrite(host_type, host); + }); }) .catch((err) => { // Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported. @@ -117,15 +124,56 @@ const internalNginx = { }, /** - * @param {String} host_type - * @param {Integer} host_id + * @param {String} nice_host_type Already file-friendly (e.g. proxy_host) + * @param {Object} [host] Required unless nice_host_type is "default" * @returns {String} */ - getConfigName: (host_type, host_id) => { - if (host_type === "default") { + getConfigName: (nice_host_type, host) => { + if (nice_host_type === "default") { return "/data/nginx/default_host/site.conf"; } - return `/data/nginx/${internalNginx.getFileFriendlyHostType(host_type)}/${host_id}.conf`; + const basename = utils.getNginxFileStem(nice_host_type, host); + return `/data/nginx/${nice_host_type}/${basename}.conf`; + }, + + /** + * Removes all nginx config files for a host id in a type directory (id.conf, id.*.conf, and .err variants). + * + * @param {String} nice_host_type + * @param {Number} host_id + * @param {Boolean} delete_err_file + */ + deleteDiskConfigsForHostId: (nice_host_type, host_id, delete_err_file) => { + if (nice_host_type === "default" || !host_id) { + return; + } + const dir = `/data/nginx/${nice_host_type}`; + if (!fs.existsSync(dir)) { + return; + } + const names = fs.readdirSync(dir); + for (const name of utils.diskConfigFilenamesToDelete(names, host_id, delete_err_file)) { + internalNginx.deleteFile(join(dir, name)); + } + }, + + /** + * Removes legacy Lets Encrypt temp configs for a certificate id (letsencrypt_{id}.conf and letsencrypt_{id}.*.conf). + * + * @param {Number} certificate_id + */ + deleteLetsEncryptTempConfigsForId: (certificate_id) => { + if (!certificate_id) { + return; + } + const dir = "/data/nginx/temp"; + if (!fs.existsSync(dir)) { + return; + } + const names = fs.readdirSync(dir); + for (const name of utils.letsencryptTempConfigFilenamesToDelete(names, certificate_id)) { + internalNginx.deleteFile(join(dir, name)); + } }, /** @@ -196,7 +244,10 @@ const internalNginx = { return new Promise((resolve, reject) => { let template = null; - const filename = internalNginx.getConfigName(nice_host_type, host.id); + + host.nginx_file_stem = utils.getNginxFileStem(nice_host_type, host); + + const filename = internalNginx.getConfigName(nice_host_type, host); try { template = fs.readFileSync(`${__dirname}/../templates/${nice_host_type}.conf`, { encoding: "utf8" }); @@ -275,7 +326,9 @@ const internalNginx = { return new Promise((resolve, reject) => { let template = null; - const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`; + internalNginx.deleteLetsEncryptTempConfigsForId(certificate.id); + const basename = utils.getLetsEncryptTempConfigBasename(certificate); + const filename = `/data/nginx/temp/${basename}.conf`; try { template = fs.readFileSync(`${__dirname}/../templates/letsencrypt-request.conf`, { encoding: "utf8" }); @@ -333,9 +386,8 @@ const internalNginx = { * @returns {Promise} */ deleteLetsEncryptRequestConfig: (certificate) => { - const config_file = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`; return new Promise((resolve /*, reject*/) => { - internalNginx.deleteFile(config_file); + internalNginx.deleteLetsEncryptTempConfigsForId(certificate.id); resolve(); }); }, @@ -347,17 +399,20 @@ const internalNginx = { * @returns {Promise} */ deleteConfig: (host_type, host, delete_err_file) => { - const config_file = internalNginx.getConfigName( - internalNginx.getFileFriendlyHostType(host_type), - typeof host === "undefined" ? 0 : host.id, - ); - const config_file_err = `${config_file}.err`; + const nice = internalNginx.getFileFriendlyHostType(host_type); return new Promise((resolve /*, reject*/) => { - internalNginx.deleteFile(config_file); - if (delete_err_file) { - internalNginx.deleteFile(config_file_err); + if (nice === "default") { + internalNginx.deleteFile("/data/nginx/default_host/site.conf"); + if (delete_err_file) { + internalNginx.deleteFile("/data/nginx/default_host/site.conf.err"); + } + resolve(); + return; } + + const hostId = host && typeof host.id !== "undefined" ? host.id : 0; + internalNginx.deleteDiskConfigsForHostId(nice, hostId, delete_err_file); resolve(); }); }, @@ -368,23 +423,39 @@ const internalNginx = { * @returns {Promise} */ renameConfigAsError: (host_type, host) => { - const config_file = internalNginx.getConfigName( - internalNginx.getFileFriendlyHostType(host_type), - typeof host === "undefined" ? 0 : host.id, - ); + const nice = internalNginx.getFileFriendlyHostType(host_type); + const config_file = internalNginx.getConfigName(nice, host); const config_file_err = `${config_file}.err`; return new Promise((resolve /*, reject*/) => { - fs.unlink(config_file, () => { - // ignore result, continue - fs.rename(config_file, config_file_err, () => { - // also ignore result, as this is a debugging informative file anyway - resolve(); - }); + fs.rename(config_file, config_file_err, () => { + // ignore result, as this is a debugging informative file anyway + resolve(); }); }); }, + /** + * Align /data/logs filenames with the current nginx stem after a successful config write (same rules as templates). + * + * @param {String} nice_host_type e.g. proxy_host + * @param {Object} host + */ + renameStaleLogsAfterConfigWrite: (nice_host_type, host) => { + if (!host || typeof host.id === "undefined" || logPrefixForHostType(nice_host_type) === null) { + return; + } + try { + renameStaleLogsToCurrent(process.env.NPM_LOG_DIR || DEFAULT_LOG_DIR, nice_host_type, host, { + dryRun: false, + }); + } catch (err) { + logger.warn( + `Stale host log rename (${nice_host_type} #${host.id}): ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, + /** * @param {String} hostType * @param {Array} hosts diff --git a/backend/internal/nginx.test.js b/backend/internal/nginx.test.js new file mode 100644 index 0000000000..b8d30cf9d6 --- /dev/null +++ b/backend/internal/nginx.test.js @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getNginxFileStem } from "../lib/utils.js"; +import internalNginx from "./nginx.js"; + +describe("internalNginx.getConfigName", () => { + it("returns default host path unchanged", () => { + assert.equal(internalNginx.getConfigName("default", {}), "/data/nginx/default_host/site.conf"); + }); + + it("returns id-only basename when HTTP host has no domains", () => { + assert.equal( + internalNginx.getConfigName("proxy_host", { id: 10, domain_names: [] }), + "/data/nginx/proxy_host/10.conf", + ); + }); + + it("returns id.domain basename when HTTP host has domains", () => { + assert.equal( + internalNginx.getConfigName("proxy_host", { id: 5, domain_names: ["www.TEST.example"] }), + "/data/nginx/proxy_host/5.www.test.example.conf", + ); + }); + + it("returns id.port basename for streams", () => { + assert.equal( + internalNginx.getConfigName("stream", { id: 2, incoming_port: 9000 }), + "/data/nginx/stream/2.9000.conf", + ); + }); + + it("falls back to id only for unknown host types", () => { + assert.equal( + internalNginx.getConfigName("future_host_kind", { id: 42, domain_names: ["ignored.example"] }), + "/data/nginx/future_host_kind/42.conf", + ); + }); + + it("config path uses the same stem as utils.getNginxFileStem", () => { + const nice = "proxy_host"; + const host = { id: 7, domain_names: ["sync.test"] }; + const stem = getNginxFileStem(nice, host); + assert.equal(internalNginx.getConfigName(nice, host), `/data/nginx/${nice}/${stem}.conf`); + }); +}); + +describe("internalNginx.renameStaleLogsAfterConfigWrite", () => { + it("no-ops for host kinds without per-host logs", () => { + assert.doesNotThrow(() => internalNginx.renameStaleLogsAfterConfigWrite("default", { id: 1 })); + }); + + it("no-ops when host id is missing", () => { + assert.doesNotThrow(() => internalNginx.renameStaleLogsAfterConfigWrite("proxy_host", {})); + }); +}); diff --git a/backend/lib/nginx_host_logs.js b/backend/lib/nginx_host_logs.js new file mode 100644 index 0000000000..5abb142a93 --- /dev/null +++ b/backend/lib/nginx_host_logs.js @@ -0,0 +1,340 @@ +/** + * Per-host nginx access/error log paths under /data/logs (see templates proxy_host.conf, etc.). + * Migrate stale filenames when the canonical stem from {@link getNginxFileStem} changes. + * + * After each successful `internal/nginx.js` `configure()` for a host, stale names are renamed to match the new stem. + * Optional bulk tool: `scripts/migrate-host-logs.js`. + */ + +import fs from "node:fs"; +import { join } from "node:path"; +import { getNginxFileStem } from "./utils.js"; + +const DEFAULT_LOG_DIR = "/data/logs"; + +/** @type {Record} */ +const LOG_PREFIX_BY_TYPE = { + proxy_host: "proxy-host", + redirection_host: "redirection-host", + dead_host: "dead-host", + stream: "stream", +}; + +/** + * @param {String} str + * @returns {String} + */ +const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * @param {String} nice_host_type + * @returns {String|null} + */ +const logPrefixForHostType = (nice_host_type) => LOG_PREFIX_BY_TYPE[nice_host_type] ?? null; + +/** + * Active log paths (no rotated suffixes) for a stem. + * + * @param {String} logsDir + * @param {String} prefix e.g. proxy-host + * @param {String} stem + * @returns {{ access: String, error: String }} + */ +const logFilenamesForStem = (logsDir, prefix, stem) => { + const base = `${prefix}-${stem}`; + return { + access: join(logsDir, `${base}_access.log`), + error: join(logsDir, `${base}_error.log`), + }; +}; + +/** + * Canonical active log paths for the current host row (matches nginx templates). + * + * @param {String} logsDir + * @param {String} nice_host_type + * @param {Object} host + * @returns {{ access: String, error: String }|null} + */ +const currentLogPaths = (logsDir, nice_host_type, host) => { + const prefix = logPrefixForHostType(nice_host_type); + if (!prefix) { + return null; + } + const stem = getNginxFileStem(nice_host_type, host); + return logFilenamesForStem(logsDir, prefix, stem); +}; + +/** + * @param {String} stem + * @param {Number} hostId + * @returns {Boolean} + */ +const stemBelongsToHostId = (stem, hostId) => { + const idStr = String(hostId); + return stem === idStr || stem.startsWith(`${idStr}.`); +}; + +/** + * @param {String} prefix + * @param {String} filename basename only + * @returns {{ stem: String, kind: String, suffix: String }|null} + */ +const parseHostLogFilename = (prefix, filename) => { + const re = new RegExp(`^${escapeRegex(prefix)}-(.+)_(access|error)\\.log(.*)$`); + const m = filename.match(re); + if (!m) { + return null; + } + return { stem: m[1], kind: m[2], suffix: m[3] ?? "" }; +}; + +/** + * @param {String} logsDir + * @param {String} prefix + * @param {String} stem + * @param {String} kind access|error + * @param {String} suffix e.g. "" | ".1" | ".2.gz" + * @returns {String} + */ +const logPathForParts = (logsDir, prefix, stem, kind, suffix) => + join(logsDir, `${prefix}-${stem}_${kind}.log${suffix}`); + +/** + * Nginx (or `nginx -t`) may create empty log files at the new paths before we rename; remove only 0-byte files. + * + * @param {String} dest + * @param {Boolean} dryRun + * @returns {Boolean} true if rename may proceed (no dest, dest removed as empty, or dry-run over empty dest) + */ +const clearEmptyLogPlaceholder = (dest, dryRun) => { + if (!fs.existsSync(dest)) { + return true; + } + let st; + try { + st = fs.statSync(dest); + } catch { + return true; + } + if (!st.isFile() || st.size !== 0) { + return false; + } + if (!dryRun) { + fs.unlinkSync(dest); + } + return true; +}; + +/** + * Max mtime among paths that exist. + * + * @param {String[]} paths + * @returns {Number} + */ +const maxMtimeMs = (paths) => { + let max = 0; + for (const p of paths) { + try { + const st = fs.statSync(p); + const t = st.mtimeMs; + if (t > max) { + max = t; + } + } catch { + // ignore missing + } + } + return max; +}; + +/** + * List orphan log files for this host (stem ≠ current canonical stem). + * + * @param {String} logsDir + * @param {String} nice_host_type + * @param {Object} host + * @returns {{ path: String, stem: String, kind: String, suffix: String }[]} + */ +const listStaleLogFilesForHost = (logsDir, nice_host_type, host) => { + const prefix = logPrefixForHostType(nice_host_type); + if (!prefix || !host || typeof host.id === "undefined") { + return []; + } + const currentStem = getNginxFileStem(nice_host_type, host); + const hostId = host.id; + + let entries; + try { + entries = fs.readdirSync(logsDir); + } catch { + return []; + } + + const stale = []; + for (const name of entries) { + const parsed = parseHostLogFilename(prefix, name); + if (!parsed) { + continue; + } + if (!stemBelongsToHostId(parsed.stem, hostId)) { + continue; + } + if (parsed.stem === currentStem) { + continue; + } + stale.push({ + path: join(logsDir, name), + stem: parsed.stem, + kind: parsed.kind, + suffix: parsed.suffix, + }); + } + return stale; +}; + +/** + * Group stale entries by stem string. + * + * @param {{ path: String, stem: String }[]} entries + * @returns {Map} + */ +const groupStaleByStem = (entries) => { + /** @type {Map} */ + const map = new Map(); + for (const e of entries) { + const list = map.get(e.stem) ?? []; + list.push(e); + map.set(e.stem, list); + } + return map; +}; + +/** + * @param {Map} stemGroups + * @returns {String|null} stem with newest max mtime + */ +const newestStaleStemByMtime = (stemGroups) => { + let bestStem = null; + let bestMax = -1; + for (const [stem, files] of stemGroups) { + const mx = maxMtimeMs(files.map((f) => f.path)); + if (mx > bestMax) { + bestMax = mx; + bestStem = stem; + } + } + return bestStem; +}; + +/** + * Rename stale per-host logs onto the current canonical stem; multi-stem policy deletes older stems. + * + * @param {String} logsDir + * @param {String} nice_host_type + * @param {Object} host + * @param {{ dryRun?: Boolean }} [options] + * @returns {{ + * dryRun: Boolean, + * deleted: String[], + * renamed: { from: String, to: String }[], + * skipped: { from: String, to: String, reason: String }[], + * }} + */ +const renameStaleLogsToCurrent = (logsDir, nice_host_type, host, options = {}) => { + const dryRun = Boolean(options.dryRun); + const prefix = logPrefixForHostType(nice_host_type); + const deleted = []; + const renamed = []; + const skipped = []; + + if (!prefix || !host || typeof host.id === "undefined") { + return { dryRun, deleted, renamed, skipped }; + } + + const currentStem = getNginxFileStem(nice_host_type, host); + const staleEntries = listStaleLogFilesForHost(logsDir, nice_host_type, host); + if (!staleEntries.length) { + return { dryRun, deleted, renamed, skipped }; + } + + const stemGroups = groupStaleByStem(staleEntries); + const stems = [...stemGroups.keys()]; + + if (stems.length === 1) { + const onlyStem = stems[0]; + for (const entry of stemGroups.get(onlyStem)) { + const dest = logPathForParts(logsDir, prefix, currentStem, entry.kind, entry.suffix); + if (entry.path === dest) { + continue; + } + try { + if (fs.existsSync(dest) && !clearEmptyLogPlaceholder(dest, dryRun)) { + skipped.push({ from: entry.path, to: dest, reason: "target_exists_nonempty" }); + continue; + } + if (!dryRun) { + fs.renameSync(entry.path, dest); + } + renamed.push({ from: entry.path, to: dest }); + } catch (err) { + skipped.push({ from: entry.path, to: dest, reason: err instanceof Error ? err.message : String(err) }); + } + } + return { dryRun, deleted, renamed, skipped }; + } + + const newestStem = newestStaleStemByMtime(stemGroups); + for (const stem of stems) { + if (stem === newestStem) { + continue; + } + for (const entry of stemGroups.get(stem)) { + try { + if (!dryRun && fs.existsSync(entry.path)) { + fs.unlinkSync(entry.path); + } + deleted.push(entry.path); + } catch (err) { + skipped.push({ + from: entry.path, + to: "", + reason: `delete:${err instanceof Error ? err.message : String(err)}`, + }); + } + } + } + + for (const entry of stemGroups.get(newestStem)) { + const dest = logPathForParts(logsDir, prefix, currentStem, entry.kind, entry.suffix); + if (entry.path === dest) { + continue; + } + try { + if (fs.existsSync(dest) && !clearEmptyLogPlaceholder(dest, dryRun)) { + skipped.push({ from: entry.path, to: dest, reason: "target_exists_nonempty" }); + continue; + } + if (!dryRun) { + fs.renameSync(entry.path, dest); + } + renamed.push({ from: entry.path, to: dest }); + } catch (err) { + skipped.push({ from: entry.path, to: dest, reason: err instanceof Error ? err.message : String(err) }); + } + } + + return { dryRun, deleted, renamed, skipped }; +}; + +export { + LOG_PREFIX_BY_TYPE, + DEFAULT_LOG_DIR, + logPrefixForHostType, + logFilenamesForStem, + currentLogPaths, + stemBelongsToHostId, + parseHostLogFilename, + listStaleLogFilesForHost, + renameStaleLogsToCurrent, +}; diff --git a/backend/lib/nginx_host_logs.test.js b/backend/lib/nginx_host_logs.test.js new file mode 100644 index 0000000000..4232a07f85 --- /dev/null +++ b/backend/lib/nginx_host_logs.test.js @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { getNginxFileStem } from "./utils.js"; +import { + LOG_PREFIX_BY_TYPE, + currentLogPaths, + listStaleLogFilesForHost, + logFilenamesForStem, + logPrefixForHostType, + parseHostLogFilename, + renameStaleLogsToCurrent, + stemBelongsToHostId, +} from "./nginx_host_logs.js"; + +const mkTempLogsDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "npm-host-logs-")); + +describe("logPrefixForHostType", () => { + it("maps known host kinds to nginx log filename prefixes", () => { + assert.equal(logPrefixForHostType("proxy_host"), "proxy-host"); + assert.equal(logPrefixForHostType("redirection_host"), "redirection-host"); + assert.equal(logPrefixForHostType("dead_host"), "dead-host"); + assert.equal(logPrefixForHostType("stream"), "stream"); + }); + + it("returns null for unknown kinds", () => { + assert.equal(logPrefixForHostType("unknown"), null); + }); + + it("matches LOG_PREFIX_BY_TYPE keys", () => { + assert.deepEqual(LOG_PREFIX_BY_TYPE, { + proxy_host: "proxy-host", + redirection_host: "redirection-host", + dead_host: "dead-host", + stream: "stream", + }); + }); +}); + +describe("logFilenamesForStem / currentLogPaths", () => { + it("builds active path pair from prefix and stem", () => { + const dir = "/data/logs"; + assert.deepEqual(logFilenamesForStem(dir, "proxy-host", "5.app.example.com"), { + access: path.join(dir, "proxy-host-5.app.example.com_access.log"), + error: path.join(dir, "proxy-host-5.app.example.com_error.log"), + }); + }); + + it("matches getNginxFileStem for proxy and stream rows", () => { + const dir = mkTempLogsDir(); + // Basename uses domain_names[0] as stored (DB rows are sorted on save). + const proxyRow = { id: 2, domain_names: ["a.example.com", "z.example.com"] }; + const stem = getNginxFileStem("proxy_host", proxyRow); + assert.equal(stem, "2.a.example.com"); + const paths = currentLogPaths(dir, "proxy_host", proxyRow); + assert.deepEqual(paths, logFilenamesForStem(dir, "proxy-host", stem)); + + const streamRow = { id: 3, incoming_port: 443 }; + const sStem = getNginxFileStem("stream", streamRow); + const sPaths = currentLogPaths(dir, "stream", streamRow); + assert.deepEqual(sPaths, logFilenamesForStem(dir, "stream", sStem)); + }); +}); + +describe("stemBelongsToHostId", () => { + it("accepts id-only and id.slug stems for the numeric id", () => { + assert.equal(stemBelongsToHostId("5", 5), true); + assert.equal(stemBelongsToHostId("5.foo.com", 5), true); + }); + + it("does not treat 51 as belonging to host 5", () => { + assert.equal(stemBelongsToHostId("51", 5), false); + assert.equal(stemBelongsToHostId("51", 51), true); + }); +}); + +describe("parseHostLogFilename", () => { + it("parses active logs and rotation / gzip suffixes", () => { + assert.deepEqual(parseHostLogFilename("proxy-host", "proxy-host-5_access.log"), { + stem: "5", + kind: "access", + suffix: "", + }); + assert.deepEqual(parseHostLogFilename("proxy-host", "proxy-host-5.app_access.log.1"), { + stem: "5.app", + kind: "access", + suffix: ".1", + }); + assert.deepEqual(parseHostLogFilename("proxy-host", "proxy-host-5_error.log.2.gz"), { + stem: "5", + kind: "error", + suffix: ".2.gz", + }); + }); + + it("returns null for unrelated names", () => { + assert.equal(parseHostLogFilename("proxy-host", "default-host_access.log"), null); + assert.equal(parseHostLogFilename("proxy-host", "proxy-host-5.json"), null); + }); +}); + +describe("listStaleLogFilesForHost", () => { + it("lists stale stems and rotation files; excludes current stem and other host ids", () => { + const dir = mkTempLogsDir(); + const host = { id: 5, domain_names: ["keep.example.com"] }; + const current = getNginxFileStem("proxy_host", host); + + fs.writeFileSync(path.join(dir, `proxy-host-${current}_access.log`), ""); + fs.writeFileSync(path.join(dir, "proxy-host-5_access.log"), ""); + fs.writeFileSync(path.join(dir, "proxy-host-5_access.log.1"), ""); + fs.writeFileSync(path.join(dir, "proxy-host-51_access.log"), ""); + + const stale = listStaleLogFilesForHost(dir, "proxy_host", host); + const stems = new Set(stale.map((e) => e.stem)); + assert.ok(stems.has("5")); + assert.ok(!stems.has("51")); + assert.equal(stale.filter((e) => e.suffix === ".1").length, 1); + }); + + it("returns empty when log dir is missing", () => { + const stale = listStaleLogFilesForHost("/nonexistent/npm-logs-xyz", "proxy_host", { + id: 1, + domain_names: [], + }); + assert.equal(stale.length, 0); + }); +}); + +describe("renameStaleLogsToCurrent", () => { + it("renames a single stale stem family onto the canonical stem", () => { + const dir = mkTempLogsDir(); + const host = { id: 7, domain_names: ["new.example.org"] }; + const canon = getNginxFileStem("proxy_host", host); + + fs.writeFileSync(path.join(dir, "proxy-host-7_access.log"), "a"); + fs.writeFileSync(path.join(dir, "proxy-host-7_access.log.1"), "b"); + fs.writeFileSync(path.join(dir, "proxy-host-7_error.log"), "c"); + + const r = renameStaleLogsToCurrent(dir, "proxy_host", host, { dryRun: false }); + assert.equal(r.renamed.length, 3); + assert.equal(r.deleted.length, 0); + + assert.ok(fs.existsSync(path.join(dir, `proxy-host-${canon}_access.log`))); + assert.ok(fs.existsSync(path.join(dir, `proxy-host-${canon}_access.log.1`))); + assert.ok(fs.existsSync(path.join(dir, `proxy-host-${canon}_error.log`))); + assert.equal(fs.readFileSync(path.join(dir, `proxy-host-${canon}_access.log`), "utf8"), "a"); + }); + + it("with multiple stale stems, keeps newest by mtime and deletes older stems", () => { + const dir = mkTempLogsDir(); + const host = { id: 4, domain_names: ["zzz.example.net"] }; + const canon = getNginxFileStem("proxy_host", host); + assert.equal(canon, "4.zzz.example.net"); + + const oldStemPath = path.join(dir, "proxy-host-4_access.log"); + const newerStalePath = path.join(dir, "proxy-host-4.prev.example.net_access.log"); + fs.writeFileSync(oldStemPath, "older-stem"); + fs.writeFileSync(newerStalePath, "newer-stem"); + const tOld = new Date("2020-01-01T00:00:00Z"); + const tNew = new Date("2024-06-01T00:00:00Z"); + fs.utimesSync(oldStemPath, tOld, tOld); + fs.utimesSync(newerStalePath, tNew, tNew); + + const r = renameStaleLogsToCurrent(dir, "proxy_host", host, { dryRun: false }); + assert.ok(r.deleted.some((p) => p.endsWith("proxy-host-4_access.log"))); + assert.ok(r.renamed.some(({ to }) => to === path.join(dir, `proxy-host-${canon}_access.log`))); + assert.ok(!fs.existsSync(oldStemPath)); + assert.ok(!fs.existsSync(newerStalePath)); + assert.ok(fs.existsSync(path.join(dir, `proxy-host-${canon}_access.log`))); + assert.equal(fs.readFileSync(path.join(dir, `proxy-host-${canon}_access.log`), "utf8"), "newer-stem"); + }); + + it("dryRun does not rename or delete", () => { + const dir = mkTempLogsDir(); + const host = { id: 9, domain_names: ["x.example"] }; + const p = path.join(dir, "proxy-host-9_access.log"); + fs.writeFileSync(p, "z"); + + const before = fs.statSync(p).mtimeMs; + const r = renameStaleLogsToCurrent(dir, "proxy_host", host, { dryRun: true }); + assert.ok(r.renamed.length > 0); + assert.equal(fs.statSync(p).mtimeMs, before); + assert.ok(fs.existsSync(p)); + }); + + it("skips rename when destination already exists with content", () => { + const dir = mkTempLogsDir(); + const host = { id: 3, domain_names: ["d.example"] }; + const canon = getNginxFileStem("proxy_host", host); + const dest = path.join(dir, `proxy-host-${canon}_access.log`); + const src = path.join(dir, "proxy-host-3_access.log"); + fs.writeFileSync(dest, "dest"); + fs.writeFileSync(src, "src"); + + const r = renameStaleLogsToCurrent(dir, "proxy_host", host, { dryRun: false }); + assert.ok(r.skipped.some((s) => s.reason === "target_exists_nonempty")); + assert.equal(fs.readFileSync(dest, "utf8"), "dest"); + assert.ok(fs.existsSync(src)); + }); + + it("removes empty destination placeholder then renames (nginx may create the new path first)", () => { + const dir = mkTempLogsDir(); + const host = { id: 3, domain_names: ["d.example"] }; + const canon = getNginxFileStem("proxy_host", host); + const dest = path.join(dir, `proxy-host-${canon}_access.log`); + const src = path.join(dir, "proxy-host-3_access.log"); + fs.writeFileSync(dest, ""); + fs.writeFileSync(src, "migrated"); + + const r = renameStaleLogsToCurrent(dir, "proxy_host", host, { dryRun: false }); + assert.equal(r.skipped.length, 0); + assert.ok(r.renamed.some(({ to }) => to === dest)); + assert.equal(fs.readFileSync(dest, "utf8"), "migrated"); + assert.ok(!fs.existsSync(src)); + }); +}); diff --git a/backend/lib/utils.js b/backend/lib/utils.js index af7ad3c952..ba3b19ff37 100644 --- a/backend/lib/utils.js +++ b/backend/lib/utils.js @@ -107,4 +107,163 @@ const getRenderEngine = () => { return renderEngine; }; -export default { exec, execFile, omitRow, omitRows, getRenderEngine }; +const MAX_DOMAIN_FILENAME_LENGTH = 200; + +/** + * Sanitize a domain label for use in nginx config / log filenames. + * Invalid or empty input yields "" (callers fall back to id-only basenames). + * + * @param {String} domain + * @returns {String} + */ +const sanitizeDomainForFilename = (domain) => { + if (!domain || typeof domain !== "string") { + return ""; + } + let s = domain.trim().toLowerCase(); + s = s.replace(/[^a-z0-9.-]+/g, "_"); + s = s.replace(/_+/g, "_"); + s = s.replace(/^\.+|\.+$/g, ""); + if (s.length > MAX_DOMAIN_FILENAME_LENGTH) { + s = s.slice(0, MAX_DOMAIN_FILENAME_LENGTH); + s = s.replace(/[._]+$/g, ""); + } + return s || ""; +}; + +/** + * Basename (no .conf) for proxy / redirection / dead hosts: id, or id.first-domain when domain_names is non-empty. + * + * @param {Object} host + * @returns {String} + */ +const getNginxFileBasenameForDomainHost = (host) => { + const id = host.id; + const domains = host.domain_names; + if (Array.isArray(domains) && domains.length > 0) { + const slug = sanitizeDomainForFilename(domains[0]); + if (!slug) { + return String(id); + } + return `${id}.${slug}`; + } + return String(id); +}; + +/** + * Basename (no .conf) for stream hosts: id.incoming_port + * + * @param {Object} host + * @returns {String} + */ +const getNginxFileBasenameForStream = (host) => { + const id = host.id; + const port = host.incoming_port; + if (port === undefined || port === null || port === "") { + return String(id); + } + return `${id}.${port}`; +}; + +/** + * Stem used in nginx config paths, log filenames, and Liquid `nginx_file_stem`. + * + * @param {String} nice_host_type e.g. proxy_host, stream (not default_host path — still used for template vars) + * @param {Object} [host] + * @returns {String} + */ +const getNginxFileStem = (nice_host_type, host) => { + if (nice_host_type === "default") { + return host && typeof host.id !== "undefined" ? String(host.id) : ""; + } + if (nice_host_type === "stream") { + return getNginxFileBasenameForStream(host); + } + if ( + nice_host_type === "proxy_host" || + nice_host_type === "redirection_host" || + nice_host_type === "dead_host" + ) { + return getNginxFileBasenameForDomainHost(host); + } + return host && typeof host.id !== "undefined" ? String(host.id) : ""; +}; + +/** + * Temp Lets Encrypt nginx config basename (no .conf), under /data/nginx/temp/ + * + * @param {Object} certificate + * @returns {String} + */ +const getLetsEncryptTempConfigBasename = (certificate) => { + const id = certificate.id; + const domains = certificate.domain_names; + if (Array.isArray(domains) && domains.length > 0) { + const slug = sanitizeDomainForFilename(domains[0]); + if (!slug) { + return `letsencrypt_${id}`; + } + return `letsencrypt_${id}.${slug}`; + } + return `letsencrypt_${id}`; +}; + +/** + * Basenames under /data/nginx/{type}/ that should be deleted for a host id (used by tests and nginx cleanup). + * + * @param {String[]} dirEntries filenames only + * @param {Number} hostId + * @param {Boolean} deleteErrFile + * @returns {String[]} + */ +const diskConfigFilenamesToDelete = (dirEntries, hostId, deleteErrFile) => { + const prefix = `${hostId}.`; + return dirEntries.filter((name) => { + if (!name.startsWith(prefix)) { + return false; + } + if (name.endsWith(".conf.err")) { + return deleteErrFile; + } + if (name.endsWith(".conf")) { + return true; + } + return false; + }); +}; + +/** + * Basenames under /data/nginx/temp/ that match a certificate id. + * + * @param {String[]} dirEntries + * @param {Number} certificateId + * @returns {String[]} + */ +const letsencryptTempConfigFilenamesToDelete = (dirEntries, certificateId) => { + const prefix = `letsencrypt_${certificateId}.`; + return dirEntries.filter((name) => name.startsWith(prefix) && name.endsWith(".conf")); +}; + +export { + sanitizeDomainForFilename, + getNginxFileBasenameForDomainHost, + getNginxFileBasenameForStream, + getNginxFileStem, + getLetsEncryptTempConfigBasename, + diskConfigFilenamesToDelete, + letsencryptTempConfigFilenamesToDelete, +}; +export default { + exec, + execFile, + omitRow, + omitRows, + getRenderEngine, + sanitizeDomainForFilename, + getNginxFileBasenameForDomainHost, + getNginxFileBasenameForStream, + getNginxFileStem, + getLetsEncryptTempConfigBasename, + diskConfigFilenamesToDelete, + letsencryptTempConfigFilenamesToDelete, +}; diff --git a/backend/lib/utils.test.js b/backend/lib/utils.test.js new file mode 100644 index 0000000000..4e2fe9f1cd --- /dev/null +++ b/backend/lib/utils.test.js @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + diskConfigFilenamesToDelete, + getLetsEncryptTempConfigBasename, + getNginxFileBasenameForDomainHost, + getNginxFileBasenameForStream, + getNginxFileStem, + letsencryptTempConfigFilenamesToDelete, + sanitizeDomainForFilename, +} from "./utils.js"; + +describe("sanitizeDomainForFilename", () => { + it("returns empty string for non-strings", () => { + assert.equal(sanitizeDomainForFilename(""), ""); + assert.equal(sanitizeDomainForFilename(null), ""); + assert.equal(sanitizeDomainForFilename(undefined), ""); + }); + + it("lowercases and keeps dots and hyphens", () => { + assert.equal(sanitizeDomainForFilename("WWW.EXAMPLE.COM"), "www.example.com"); + assert.equal(sanitizeDomainForFilename("a-b.c"), "a-b.c"); + }); + + it("replaces unsafe characters with underscores", () => { + assert.equal(sanitizeDomainForFilename("*.example.com"), "_.example.com"); + assert.equal(sanitizeDomainForFilename("foo_bar.com"), "foo_bar.com"); + }); + + it("collapses punctuation to underscores", () => { + assert.equal(sanitizeDomainForFilename("@@@"), "_"); + }); +}); + +describe("getNginxFileBasenameForDomainHost", () => { + it("uses id only when domain_names is empty or missing", () => { + assert.equal(getNginxFileBasenameForDomainHost({ id: 12, domain_names: [] }), "12"); + assert.equal(getNginxFileBasenameForDomainHost({ id: 12 }), "12"); + }); + + it("uses id only when first domain sanitizes to empty", () => { + assert.equal(getNginxFileBasenameForDomainHost({ id: 3, domain_names: [""] }), "3"); + }); + + it("uses id.sanitized-first-domain when domain_names is non-empty", () => { + assert.equal( + getNginxFileBasenameForDomainHost({ id: 5, domain_names: ["app.example.com"] }), + "5.app.example.com", + ); + assert.equal( + getNginxFileBasenameForDomainHost({ id: 1, domain_names: ["x.test", "y.test"] }), + "1.x.test", + ); + }); +}); + +describe("getNginxFileBasenameForStream", () => { + it("uses id.incoming_port", () => { + assert.equal(getNginxFileBasenameForStream({ id: 9, incoming_port: 8443 }), "9.8443"); + }); + + it("uses id only when incoming_port is missing", () => { + assert.equal(getNginxFileBasenameForStream({ id: 9 }), "9"); + }); +}); + +describe("getNginxFileStem", () => { + it("matches basename logic for known types", () => { + assert.equal(getNginxFileStem("proxy_host", { id: 1, domain_names: ["a.example"] }), "1.a.example"); + assert.equal(getNginxFileStem("stream", { id: 2, incoming_port: 80 }), "2.80"); + assert.equal(getNginxFileStem("future_kind", { id: 99 }), "99"); + }); +}); + +describe("getLetsEncryptTempConfigBasename", () => { + it("uses letsencrypt_id when certificate has no domains", () => { + assert.equal(getLetsEncryptTempConfigBasename({ id: 4, domain_names: [] }), "letsencrypt_4"); + }); + + it("uses letsencrypt_id.slug when domains exist", () => { + assert.equal( + getLetsEncryptTempConfigBasename({ id: 4, domain_names: ["acme.example.org"] }), + "letsencrypt_4.acme.example.org", + ); + }); + + it("uses letsencrypt_id only when first domain sanitizes to empty", () => { + assert.equal(getLetsEncryptTempConfigBasename({ id: 4, domain_names: [""] }), "letsencrypt_4"); + }); +}); + +describe("diskConfigFilenamesToDelete", () => { + it("selects id.conf, id.suffix.conf, and optionally id.suffix.conf.err", () => { + const names = ["1.conf", "1.example.com.conf", "1.example.com.conf.err", "2.conf", "other.conf"]; + assert.deepEqual(diskConfigFilenamesToDelete(names, 1, false).sort(), ["1.conf", "1.example.com.conf"]); + assert.deepEqual(diskConfigFilenamesToDelete(names, 1, true).sort(), [ + "1.conf", + "1.example.com.conf", + "1.example.com.conf.err", + ]); + }); +}); + +describe("letsencryptTempConfigFilenamesToDelete", () => { + it("selects letsencrypt_id.conf and letsencrypt_id.slug.conf", () => { + const names = ["letsencrypt_3.conf", "letsencrypt_3.app.example.conf", "letsencrypt_4.conf"]; + assert.deepEqual(letsencryptTempConfigFilenamesToDelete(names, 3).sort(), [ + "letsencrypt_3.app.example.conf", + "letsencrypt_3.conf", + ]); + }); +}); diff --git a/backend/package.json b/backend/package.json index e31bf22325..ccd0b4f51d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,10 @@ "scripts": { "lint": "biome lint", "prettier": "biome format --write .", + "test": "node --test lib/*.test.js internal/*.test.js", "validate-schema": "node validate-schema.js", - "regenerate-config": "node scripts/regenerate-config" + "regenerate-config": "node scripts/regenerate-config", + "migrate-host-logs": "node scripts/migrate-host-logs.js" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.3.1", diff --git a/backend/scripts/migrate-host-logs.js b/backend/scripts/migrate-host-logs.js new file mode 100644 index 0000000000..34510b313c --- /dev/null +++ b/backend/scripts/migrate-host-logs.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Renames or removes stale per-host nginx log files under /data/logs when the canonical + * log stem (from the DB) no longer matches on-disk names. See lib/nginx_host_logs.js. + * + * Usage: + * node scripts/migrate-host-logs.js [--dry-run] [--yes] + * Env: + * NPM_LOG_DIR override log directory (default /data/logs) + * + * Host saves already rename stale logs after nginx config writes; use this for a full sweep without touching each host. + */ + +import * as process from "node:process"; +import { global as logger } from "../logger.js"; +import { DEFAULT_LOG_DIR, renameStaleLogsToCurrent } from "../lib/nginx_host_logs.js"; +import deadHostModel from "../models/dead_host.js"; +import proxyHostModel from "../models/proxy_host.js"; +import redirectionHostModel from "../models/redirection_host.js"; +import streamModel from "../models/stream.js"; + +const args = process.argv.slice(2); +const DRY_RUN = args.includes("--dry-run"); +const UNATTENDED = args.includes("-y") || args.includes("--yes"); +const LOG_DIR = process.env.NPM_LOG_DIR || DEFAULT_LOG_DIR; + +if (args.includes("--help") || args.includes("-h")) { + console.log(` +Migrate stale nginx access/error logs for proxy, redirection, dead, and stream hosts. + + --dry-run Print actions only; do not delete or rename files + -y, --yes Skip confirmation + NPM_LOG_DIR Log directory (default: ${DEFAULT_LOG_DIR}) + +Example: + node scripts/migrate-host-logs.js --dry-run + node scripts/migrate-host-logs.js -y +`); + process.exit(0); +} + +const logIt = (msg, type = "info") => logger[type](`${DRY_RUN ? "[DRY RUN] " : ""}${msg}`); + +if (!DRY_RUN && !UNATTENDED) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await new Promise((resolve) => { + rl.question( + "This will rename/delete log files under " + LOG_DIR + " to match current host stems.\nProceed? (y/N) ", + resolve, + ); + }); + rl.close(); + if (String(answer).toLowerCase() !== "y") { + console.log("Aborting."); + process.exit(0); + } +} + +let totalRenamed = 0; +let totalDeleted = 0; +let totalSkipped = 0; + +const processModel = async (model, niceType, label) => { + const rows = await model + .query() + .where("is_deleted", 0) + .groupBy("id") + .allowGraph(model.defaultAllowGraph) + .withGraphFetched(`[${model.defaultExpand.join(", ")}]`) + .orderBy(...model.defaultOrder); + + logIt(`[${label}] Processing ${rows.length} hosts...`); + + for (const row of rows) { + const result = renameStaleLogsToCurrent(LOG_DIR, niceType, row, { dryRun: DRY_RUN }); + totalRenamed += result.renamed.length; + totalDeleted += result.deleted.length; + totalSkipped += result.skipped.length; + + if (result.renamed.length || result.deleted.length || result.skipped.length) { + logIt( + `[${label}] host #${row.id}: renamed=${result.renamed.length} deleted=${result.deleted.length} skipped=${result.skipped.length}`, + ); + } + } +}; + +await processModel(proxyHostModel, "proxy_host", "proxy"); +await processModel(redirectionHostModel, "redirection_host", "redirection"); +await processModel(deadHostModel, "dead_host", "dead"); +await processModel(streamModel, "stream", "stream"); + +logIt(`Done. renamed=${totalRenamed} deleted=${totalDeleted} skipped=${totalSkipped}`, "success"); +process.exit(0); diff --git a/backend/templates/dead_host.conf b/backend/templates/dead_host.conf index 2e7d2a007a..e94f24d139 100644 --- a/backend/templates/dead_host.conf +++ b/backend/templates/dead_host.conf @@ -10,8 +10,8 @@ server { {% include "_hsts.conf" %} {% include "_forced_ssl.conf" %} - access_log /data/logs/dead-host-{{ id }}_access.log standard; - error_log /data/logs/dead-host-{{ id }}_error.log warn; + access_log /data/logs/dead-host-{{ nginx_file_stem | default: id }}_access.log standard; + error_log /data/logs/dead-host-{{ nginx_file_stem | default: id }}_error.log warn; {{ advanced_config }} diff --git a/backend/templates/proxy_host.conf b/backend/templates/proxy_host.conf index d23ca46fa2..fcb0a6bf6d 100644 --- a/backend/templates/proxy_host.conf +++ b/backend/templates/proxy_host.conf @@ -22,8 +22,8 @@ proxy_set_header Connection $http_connection; proxy_http_version 1.1; {% endif %} - access_log /data/logs/proxy-host-{{ id }}_access.log proxy; - error_log /data/logs/proxy-host-{{ id }}_error.log warn; + access_log /data/logs/proxy-host-{{ nginx_file_stem | default: id }}_access.log proxy; + error_log /data/logs/proxy-host-{{ nginx_file_stem | default: id }}_error.log warn; {{ advanced_config }} diff --git a/backend/templates/redirection_host.conf b/backend/templates/redirection_host.conf index 7dd360795f..03dd0bb48e 100644 --- a/backend/templates/redirection_host.conf +++ b/backend/templates/redirection_host.conf @@ -12,8 +12,8 @@ server { {% include "_hsts.conf" %} {% include "_forced_ssl.conf" %} - access_log /data/logs/redirection-host-{{ id }}_access.log standard; - error_log /data/logs/redirection-host-{{ id }}_error.log warn; + access_log /data/logs/redirection-host-{{ nginx_file_stem | default: id }}_access.log standard; + error_log /data/logs/redirection-host-{{ nginx_file_stem | default: id }}_error.log warn; {{ advanced_config }} diff --git a/backend/templates/stream.conf b/backend/templates/stream.conf index 3a10387b27..08babee676 100644 --- a/backend/templates/stream.conf +++ b/backend/templates/stream.conf @@ -12,8 +12,8 @@ server { proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; - access_log /data/logs/stream-{{ id }}_access.log stream; - error_log /data/logs/stream-{{ id }}_error.log warn; + access_log /data/logs/stream-{{ nginx_file_stem | default: id }}_access.log stream; + error_log /data/logs/stream-{{ nginx_file_stem | default: id }}_error.log warn; # Custom include /data/nginx/custom/server_stream[.]conf; @@ -28,8 +28,8 @@ server { proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; - access_log /data/logs/stream-{{ id }}_access.log stream; - error_log /data/logs/stream-{{ id }}_error.log warn; + access_log /data/logs/stream-{{ nginx_file_stem | default: id }}_access.log stream; + error_log /data/logs/stream-{{ nginx_file_stem | default: id }}_error.log warn; # Custom include /data/nginx/custom/server_stream[.]conf; diff --git a/docs/src/upgrading/index.md b/docs/src/upgrading/index.md index 21076d19eb..c667aa35d7 100644 --- a/docs/src/upgrading/index.md +++ b/docs/src/upgrading/index.md @@ -14,3 +14,7 @@ any crazy instructions. These steps above will pull the latest updates and recre containers. See the [list of releases](https://github.com/NginxProxyManager/nginx-proxy-manager/releases) for any upgrade steps specific to each release. + +## After Upgrading + +After upgrading to a new version of NGINX Proxy Manager, any config templates that have been modified will not be automatically applied to any existing hosts. This is to avoid a large number of hosts being re-generated on startup. For any hosts that you would like to update, you can simply edit the host in the UI and click the "Save" button without making any changes. This will trigger the new templates to be applied to the host as well as rename any applicable log files. diff --git a/scripts/ci/test-and-build b/scripts/ci/test-and-build index a0de34140a..ce5854d78f 100755 --- a/scripts/ci/test-and-build +++ b/scripts/ci/test-and-build @@ -12,7 +12,7 @@ docker run --rm \ -v "$(pwd)/backend:/app" \ -w /app \ "${TESTING_IMAGE}" \ - sh -c 'yarn install && yarn lint . && rm -rf node_modules' + sh -c 'yarn install && yarn lint . && yarn test && rm -rf node_modules' echo -e "${BLUE}❯ ${GREEN}Testing Complete${RESET}" # Build