mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-23 18:21:38 +01:00
When `nginx -t` fails, configure() is meant to move the broken config to <id>.conf.err so the failure can be inspected. renameConfigAsError() unlinked the source file before renaming it, so the rename always failed and the config was simply deleted. The deleteConfig() call after it then removed any .err file left over from an earlier failure. - unlink the destination .err file instead of the source - return the rename promise so the delete does not race it - pass delete_err_file = false so the new .err file survives - drop the stale 4th argument in the success path, which silently made delete_err_file false and left old .err files behind
443 lines
12 KiB
JavaScript
443 lines
12 KiB
JavaScript
import fs from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import _ from "lodash";
|
|
import errs from "../lib/error.js";
|
|
import utils from "../lib/utils.js";
|
|
import { debug, nginx as logger } from "../logger.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const internalNginx = {
|
|
/**
|
|
* This will:
|
|
* - 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 BAD: update the meta with offline status and remove the config entirely
|
|
* - then reload nginx
|
|
*
|
|
* @param {Object|String} model
|
|
* @param {String} host_type
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
configure: (model, host_type, host) => {
|
|
let combined_meta = {};
|
|
|
|
return internalNginx
|
|
.test()
|
|
.then(() => {
|
|
// Nginx is OK
|
|
// 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, true);
|
|
})
|
|
.then(() => {
|
|
return internalNginx.generateConfig(host_type, host);
|
|
})
|
|
.then(() => {
|
|
// Test nginx again and update meta with result
|
|
return internalNginx
|
|
.test()
|
|
.then(() => {
|
|
// nginx is ok
|
|
combined_meta = _.assign({}, host.meta, {
|
|
nginx_online: true,
|
|
nginx_err: null,
|
|
});
|
|
|
|
return model.query().where("id", host.id).patch({
|
|
meta: combined_meta,
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
// Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported.
|
|
// It will always look like this:
|
|
// nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address)
|
|
|
|
const valid_lines = [];
|
|
const err_lines = err.message.split("\n");
|
|
err_lines.map((line) => {
|
|
if (line.indexOf("/var/log/nginx/error.log") === -1) {
|
|
valid_lines.push(line);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
debug(logger, "Nginx test failed:", valid_lines.join("\n"));
|
|
|
|
// config is bad, update meta and delete config
|
|
combined_meta = _.assign({}, host.meta, {
|
|
nginx_online: false,
|
|
nginx_err: valid_lines.join("\n"),
|
|
});
|
|
|
|
return model
|
|
.query()
|
|
.where("id", host.id)
|
|
.patch({
|
|
meta: combined_meta,
|
|
})
|
|
.then(() => {
|
|
// Keep the failed config as a .err file for inspection
|
|
return internalNginx.renameConfigAsError(host_type, host);
|
|
})
|
|
.then(() => {
|
|
// The rename removed the live config already, don't touch the .err file
|
|
return internalNginx.deleteConfig(host_type, host, false);
|
|
});
|
|
});
|
|
})
|
|
.then(() => {
|
|
return internalNginx.reload();
|
|
})
|
|
.then(() => {
|
|
return combined_meta;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @returns {Promise}
|
|
*/
|
|
test: () => {
|
|
debug(logger, "Testing Nginx configuration");
|
|
return utils.execFile("/usr/sbin/nginx", ["-t", "-g", "error_log off;"]);
|
|
},
|
|
|
|
/**
|
|
* @returns {Promise}
|
|
*/
|
|
reload: () => {
|
|
return internalNginx.test().then(() => {
|
|
logger.info("Reloading Nginx");
|
|
return utils.execFile("/usr/sbin/nginx", ["-s", "reload"]);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Integer} host_id
|
|
* @returns {String}
|
|
*/
|
|
getConfigName: (host_type, host_id) => {
|
|
if (host_type === "default") {
|
|
return "/data/nginx/default_host/site.conf";
|
|
}
|
|
return `/data/nginx/${internalNginx.getFileFriendlyHostType(host_type)}/${host_id}.conf`;
|
|
},
|
|
|
|
/**
|
|
* Generates custom locations
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
renderLocations: (host) => {
|
|
return new Promise((resolve, reject) => {
|
|
let template;
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/_location.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
const renderEngine = utils.getRenderEngine();
|
|
let renderedLocations = "";
|
|
|
|
const locationRendering = async () => {
|
|
for (let i = 0; i < host.locations.length; i++) {
|
|
const locationCopy = Object.assign(
|
|
{},
|
|
{ access_list_id: host.access_list_id },
|
|
{ certificate_id: host.certificate_id },
|
|
{ ssl_forced: host.ssl_forced },
|
|
{ caching_enabled: host.caching_enabled },
|
|
{ block_exploits: host.block_exploits },
|
|
{ allow_websocket_upgrade: host.allow_websocket_upgrade },
|
|
{ http2_support: host.http2_support },
|
|
{ hsts_enabled: host.hsts_enabled },
|
|
{ hsts_subdomains: host.hsts_subdomains },
|
|
{ access_list: host.access_list },
|
|
{ certificate: host.certificate },
|
|
host.locations[i],
|
|
);
|
|
|
|
if (locationCopy.forward_host.indexOf("/") > -1) {
|
|
const splitted = locationCopy.forward_host.split("/");
|
|
|
|
locationCopy.forward_host = splitted.shift();
|
|
locationCopy.forward_path = `/${splitted.join("/")}`;
|
|
}
|
|
|
|
renderedLocations += await renderEngine.parseAndRender(template, locationCopy);
|
|
}
|
|
};
|
|
|
|
locationRendering().then(() => resolve(renderedLocations));
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} host
|
|
* @returns {Promise}
|
|
*/
|
|
generateConfig: (host_type, host_row) => {
|
|
// Prevent modifying the original object:
|
|
const host = JSON.parse(JSON.stringify(host_row));
|
|
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
|
|
|
|
debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2));
|
|
|
|
const renderEngine = utils.getRenderEngine();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let template = null;
|
|
const filename = internalNginx.getConfigName(nice_host_type, host.id);
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/${nice_host_type}.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
let locationsPromise;
|
|
let origLocations;
|
|
|
|
// Manipulate the data a bit before sending it to the template
|
|
if (nice_host_type !== "default") {
|
|
host.use_default_location = true;
|
|
if (typeof host.advanced_config !== "undefined" && host.advanced_config) {
|
|
host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
|
|
}
|
|
}
|
|
|
|
// For redirection hosts, if the scheme is not http or https, set it to $scheme
|
|
if (
|
|
nice_host_type === "redirection_host" &&
|
|
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
|
|
) {
|
|
host.forward_scheme = "$scheme";
|
|
}
|
|
|
|
if (host.locations) {
|
|
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
|
|
origLocations = [].concat(host.locations);
|
|
locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
|
|
host.locations = renderedLocations;
|
|
});
|
|
|
|
// Allow someone who is using / custom location path to use it, and skip the default / location
|
|
_.map(host.locations, (location) => {
|
|
if (location.path === "/") {
|
|
host.use_default_location = false;
|
|
}
|
|
});
|
|
} else {
|
|
locationsPromise = Promise.resolve();
|
|
}
|
|
|
|
// Set the IPv6 setting for the host
|
|
host.ipv6 = internalNginx.ipv6Enabled();
|
|
|
|
locationsPromise.then(() => {
|
|
renderEngine
|
|
.parseAndRender(template, host)
|
|
.then((config_text) => {
|
|
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
|
debug(logger, "Wrote config:", filename, config_text);
|
|
|
|
// Restore locations array
|
|
host.locations = origLocations;
|
|
|
|
resolve(true);
|
|
})
|
|
.catch((err) => {
|
|
debug(logger, `Could not write ${filename}:`, err.message);
|
|
reject(new errs.ConfigurationError(err.message));
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* This generates a temporary nginx config listening on port 80 for the domain names listed
|
|
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
|
|
* when requesting a certificate without having a hostname set up already.
|
|
*
|
|
* @param {Object} certificate
|
|
* @returns {Promise}
|
|
*/
|
|
generateLetsEncryptRequestConfig: (certificate) => {
|
|
debug(logger, "Generating LetsEncrypt Request Config:", certificate);
|
|
const renderEngine = utils.getRenderEngine();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let template = null;
|
|
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
|
|
|
|
try {
|
|
template = fs.readFileSync(`${__dirname}/../templates/letsencrypt-request.conf`, { encoding: "utf8" });
|
|
} catch (err) {
|
|
reject(new errs.ConfigurationError(err.message));
|
|
return;
|
|
}
|
|
|
|
certificate.ipv6 = internalNginx.ipv6Enabled();
|
|
|
|
renderEngine
|
|
.parseAndRender(template, certificate)
|
|
.then((config_text) => {
|
|
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
|
debug(logger, "Wrote config:", filename, config_text);
|
|
resolve(true);
|
|
})
|
|
.catch((err) => {
|
|
debug(logger, `Could not write ${filename}:`, err.message);
|
|
reject(new errs.ConfigurationError(err.message));
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* A simple wrapper around unlinkSync that writes to the logger
|
|
*
|
|
* @param {String} filename
|
|
*/
|
|
deleteFile: (filename) => {
|
|
if (!fs.existsSync(filename)) {
|
|
return;
|
|
}
|
|
try {
|
|
debug(logger, `Deleting file: ${filename}`);
|
|
fs.unlinkSync(filename);
|
|
} catch (err) {
|
|
debug(logger, "Could not delete file:", JSON.stringify(err, null, 2));
|
|
}
|
|
},
|
|
|
|
/**
|
|
*
|
|
* @param {String} host_type
|
|
* @returns String
|
|
*/
|
|
getFileFriendlyHostType: (host_type) => {
|
|
return host_type.replace(/-/g, "_");
|
|
},
|
|
|
|
/**
|
|
* This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig`
|
|
*
|
|
* @param {Object} certificate
|
|
* @returns {Promise}
|
|
*/
|
|
deleteLetsEncryptRequestConfig: (certificate) => {
|
|
const config_file = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
|
|
return new Promise((resolve /*, reject*/) => {
|
|
internalNginx.deleteFile(config_file);
|
|
resolve();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} [host]
|
|
* @param {Boolean} [delete_err_file]
|
|
* @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`;
|
|
|
|
return new Promise((resolve /*, reject*/) => {
|
|
internalNginx.deleteFile(config_file);
|
|
if (delete_err_file) {
|
|
internalNginx.deleteFile(config_file_err);
|
|
}
|
|
resolve();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Object} [host]
|
|
* @returns {Promise}
|
|
*/
|
|
renameConfigAsError: (host_type, host) => {
|
|
const config_file = internalNginx.getConfigName(
|
|
internalNginx.getFileFriendlyHostType(host_type),
|
|
typeof host === "undefined" ? 0 : host.id,
|
|
);
|
|
const config_file_err = `${config_file}.err`;
|
|
|
|
return new Promise((resolve /*, reject*/) => {
|
|
fs.unlink(config_file_err, () => {
|
|
// ignore result, a previous .err file may not exist
|
|
fs.rename(config_file, config_file_err, () => {
|
|
// also ignore result, as this is a debugging informative file anyway
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @param {String} hostType
|
|
* @param {Array} hosts
|
|
* @returns {Promise}
|
|
*/
|
|
bulkGenerateConfigs: (hostType, hosts) => {
|
|
const promises = [];
|
|
hosts.map((host) => {
|
|
promises.push(internalNginx.generateConfig(hostType, host));
|
|
return true;
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
/**
|
|
* @param {String} host_type
|
|
* @param {Array} hosts
|
|
* @returns {Promise}
|
|
*/
|
|
bulkDeleteConfigs: (host_type, hosts) => {
|
|
const promises = [];
|
|
hosts.map((host) => {
|
|
promises.push(internalNginx.deleteConfig(host_type, host, true));
|
|
return true;
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
/**
|
|
* @param {string} config
|
|
* @returns {boolean}
|
|
*/
|
|
advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im),
|
|
|
|
/**
|
|
* @returns {boolean}
|
|
*/
|
|
ipv6Enabled: () => {
|
|
if (typeof process.env.DISABLE_IPV6 !== "undefined") {
|
|
const disabled = process.env.DISABLE_IPV6.toLowerCase();
|
|
return !(disabled === "on" || disabled === "true" || disabled === "1" || disabled === "yes");
|
|
}
|
|
|
|
return true;
|
|
},
|
|
};
|
|
|
|
export default internalNginx;
|