From 0e9b3db5fce8f64c759076d0d95932fd6e0ca2c5 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 29 Oct 2026 14:03:15 -0500 Subject: [PATCH] fix: let non docker installs switch nginx to https (#1711) --- CHANGELOG.md | 2 + env.catalog.json | 24 ++++++++ src/backend/tests/tls/nginx-template.test.ts | 41 +++++++++++++ src/backend/tls/nginx-reload.ts | 60 +++++++++++++++----- 4 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 src/backend/tests/tls/nginx-template.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5766454a8..32c6515bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update - Hosts can have a color and an icon, which sync to the desktop and mobile apps - A sidebar setting to turn off alternating row colors +- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added ### Fixed @@ -21,6 +22,7 @@ - A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed - The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again - On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module +- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart ## 26.10.5 diff --git a/env.catalog.json b/env.catalog.json index 38b5d7bf4..5c52fb045 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -83,6 +83,30 @@ "default": "8443", "description": "Port for HTTPS." }, + { + "name": "TERMIX_NGINX_TEMPLATE", + "group": "https", + "default": "/app/nginx/nginx-https.conf.template", + "description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker." + }, + { + "name": "TERMIX_NGINX_CONF", + "group": "https", + "default": "/tmp/nginx/nginx.conf", + "description": "Where Termix writes the rendered nginx config before it reloads nginx." + }, + { + "name": "TERMIX_NGINX_PID", + "group": "https", + "default": "/tmp/nginx/nginx.pid", + "description": "The nginx pid file. Termix only reloads nginx when it exists." + }, + { + "name": "TERMIX_APP_DIR", + "group": "https", + "default": "/app", + "description": "Where Termix is installed, used in place of /app in the nginx template." + }, { "name": "SSL_CERT_PATH", "group": "https", diff --git a/src/backend/tests/tls/nginx-template.test.ts b/src/backend/tests/tls/nginx-template.test.ts new file mode 100644 index 000000000..9dfcadd68 --- /dev/null +++ b/src/backend/tests/tls/nginx-template.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + authLogger: { info: vi.fn(), error: vi.fn() }, +})); + +const { renderNginxTemplate } = await import("../../tls/nginx-reload.js"); + +const TEMPLATE = [ + "listen ${PORT};", + "listen ${SSL_PORT} ssl;", + "ssl_certificate ${SSL_CERT_PATH};", + " root /app/html;", + "pid /tmp/nginx/nginx.pid;", +].join("\n"); + +const values = { + port: "8080", + sslPort: "443", + certPath: "/data/ssl/termix.crt", + keyPath: "/data/ssl/termix.key", +}; + +describe("renderNginxTemplate", () => { + it("fills the ports and certificate", () => { + const out = renderNginxTemplate(TEMPLATE, values); + expect(out).toContain("listen 8080;"); + expect(out).toContain("listen 443 ssl;"); + expect(out).toContain("ssl_certificate /data/ssl/termix.crt;"); + expect(out).toContain("root /app/html;"); + }); + + it("uses the install folder in place of /app", () => { + const out = renderNginxTemplate(TEMPLATE, { + ...values, + appDir: "/opt/termix/", + }); + expect(out).toContain("root /opt/termix/html;"); + expect(out).toContain("pid /tmp/nginx/nginx.pid;"); + }); +}); diff --git a/src/backend/tls/nginx-reload.ts b/src/backend/tls/nginx-reload.ts index 197ce185a..964528acd 100644 --- a/src/backend/tls/nginx-reload.ts +++ b/src/backend/tls/nginx-reload.ts @@ -3,9 +3,40 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "path"; import { authLogger } from "../utils/logger.js"; -const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template"; -const NGINX_CONF = "/tmp/nginx/nginx.conf"; -const NGINX_PID = "/tmp/nginx/nginx.pid"; +// The Docker image's paths. An install outside Docker (a Proxmox LXC script, +// a source checkout) points these at its own nginx instead. +const nginxTemplate = () => + process.env.TERMIX_NGINX_TEMPLATE || "/app/nginx/nginx-https.conf.template"; +const nginxConf = () => + process.env.TERMIX_NGINX_CONF || "/tmp/nginx/nginx.conf"; +const nginxPid = () => process.env.TERMIX_NGINX_PID || "/tmp/nginx/nginx.pid"; + +/** The template with Termix's own folder in place of the image's /app. */ +export function renderNginxTemplate( + template: string, + values: { + port: string; + sslPort: string; + certPath: string; + keyPath: string; + appDir?: string; + }, +): string { + let rendered = template + .split("${PORT}") + .join(values.port) + .split("${SSL_PORT}") + .join(values.sslPort) + .split("${SSL_CERT_PATH}") + .join(values.certPath) + .split("${SSL_KEY_PATH}") + .join(values.keyPath); + const appDir = values.appDir?.replace(/\/+$/, ""); + if (appDir && appDir !== "/app") { + rendered = rendered.replace(/(^|[\s;])\/app\//gm, `$1${appDir}/`); + } + return rendered; +} const DATA_DIR = process.env.DATA_DIR || "./db/data"; const SSL_DIR = path.join(DATA_DIR, "ssl"); @@ -51,11 +82,13 @@ export function persistSSLEnv( * restart. No-ops outside the Docker image (template/binary won't exist). */ export function reloadNginxWithSSL(): { applied: boolean; message: string } { - if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) { + const NGINX_TEMPLATE = nginxTemplate(); + const NGINX_CONF = nginxConf(); + if (!existsSync(NGINX_TEMPLATE) || !existsSync(nginxPid())) { return { applied: false, message: - "nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.", + "Termix could not find the nginx it runs with, so HTTPS is not on yet. In Docker, restart Termix with ENABLE_SSL=true. On another install, set TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF and TERMIX_NGINX_PID (and TERMIX_APP_DIR if it is not /app), or add a listen 443 ssl block with this certificate to your nginx config.", }; } @@ -66,16 +99,13 @@ export function reloadNginxWithSSL(): { applied: boolean; message: string } { const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key"); try { - const template = readFileSync(NGINX_TEMPLATE, "utf8"); - const rendered = template - .split("${PORT}") - .join(port) - .split("${SSL_PORT}") - .join(sslPort) - .split("${SSL_CERT_PATH}") - .join(certPath) - .split("${SSL_KEY_PATH}") - .join(keyPath); + const rendered = renderNginxTemplate(readFileSync(NGINX_TEMPLATE, "utf8"), { + port, + sslPort, + certPath, + keyPath, + appDir: process.env.TERMIX_APP_DIR, + }); writeFileSync(NGINX_CONF, rendered);