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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.github
.idea

**/node_modules
**/.pnpm-store
**/.cache

.tmp-s6-overlay-root
.tmp-s6-overlay.tar
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Files copied into the Linux image and scripts executed by the host must use LF endings.
docker/rootfs/** text eol=lf
*.sh text eol=lf
scripts/** text eol=lf
docker/scripts/** text eol=lf
77 changes: 77 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Build and push Docker image

on:
workflow_dispatch:
inputs:
version:
description: Docker image version tag (1.0 or later)
required: true
default: '1.0'
type: string

concurrency:
group: dockerhub-nginx-proxy-manager-${{ inputs.version }}
cancel-in-progress: false

permissions:
contents: read

env:
IMAGE_NAME: docker.io/moailaozi/nginx-proxy-manager

jobs:
build-and-push:
runs-on: ubuntu-24.04

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Validate image version
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
if [[ ! "$VERSION" =~ ^[1-9][0-9]*\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "Version must be numeric and at least 1.0 (for example: 1.0 or 1.0.1)."
exit 1
fi

- name: Set build metadata
id: metadata
shell: bash
run: |
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "date=$(date --utc '+%Y-%m-%d %H:%M:%S UTC')" >> "$GITHUB_OUTPUT"

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}

- name: Build frontend assets
run: bash scripts/ci/frontend-build

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.IMAGE_NAME }}:${{ inputs.version }}
build-args: |
BUILD_VERSION=${{ inputs.version }}
BUILD_COMMIT=${{ steps.metadata.outputs.commit }}
BUILD_DATE=${{ steps.metadata.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ test/node_modules
docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
.claude

.tmp-s6-overlay-root/
.tmp-s6-overlay.tar
3 changes: 3 additions & 0 deletions backend/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
auto-install-peers=true
registry=https://registry.npmmirror.com
strict-ssl=false
21 changes: 21 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cors from "./lib/express/cors.js";
import jwt from "./lib/express/jwt.js";
import { debug, express as logger } from "./logger.js";
import mainRoutes from "./routes/main.js";
import nginxDeploymentCoordinator from "./internal/nginx-deployment-coordinator.js";

/**
* App
Expand Down Expand Up @@ -55,6 +56,18 @@ app.use((_, res, next) => {
});

app.use(jwt());

// Reconcile any interrupted atomic deployment before API routes can trigger a
// new configuration change. Recovery only touches journals left by the
// coordinator; an empty deployment directory is a no-op.
try {
const recoveredDeployments = await nginxDeploymentCoordinator.recover();
if (recoveredDeployments.length) logger.warn(`Recovered ${recoveredDeployments.length} interrupted nginx deployment(s)`);
} catch (error) {
logger.error(`Unable to recover nginx deployments: ${error.message}`);
throw error;
}

app.use("/", mainRoutes);

// production error handler
Expand All @@ -67,6 +80,14 @@ app.use((err, req, res, _) => {
},
};

if (typeof err.error_code !== "undefined") {
payload.error.error_code = err.error_code;
}

if (typeof err.details !== "undefined") {
payload.error.details = err.details;
}

if (typeof err.message_i18n !== "undefined") {
payload.error.message_i18n = err.message_i18n;
}
Expand Down
2 changes: 2 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import app from "./app.js";
import internalCertificate from "./internal/certificate.js";
import internalIpRanges from "./internal/ip_ranges.js";
import nginxLogFollowHub from "./internal/nginx-log-follow-hub.js";
import { global as logger } from "./logger.js";
import { migrateUp } from "./migrate.js";
import { getCompiledSchema } from "./schema/index.js";
Expand Down Expand Up @@ -33,6 +34,7 @@ async function appStart() {

process.on("SIGTERM", () => {
logger.info(`PID ${process.pid} received SIGTERM`);
nginxLogFollowHub.closeAll();
server.close(() => {
logger.info("Stopping.");
process.exit(0);
Expand Down
81 changes: 81 additions & 0 deletions backend/internal/nginx-config-artifacts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import fs from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { getActivePath } from "./nginx-host-adapters.js";

export const deploymentRoot = (nginxRoot = "/data/nginx") => join(nginxRoot, ".deploy");

export const assertInside = (root, path) => {
const rootPath = resolve(root);
const target = resolve(path);
if (target !== rootPath && !target.startsWith(`${rootPath}${process.platform === "win32" ? "\\" : "/"}`)) {
throw new Error(`Path escapes configured nginx root: ${path}`);
}
return target;
};

export const activeArtifactPath = (hostType, hostId, nginxRoot = "/data/nginx") => getActivePath(hostType, hostId, nginxRoot);
export const candidateArtifactPath = (hostType, hostId, operationId, nginxRoot = "/data/nginx") =>
assertInside(deploymentRoot(nginxRoot), join(deploymentRoot(nginxRoot), "candidates", hostType, String(hostId), `${operationId}.conf`));
export const stagingArtifactPath = (hostType, hostId, operationId, nginxRoot = "/data/nginx") =>
assertInside(deploymentRoot(nginxRoot), join(deploymentRoot(nginxRoot), "staging", operationId, hostType, `${hostId}.conf`));
export const backupArtifactPath = (hostType, hostId, operationId, nginxRoot = "/data/nginx") =>
assertInside(deploymentRoot(nginxRoot), join(deploymentRoot(nginxRoot), "backups", operationId, hostType, `${hostId}.conf`));
export const journalPath = (operationId, nginxRoot = "/data/nginx") => assertInside(deploymentRoot(nginxRoot), join(deploymentRoot(nginxRoot), "journal", `${operationId}.json`));

export const readArtifact = async (path) => {
try {
return await fs.readFile(path, "utf8");
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
};

/** Atomic within one filesystem: write, fsync, rename and fsync directory. */
export const atomicWrite = async (path, content) => {
await fs.mkdir(dirname(path), { recursive: true });
const temp = join(dirname(path), `.${randomUUID()}.tmp`);
let handle;
try {
handle = await fs.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = null;
await fs.rename(temp, path);
try {
const directory = await fs.open(dirname(path), "r");
await directory.sync();
await directory.close();
} catch (error) {
if (!["EINVAL", "EPERM", "ENOTSUP"].includes(error.code)) throw error;
}
} finally {
if (handle) await handle.close();
await fs.rm(temp, { force: true }).catch(() => undefined);
}
};

