mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-24 13:50:20 +01:00
Merge branch 'develop' into develop
This commit is contained in:
+7
-11
@@ -161,12 +161,12 @@ const internal2fa = {
|
||||
}
|
||||
|
||||
const result = await verify({
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
guardrails: createGuardrails({
|
||||
MIN_SECRET_BYTES: 10,
|
||||
}),
|
||||
});
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
guardrails: createGuardrails({
|
||||
MIN_SECRET_BYTES: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
throw new errs.AuthError("Invalid verification code");
|
||||
@@ -288,11 +288,7 @@ const internal2fa = {
|
||||
},
|
||||
|
||||
getUserPasswordAuth: async (userId) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.andWhere("type", "password")
|
||||
.first();
|
||||
const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.ItemNotFoundError("Auth not found");
|
||||
|
||||
@@ -98,7 +98,7 @@ const internalAccessList = {
|
||||
id: data.id,
|
||||
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
|
||||
},
|
||||
true // skip masking
|
||||
true, // skip masking
|
||||
);
|
||||
|
||||
// Audit log
|
||||
@@ -212,10 +212,10 @@ const internalAccessList = {
|
||||
id: data.id,
|
||||
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
|
||||
},
|
||||
true // skip masking
|
||||
true, // skip masking
|
||||
);
|
||||
|
||||
await internalAccessList.build(freshRow)
|
||||
await internalAccessList.build(freshRow);
|
||||
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
|
||||
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
|
||||
}
|
||||
@@ -272,17 +272,13 @@ const internalAccessList = {
|
||||
*/
|
||||
get: async (access, data, skipMasking) => {
|
||||
const thisData = data || {};
|
||||
const accessData = await access.can("access_lists:get", thisData.id)
|
||||
const accessData = await access.can("access_lists:get", thisData.id);
|
||||
|
||||
const query = accessListModel
|
||||
.query()
|
||||
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
|
||||
.leftJoin("proxy_host", function () {
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
|
||||
"proxy_host.is_deleted",
|
||||
"=",
|
||||
0,
|
||||
);
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
|
||||
})
|
||||
.where("access_list.is_deleted", 0)
|
||||
.andWhere("access_list.id", thisData.id)
|
||||
@@ -337,19 +333,13 @@ const internalAccessList = {
|
||||
// 4. audit log
|
||||
|
||||
// 1. update row to be deleted
|
||||
await accessListModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
await accessListModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
|
||||
// 2. update any proxy hosts that were using it (ignoring permissions)
|
||||
if (row.proxy_hosts) {
|
||||
await proxyHostModel
|
||||
.query()
|
||||
.where("access_list_id", "=", row.id)
|
||||
.patch({ access_list_id: 0 });
|
||||
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
|
||||
|
||||
// 3. reconfigure those hosts, then reload nginx
|
||||
// set the access_list_id to zero for these items
|
||||
@@ -426,11 +416,7 @@ const internalAccessList = {
|
||||
.query()
|
||||
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
|
||||
.leftJoin("proxy_host", function () {
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
|
||||
"proxy_host.is_deleted",
|
||||
"=",
|
||||
0,
|
||||
);
|
||||
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
|
||||
})
|
||||
.where("access_list.is_deleted", 0)
|
||||
.groupBy("access_list.id")
|
||||
@@ -472,10 +458,7 @@ const internalAccessList = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: async (userId, visibility) => {
|
||||
const query = accessListModel
|
||||
.query()
|
||||
.count("id as count")
|
||||
.where("is_deleted", 0);
|
||||
const query = accessListModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", userId);
|
||||
@@ -537,20 +520,24 @@ const internalAccessList = {
|
||||
}
|
||||
|
||||
// 2. create empty access file
|
||||
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
|
||||
fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
|
||||
|
||||
// 3. generate password for each user
|
||||
if (list.items.length) {
|
||||
await new Promise((resolve, reject) => {
|
||||
batchflow(list.items).sequential()
|
||||
batchflow(list.items)
|
||||
.sequential()
|
||||
.each((_i, item, next) => {
|
||||
if (item.password?.length) {
|
||||
logger.info(`Adding: ${item.username}`);
|
||||
|
||||
utils.execFile('openssl', ['passwd', '-apr1', item.password])
|
||||
utils
|
||||
.execFile("openssl", ["passwd", "-apr1", item.password])
|
||||
.then((res) => {
|
||||
try {
|
||||
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
|
||||
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
@@ -572,7 +559,7 @@ const internalAccessList = {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default internalAccessList;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import auditLogModel from "../models/audit-log.js";
|
||||
|
||||
const internalAuditLog = {
|
||||
|
||||
/**
|
||||
* All logs
|
||||
*
|
||||
@@ -46,11 +45,7 @@ const internalAuditLog = {
|
||||
get: async (access, data) => {
|
||||
await access.can("auditlog:list");
|
||||
|
||||
const query = auditLogModel
|
||||
.query()
|
||||
.andWhere("id", data.id)
|
||||
.allowGraph("[user]")
|
||||
.first();
|
||||
const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first();
|
||||
|
||||
if (typeof data.expand !== "undefined" && data.expand !== null) {
|
||||
query.withGraphFetched(`[${data.expand.join(", ")}]`);
|
||||
|
||||
@@ -881,10 +881,20 @@ const internalCertificate = {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
logger.info(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Don't fail if file does not exist, so no need for action in the callback
|
||||
} finally {
|
||||
// Remove the credentials file whether certbot succeeded or failed.
|
||||
//
|
||||
// This cleanup used to sit in a catch block, so it only ran when issuance FAILED.
|
||||
// A certificate that issued successfully left its DNS provider API credentials in
|
||||
// /etc/letsencrypt/credentials for the entire life of that certificate. Nothing
|
||||
// reads the file between certbot runs, so there is no reason to keep it:
|
||||
// renewLetsEncryptSslWithDnsChallenge() writes it again immediately before each
|
||||
// renewal.
|
||||
//
|
||||
// unlink is fire-and-forget with an empty callback. If the file is already gone
|
||||
// that is the end state we wanted anyway, and a missing file must never turn a
|
||||
// successful issuance into a failure.
|
||||
fs.unlink(credentialsLocation, () => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -981,6 +991,43 @@ const internalCertificate = {
|
||||
`Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
|
||||
);
|
||||
|
||||
// certbot reads the DNS credentials back from the path recorded in the renewal config
|
||||
// it wrote at issuance time, for example:
|
||||
//
|
||||
// authenticator = dns-cloudflare
|
||||
// dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27
|
||||
//
|
||||
// so the file has to be present for the duration of this run. Write it here and remove
|
||||
// it again below rather than leaving it on disk between renewals.
|
||||
//
|
||||
// Leaving it is an avoidable exposure. Anything running as root - a compromised
|
||||
// process, a script, malware - can read the token and use it to issue valid Let's
|
||||
// Encrypt certificates for the domain. Those certificates are genuinely trusted, so
|
||||
// traffic presented with them passes TLS inspection, IDS/IPS and DLP that would
|
||||
// otherwise flag it, and an exfiltration path built on them looks like ordinary
|
||||
// HTTPS. The exposure window should be one certbot run, not the life of the
|
||||
// certificate.
|
||||
//
|
||||
// The value is not on the certificate object we were handed: renew() sources that from
|
||||
// internalCertificate.get(), which pipes the row through utils.omitRow(omissions()) so
|
||||
// meta.dns_provider_credentials can never travel out over the API. Read the row from
|
||||
// the model directly to get at it.
|
||||
const row = await certificateModel.query().where("id", certificate.id).first();
|
||||
const credentials = row?.meta?.dns_provider_credentials;
|
||||
const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
|
||||
|
||||
if (credentials) {
|
||||
fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
|
||||
fs.writeFileSync(credentialsLocation, credentials, { mode: 0o600 });
|
||||
} else {
|
||||
// Nothing stored to write. A certificate issued under the previous behaviour may
|
||||
// still have its file on disk; leave it be and let certbot decide. Throwing here
|
||||
// would break a renewal that would otherwise have succeeded.
|
||||
logger.warn(
|
||||
`No stored DNS credentials for Cert #${certificate.id}; relying on any existing ${credentialsLocation}`,
|
||||
);
|
||||
}
|
||||
|
||||
const args = [
|
||||
"renew",
|
||||
"--force-renewal",
|
||||
@@ -1008,9 +1055,19 @@ const internalCertificate = {
|
||||
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
logger.info(result);
|
||||
return result;
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
logger.info(result);
|
||||
return result;
|
||||
} finally {
|
||||
// Only clean up a file we put there ourselves. If `credentials` came back empty we
|
||||
// wrote nothing, and an older file left on disk by the previous behaviour is the
|
||||
// only thing keeping that certificate renewable - deleting it would break the next
|
||||
// run for no gain.
|
||||
if (credentials) {
|
||||
fs.unlink(credentialsLocation, () => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,9 +54,7 @@ const internalDeadHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const row = await deadHostModel.query()
|
||||
.insertAndFetch(thisData)
|
||||
.then(utils.omitRow(omissions()));
|
||||
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
@@ -153,12 +151,8 @@ const internalDeadHost = {
|
||||
|
||||
thisData = internalHost.cleanSslHstsData(thisData, row);
|
||||
|
||||
|
||||
// do the row update
|
||||
await deadHostModel
|
||||
.query()
|
||||
.where({id: data.id})
|
||||
.patch(data);
|
||||
await deadHostModel.query().where({ id: data.id }).patch(data);
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
@@ -168,15 +162,18 @@ const internalDeadHost = {
|
||||
meta: thisData,
|
||||
});
|
||||
|
||||
const thisRow = await internalDeadHost
|
||||
.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate"],
|
||||
});
|
||||
const thisRow = await internalDeadHost.get(access, {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate"],
|
||||
});
|
||||
|
||||
// Configure nginx
|
||||
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", row);
|
||||
row.meta = newMeta;
|
||||
if (!thisRow.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
|
||||
}
|
||||
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", thisRow);
|
||||
thisRow.meta = newMeta;
|
||||
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
|
||||
},
|
||||
|
||||
@@ -224,18 +221,15 @@ const internalDeadHost = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
delete: async (access, data) => {
|
||||
await access.can("dead_hosts:delete", data.id)
|
||||
await access.can("dead_hosts:delete", data.id);
|
||||
const row = await internalDeadHost.get(access, { id: data.id });
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
|
||||
await deadHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
await deadHostModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
|
||||
// Delete Nginx Config
|
||||
await internalNginx.deleteConfig("dead_host", row);
|
||||
@@ -259,7 +253,7 @@ const internalDeadHost = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
enable: async (access, data) => {
|
||||
await access.can("dead_hosts:update", data.id)
|
||||
await access.can("dead_hosts:update", data.id);
|
||||
const row = await internalDeadHost.get(access, {
|
||||
id: data.id,
|
||||
expand: ["certificate", "owner"],
|
||||
@@ -273,12 +267,9 @@ const internalDeadHost = {
|
||||
|
||||
row.enabled = 1;
|
||||
|
||||
await deadHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
enabled: 1,
|
||||
});
|
||||
await deadHostModel.query().where("id", row.id).patch({
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
// Configure nginx
|
||||
await internalNginx.configure(deadHostModel, "dead_host", row);
|
||||
@@ -301,7 +292,7 @@ const internalDeadHost = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
disable: async (access, data) => {
|
||||
await access.can("dead_hosts:update", data.id)
|
||||
await access.can("dead_hosts:update", data.id);
|
||||
const row = await internalDeadHost.get(access, { id: data.id });
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
@@ -312,12 +303,9 @@ const internalDeadHost = {
|
||||
|
||||
row.enabled = 0;
|
||||
|
||||
await deadHostModel
|
||||
.query()
|
||||
.where("id", row.id)
|
||||
.patch({
|
||||
enabled: 0,
|
||||
});
|
||||
await deadHostModel.query().where("id", row.id).patch({
|
||||
enabled: 0,
|
||||
});
|
||||
|
||||
// Delete Nginx Config
|
||||
await internalNginx.deleteConfig("dead_host", row);
|
||||
@@ -342,7 +330,7 @@ const internalDeadHost = {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getAll: async (access, expand, searchQuery) => {
|
||||
const accessData = await access.can("dead_hosts:list")
|
||||
const accessData = await access.can("dead_hosts:list");
|
||||
const query = deadHostModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import fs from "node:fs";
|
||||
import errs from "../lib/error.js";
|
||||
import internalDeadHost from "./dead-host.js";
|
||||
import internalProxyHost from "./proxy-host.js";
|
||||
import internalRedirectionHost from "./redirection-host.js";
|
||||
import internalStream from "./stream.js";
|
||||
|
||||
const SYSTEM_LOG_FILE = "/data/logs/backend.log";
|
||||
const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log";
|
||||
|
||||
// Matches the access_log/error_log paths written by the nginx templates
|
||||
// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf).
|
||||
// This is a fixed, server-side lookup table - a host log path is always derived
|
||||
// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied
|
||||
// path or filename, so there is no path-traversal surface here.
|
||||
const HOST_FILE_PREFIX = {
|
||||
proxy: "proxy-host",
|
||||
redirection: "redirection-host",
|
||||
dead: "dead-host",
|
||||
stream: "stream",
|
||||
};
|
||||
|
||||
const DEFAULT_LINES = 200;
|
||||
const MAX_LINES = 1000;
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
// Never scan further back than this, regardless of how many lines were requested,
|
||||
// so a huge or pathological log file can't turn a single request into unbounded I/O.
|
||||
const MAX_SCAN_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Reads at most `maxLines` lines from the end of a file, without loading the
|
||||
* whole file into memory. Reads backwards in fixed-size chunks until enough
|
||||
* newlines have been seen, the start of the file is reached, or the hard
|
||||
* MAX_SCAN_BYTES ceiling is hit.
|
||||
*
|
||||
* @param {String} filePath
|
||||
* @param {Number} maxLines
|
||||
* @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>}
|
||||
*/
|
||||
const readLastLines = async (filePath, maxLines) => {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.promises.open(filePath, "r");
|
||||
const stat = await handle.stat();
|
||||
const { size } = stat;
|
||||
|
||||
if (size === 0) {
|
||||
return { lines: [], size: 0, truncated: false };
|
||||
}
|
||||
|
||||
let position = size;
|
||||
let scanned = 0;
|
||||
let newlineCount = 0;
|
||||
const chunks = [];
|
||||
|
||||
while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) {
|
||||
const readSize = Math.min(CHUNK_SIZE, position);
|
||||
position -= readSize;
|
||||
const buffer = Buffer.alloc(readSize);
|
||||
await handle.read(buffer, 0, readSize, position);
|
||||
scanned += readSize;
|
||||
for (let i = buffer.length - 1; i >= 0; i--) {
|
||||
if (buffer[i] === 0x0a) newlineCount++;
|
||||
}
|
||||
chunks.unshift(buffer);
|
||||
}
|
||||
|
||||
const truncated = position > 0 && scanned >= MAX_SCAN_BYTES;
|
||||
|
||||
// If we didn't start reading from byte 0, the first line in our buffer is only
|
||||
// partial *unless* it happens that `position` landed exactly on a line boundary
|
||||
// (the byte right before it is a newline) - check that one byte to avoid
|
||||
// silently dropping a perfectly valid line.
|
||||
let startsOnLineBoundary = position === 0;
|
||||
if (position > 0) {
|
||||
const boundaryByte = Buffer.alloc(1);
|
||||
await handle.read(boundaryByte, 0, 1, position - 1);
|
||||
startsOnLineBoundary = boundaryByte[0] === 0x0a;
|
||||
}
|
||||
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
const allLines = text.split("\n");
|
||||
|
||||
if (!startsOnLineBoundary && allLines.length > 0) {
|
||||
allLines.shift();
|
||||
}
|
||||
// Drop the trailing empty element caused by a final trailing newline.
|
||||
if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
|
||||
allLines.pop();
|
||||
}
|
||||
|
||||
return { lines: allLines.slice(-maxLines), size, truncated };
|
||||
} finally {
|
||||
if (handle) {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a validated {type, host_type, host_id, channel} selection to the
|
||||
* fixed, absolute path of the log file on disk. Throws if the combination
|
||||
* isn't a recognised source.
|
||||
*
|
||||
* `channel` ("access" | "error") picks which of the two log files nginx writes
|
||||
* per host - it's deliberately not named "stream" to avoid confusion with the
|
||||
* "stream" host_type (TCP/UDP stream hosts).
|
||||
*
|
||||
* @param {Object} data
|
||||
* @returns {String}
|
||||
*/
|
||||
const resolveFilePath = (data) => {
|
||||
switch (data.type) {
|
||||
case "system":
|
||||
return SYSTEM_LOG_FILE;
|
||||
case "letsencrypt":
|
||||
return LETSENCRYPT_LOG_FILE;
|
||||
case "host": {
|
||||
const prefix = HOST_FILE_PREFIX[data.host_type];
|
||||
if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) {
|
||||
throw new errs.ValidationError("Invalid host log source");
|
||||
}
|
||||
return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`;
|
||||
}
|
||||
default:
|
||||
throw new errs.ItemNotFoundError(data.type);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Array} rows
|
||||
* @returns {Array}
|
||||
*/
|
||||
const toHostOptions = (rows) =>
|
||||
rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`,
|
||||
}));
|
||||
|
||||
const internalLogViewer = {
|
||||
/**
|
||||
* Lists the log sources available for the log viewer: the system (backend)
|
||||
* log, the Let's Encrypt (certbot) log, and one entry per host the caller
|
||||
* can see, for each host type.
|
||||
*
|
||||
* @param {Access} access
|
||||
* @returns {Promise}
|
||||
*/
|
||||
listSources: async (access) => {
|
||||
await access.can("logs:list");
|
||||
|
||||
const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([
|
||||
internalProxyHost.getAll(access),
|
||||
internalRedirectionHost.getAll(access),
|
||||
internalDeadHost.getAll(access),
|
||||
internalStream.getAll(access),
|
||||
]);
|
||||
|
||||
return {
|
||||
system: { label: "System" },
|
||||
letsencrypt: { label: "Let's Encrypt" },
|
||||
hosts: {
|
||||
proxy: toHostOptions(proxyHosts),
|
||||
redirection: toHostOptions(redirectionHosts),
|
||||
dead: toHostOptions(deadHosts),
|
||||
stream: streams.map((row) => ({
|
||||
id: row.id,
|
||||
label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the last N lines of the requested log source, optionally
|
||||
* filtered by level and/or a plain-text search term.
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {String} data.type "system" | "letsencrypt" | "host"
|
||||
* @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream"
|
||||
* @param {Number} [data.host_id]
|
||||
* @param {String} [data.channel] "access" | "error"
|
||||
* @param {Number} [data.lines]
|
||||
* @param {String} [data.level]
|
||||
* @param {String} [data.search]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
tail: async (access, data) => {
|
||||
await access.can("logs:list");
|
||||
|
||||
const filePath = resolveFilePath(data);
|
||||
const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await readLastLines(filePath, lines);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return { lines: [], size: 0, truncated: false, exists: false };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let outputLines = result.lines;
|
||||
|
||||
// Only the system log is written in our own "LEVEL [scope]" format - level
|
||||
// filtering on nginx/certbot lines would just match nothing and look like an
|
||||
// empty log, so the filter is a no-op for any other source.
|
||||
if (data.type === "system" && data.level) {
|
||||
const needle = ` ${data.level.toUpperCase().padEnd(7)} `;
|
||||
outputLines = outputLines.filter((line) => line.includes(needle));
|
||||
}
|
||||
|
||||
if (data.search) {
|
||||
const needle = data.search.toLowerCase();
|
||||
outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
return {
|
||||
lines: outputLines,
|
||||
size: result.size,
|
||||
truncated: result.truncated,
|
||||
exists: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default internalLogViewer;
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import _ from "lodash";
|
||||
@@ -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, true);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.generateConfig(host_type, host);
|
||||
@@ -83,10 +84,12 @@ const internalNginx = {
|
||||
meta: combined_meta,
|
||||
})
|
||||
.then(() => {
|
||||
internalNginx.renameConfigAsError(host_type, host);
|
||||
// Keep the failed config as a .err file for inspection
|
||||
return internalNginx.renameConfigAsError(host_type, host);
|
||||
})
|
||||
.then(() => {
|
||||
return internalNginx.deleteConfig(host_type, host, true);
|
||||
// The rename removed the live config already, don't touch the .err file
|
||||
return internalNginx.deleteConfig(host_type, host, false);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -217,10 +220,21 @@ const internalNginx = {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (
|
||||
nice_host_type === "redirection_host" &&
|
||||
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
|
||||
) {
|
||||
host.forward_scheme = "$scheme";
|
||||
}
|
||||
|
||||
// A stream forwarding to an IPv6 literal must have the address wrapped in
|
||||
// square brackets before nginx appends ":<port>". Without the brackets nginx
|
||||
// reads the trailing ":<port>" as part of the address and rejects the upstream
|
||||
// ("invalid port in upstream"), so the stream saves but never activates (#5740).
|
||||
if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
|
||||
host.forwarding_host = `[${host.forwarding_host}]`;
|
||||
}
|
||||
|
||||
if (host.locations) {
|
||||
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
|
||||
origLocations = [].concat(host.locations);
|
||||
@@ -375,8 +389,8 @@ const internalNginx = {
|
||||
const config_file_err = `${config_file}.err`;
|
||||
|
||||
return new Promise((resolve /*, reject*/) => {
|
||||
fs.unlink(config_file, () => {
|
||||
// ignore result, continue
|
||||
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();
|
||||
|
||||
@@ -38,11 +38,7 @@ export default {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
}
|
||||
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", "=", user.id)
|
||||
.where("type", "=", "password")
|
||||
.first();
|
||||
const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
@@ -50,10 +46,7 @@ export default {
|
||||
|
||||
const valid = await auth.verifyPassword(data.secret);
|
||||
if (!valid) {
|
||||
throw new errs.AuthError(
|
||||
ERROR_MESSAGE_INVALID_AUTH,
|
||||
ERROR_MESSAGE_INVALID_AUTH_I18N,
|
||||
);
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
|
||||
}
|
||||
|
||||
if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) {
|
||||
@@ -171,7 +164,7 @@ export default {
|
||||
}
|
||||
|
||||
// Check scope
|
||||
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
|
||||
if (tokenData.scope?.[0] !== "2fa-challenge") {
|
||||
throw new errs.AuthError("Invalid challenge token");
|
||||
}
|
||||
|
||||
@@ -183,10 +176,7 @@ export default {
|
||||
// Verify 2FA code
|
||||
const valid = await twoFactor.verifyForLogin(userId, code);
|
||||
if (!valid) {
|
||||
throw new errs.AuthError(
|
||||
ERROR_MESSAGE_INVALID_2FA,
|
||||
ERROR_MESSAGE_INVALID_2FA_I18N,
|
||||
);
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N);
|
||||
}
|
||||
|
||||
// Create full token
|
||||
|
||||
@@ -257,11 +257,9 @@ const internalUser = {
|
||||
},
|
||||
|
||||
deleteAll: async () => {
|
||||
await userModel
|
||||
.query()
|
||||
.patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
await userModel.query().patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -390,11 +388,21 @@ const internalUser = {
|
||||
.andWhere("type", data.type)
|
||||
.first()
|
||||
.then((existing_auth) => {
|
||||
// Stamped here rather than read off modified_on, because it is compared against a
|
||||
// token's `iat` and the two only line up when the same clock writes both. The
|
||||
// database clock is a different one: with the app on one timezone and the database
|
||||
// on another, its timestamps come back hours away from where Node thinks it is.
|
||||
const password_changed_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (existing_auth) {
|
||||
// patch
|
||||
const meta = existing_auth.meta || {};
|
||||
meta.password_changed_at = password_changed_at;
|
||||
|
||||
return authModel.query().where("user_id", user.id).andWhere("type", data.type).patch({
|
||||
type: data.type, // This is required for the model to encrypt on save
|
||||
secret: data.secret,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
// insert
|
||||
@@ -402,7 +410,7 @@ const internalUser = {
|
||||
user_id: user.id,
|
||||
type: data.type,
|
||||
secret: data.secret,
|
||||
meta: {},
|
||||
meta: { password_changed_at },
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
Reference in New Issue
Block a user