Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 104 additions & 33 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
*
Expand All @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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));
}
},

/**
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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();
});
},
Expand All @@ -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();
});
},
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions backend/internal/nginx.test.js
Original file line number Diff line number Diff line change
@@ -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", {}));
});
});
Loading