export const removeArtifact = async (path) => fs.rm(path, { force: true });

export const writeJournal = async (journal, nginxRoot = "/data/nginx") => atomicWrite(journalPath(journal.operation_id, nginxRoot), `${JSON.stringify(journal, null, 2)}\n`);
export const readJournals = async (nginxRoot = "/data/nginx") => {
const directory = join(deploymentRoot(nginxRoot), "journal");
try {
const names = await fs.readdir(directory);
return Promise.all(names.filter((name) => name.endsWith(".json")).map(async (name) => JSON.parse(await fs.readFile(join(directory, name), "utf8"))));
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
};
export const deleteJournal = async (operationId, nginxRoot = "/data/nginx") => fs.rm(journalPath(operationId, nginxRoot), { force: true });

export const toLogicalPath = (path, nginxRoot = "/data/nginx") => {
const value = relative(nginxRoot, path).replace(/\\/g, "/");
if (!value || value.startsWith("../") || isAbsolute(value)) throw new Error("Artifact path is outside nginx root");
return value;
};

export default { deploymentRoot, assertInside, activeArtifactPath, candidateArtifactPath, stagingArtifactPath, backupArtifactPath, journalPath, readArtifact, atomicWrite, removeArtifact, writeJournal, readJournals, deleteJournal, toLogicalPath };
150 changes: 150 additions & 0 deletions backend/internal/nginx-config-diagnostics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const BLOCKING_DIRECTIVES = new Set([
"proxy_pass",
"listen",
"ssl_certificate",
"ssl_certificate_key",
]);
const WARNING_DIRECTIVES = new Set([
"proxy_connect_timeout",
"proxy_send_timeout",
"proxy_read_timeout",
"proxy_buffering",
"proxy_request_buffering",
"proxy_buffer_size",
"proxy_busy_buffers_size",
"proxy_buffers",
"proxy_max_temp_file_size",
"proxy_temp_file_write_size",
"proxy_limit_rate",
"proxy_headers_hash_bucket_size",
"proxy_headers_hash_max_size",
"proxy_http_version",
"proxy_method",
"proxy_pass_request_headers",
"proxy_pass_request_body",
"proxy_pass_trailers",
"proxy_ignore_client_abort",
"proxy_socket_keepalive",
"proxy_bind",
"proxy_set_header",
"proxy_hide_header",
"proxy_pass_header",
"proxy_ignore_headers",
"add_header",
"proxy_next_upstream",
"proxy_next_upstream_timeout",
"proxy_next_upstream_tries",
"proxy_intercept_errors",
"proxy_force_ranges",
"proxy_redirect",
"proxy_cookie_domain",
"proxy_cookie_path",
"proxy_ssl_server_name",
"proxy_ssl_name",
"proxy_ssl_verify",
"proxy_ssl_verify_depth",
"proxy_ssl_session_reuse",
"proxy_ssl_protocols",
"proxy_ssl_ciphers",
]);

const diagnostic = (severity, code, line, message) => ({ severity, code, scope: "advanced_config", line, message });

/**
* A deliberately small lexer: it recognises top-level directive tokens while
* ignoring comments and quoted strings. It is not an nginx parser; nginx -t
* remains the final authority.
*
* @param {string|undefined|null} config
* @returns {Array<object>}
*/
export const scanAdvancedConfig = (config) => {
if (!config) {
return [];
}
const result = [];
let token = "";
let line = 1;
let tokenLine = 1;
let quote = null;
let escaped = false;
let comment = false;
let depth = 0;

const flushDirective = (delimiter) => {
const directive = token.trim().split(/\s+/)[0]?.toLowerCase();
if (!directive) {
token = "";
return;
}
if (depth === 0) {
if (directive === "server" || directive === "location") {
result.push(diagnostic("error", "ADVANCED_MANAGED_BLOCK", tokenLine, `Advanced config may not define ${directive} blocks`));
} else if (BLOCKING_DIRECTIVES.has(directive)) {
result.push(diagnostic("error", "ADVANCED_MANAGED_DIRECTIVE", tokenLine, `Advanced config may not define ${directive}`));
} else if (directive === "include" && /(?:proxy\.conf|_access\.conf|_certificates\.conf)/i.test(token)) {
result.push(diagnostic("error", "ADVANCED_MANAGED_INCLUDE", tokenLine, "Advanced config may not replace managed includes"));
} else if (WARNING_DIRECTIVES.has(directive)) {
result.push(diagnostic("warning", "ADVANCED_STRUCTURED_CONFLICT", tokenLine, `Advanced config may override structured ${directive} settings`));
}
}
token = "";
if (delimiter === "{") {
depth += 1;
}
};

for (let index = 0; index < config.length; index += 1) {
const char = config[index];
if (comment) {
if (char === "\n") {
comment = false;
line += 1;
}
continue;
}
if (quote) {
token += char;
if (char === "\n") line += 1;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === "#") {
comment = true;
continue;
}
if (char === "\"" || char === "'") {
quote = char;
token += char;
continue;
}
if (char === "\n") {
line += 1;
token += " ";
continue;
}
if (char === ";" || char === "{") {
flushDirective(char);
tokenLine = line;
continue;
}
if (char === "}") {
token = "";
depth = Math.max(0, depth - 1);
tokenLine = line;
continue;
}
if (!token && /\S/.test(char)) tokenLine = line;
token += char;
}
return result;
};

export const hasDiagnosticErrors = (diagnostics) => diagnostics.some((item) => item.severity === "error");
export default { scanAdvancedConfig, hasDiagnosticErrors };
Loading