diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 59c13bf694..3c124765e0 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -13,11 +13,13 @@ import error from "../lib/error.js"; import utils from "../lib/utils.js"; import { debug, ssl as logger } from "../logger.js"; import certificateModel from "../models/certificate.js"; +import settingModel from "../models/setting.js"; import tokenModel from "../models/token.js"; import userModel from "../models/user.js"; import internalAuditLog from "./audit-log.js"; import internalHost from "./host.js"; import internalNginx from "./nginx.js"; +import { regenerateDefaultSiteConfig } from "./setting.js"; const letsencryptConfig = "/etc/letsencrypt.ini"; const certbotCommand = "certbot"; @@ -419,6 +421,26 @@ const internalCertificate = { // Revoke the cert await internalCertificate.revokeLetsEncryptSsl(row); } + + // If the default-site setting was using this cert, clear the stale + // reference from its meta and regenerate the nginx config so it + // falls back to no-SSL. Without the meta cleanup the Settings UI + // would still show a now-dangling certificate_id; without the + // regen the next nginx reload would fail because the cert PEM + // files are gone from disk while the rendered default_host/site.conf + // still references them. + try { + const defaultSite = await settingModel.query().where("id", "default-site").first(); + if (defaultSite && Number(defaultSite.meta?.certificate_id) === row.id) { + await settingModel.query().where("id", "default-site").patch({ + meta: { ...defaultSite.meta, certificate_id: 0, ssl_forced: false }, + }); + await regenerateDefaultSiteConfig(); + } + } catch (err) { + logger.warn(`Failed to clean up default-site after cert ${row.id} delete: ${err.message}`); + } + return true; }, diff --git a/backend/internal/setting.js b/backend/internal/setting.js index f8fc711454..9fd38f2e7e 100644 --- a/backend/internal/setting.js +++ b/backend/internal/setting.js @@ -1,8 +1,50 @@ import fs from "node:fs"; import errs from "../lib/error.js"; +import certificateModel from "../models/certificate.js"; import settingModel from "../models/setting.js"; import internalNginx from "./nginx.js"; +/** + * Build the nginx render context for the default-site setting by + * resolving its SSL certificate (if any). Returns a NEW object so the + * underlying row sent back to the API client is not mutated with + * fields that aren't part of the setting-object schema. + * + * @param {Object} row The default-site setting row + * @returns {Promise} A row-like object with certificate/ssl fields + */ +const buildDefaultSiteRenderContext = async (row) => { + const certificateId = Number(row?.meta?.certificate_id) || 0; + let certificate = null; + let ssl_forced = !!row?.meta?.ssl_forced; + if (certificateId > 0) { + const cert = await certificateModel + .query() + .where("id", certificateId) + .andWhere("is_deleted", 0) + .first(); + if (cert) { + // Drop PEM cert / private-key contents from `meta` before passing + // to the nginx template. The template renders paths based on + // `certificate_id` and only reads `certificate.provider`; the PEM + // material is on disk. Keeping `meta` here would leak the private + // key into `internalNginx.generateConfig`'s debug log. Destructure + // to avoid mutating the ORM result. + const { meta: _meta, ...safeCert } = cert; + certificate = safeCert; + } else { + // Certificate doesn't exist anymore, render without SSL + ssl_forced = false; + } + } + return { + ...row, + certificate_id: certificate ? certificateId : 0, + ssl_forced, + certificate, + }; +}; + const internalSetting = { /** * @param {Access} access @@ -31,18 +73,20 @@ const internalSetting = { id: data.id, }); }) - .then((row) => { + .then(async (row) => { if (row.id === "default-site") { // write the html if we need to if (row.value === "html") { fs.writeFileSync("/data/nginx/default_www/index.html", row.meta.html, { encoding: "utf8" }); } + const renderContext = await buildDefaultSiteRenderContext(row); + // Configure nginx return internalNginx .deleteConfig("default") .then(() => { - return internalNginx.generateConfig("default", row); + return internalNginx.generateConfig("default", renderContext); }) .then(() => { return internalNginx.test(); @@ -122,4 +166,36 @@ const internalSetting = { }, }; +/** + * Regenerate the nginx config for the default-site setting based on + * its current DB state. Intended for callers outside the settings + * update flow (e.g. certificate.delete) that invalidate the rendered + * config without touching the setting row. Mirrors the test-before- + * reload pattern of the main update flow and falls back to a clean + * config if the regenerated one is invalid. + * + * @returns {Promise} + */ +const regenerateDefaultSiteConfig = async () => { + const row = await settingModel.query().where("id", "default-site").first(); + if (!row) { + return; + } + const renderContext = await buildDefaultSiteRenderContext(row); + try { + await internalNginx.deleteConfig("default"); + await internalNginx.generateConfig("default", renderContext); + await internalNginx.test(); + await internalNginx.reload(); + } catch (err) { + // Generated config is invalid — strip it and reload so nginx + // stays healthy even if the default-site is rendered empty. + await internalNginx.deleteConfig("default"); + await internalNginx.test(); + await internalNginx.reload(); + throw err; + } +}; + +export { regenerateDefaultSiteConfig }; export default internalSetting; diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json index 050ad44125..4eda4368c6 100644 --- a/backend/schema/paths/settings/settingID/put.json +++ b/backend/schema/paths/settings/settingID/put.json @@ -46,6 +46,15 @@ }, "html": { "type": "string" + }, + "certificate_id": { + "type": "integer", + "minimum": 0, + "description": "Certificate ID to use for SSL on the default site, or 0 for none" + }, + "ssl_forced": { + "type": "boolean", + "description": "Whether to redirect HTTP to HTTPS on the default site" } }, "example": { diff --git a/backend/templates/default.conf b/backend/templates/default.conf index cc590f9d85..0557a19dd8 100644 --- a/backend/templates/default.conf +++ b/backend/templates/default.conf @@ -10,6 +10,14 @@ server { listen [::]:80 default; {% else -%} #listen [::]:80 default; +{% endif %} +{% if certificate and certificate_id > 0 -%} + listen 443 ssl default; +{% if ipv6 -%} + listen [::]:443 ssl default; +{% else -%} + #listen [::]:443 ssl default; +{% endif %} {% endif %} server_name default-host.localhost; access_log /data/logs/default-host_access.log combined; @@ -18,6 +26,15 @@ server { include conf.d/include/letsencrypt-acme-challenge.conf; +{% if certificate and certificate_id > 0 -%} +{% include "_certificates.conf" %} +{% if ssl_forced %} + # Force SSL + set $trust_forwarded_proto "F"; + include conf.d/include/force-ssl.conf; +{% endif %} +{% endif %} + {%- if value == "404" %} location / { return 404; diff --git a/frontend/src/pages/Settings/DefaultSite.tsx b/frontend/src/pages/Settings/DefaultSite.tsx index 1ee1cb5ef5..e5cff5bb1c 100644 --- a/frontend/src/pages/Settings/DefaultSite.tsx +++ b/frontend/src/pages/Settings/DefaultSite.tsx @@ -1,13 +1,38 @@ import CodeEditor from "@uiw/react-textarea-code-editor"; -import { Field, Form, Formik } from "formik"; +import cn from "classnames"; +import { Field, Form, Formik, useFormikContext } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; -import { Button, Loading } from "src/components"; +import { Button, Loading, SSLCertificateField } from "src/components"; import { useSetSetting, useSetting } from "src/hooks"; import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; import { showObjectSuccess } from "src/notifications"; +function SSLToggle({ name, label, disabled }: { name: string; label: string; disabled?: boolean }) { + const { values, setFieldValue } = useFormikContext(); + const v: any = values || {}; + const checked = !!v?.[name]; + + const toggleClasses = "form-check-input"; + const toggleEnabled = cn(toggleClasses, "bg-teal"); + + return ( + + ); +} + export default function DefaultSite() { const { data, isLoading, error } = useSetting("default-site"); const { mutate: setSetting } = useSetSetting(); @@ -19,12 +44,17 @@ export default function DefaultSite() { setIsSubmitting(true); setErrorMsg(null); + const certificateId = + typeof values.certificateId === "number" && values.certificateId > 0 ? values.certificateId : 0; + const payload = { id: "default-site", value: values.value, meta: { redirect: values.redirect, html: values.html, + certificateId: certificateId, + sslForced: certificateId > 0 ? !!values.sslForced : false, }, }; @@ -69,6 +99,8 @@ export default function DefaultSite() { value: data?.value || "congratulations", redirect: data?.meta?.redirect || "", html: data?.meta?.html || "", + certificateId: data?.meta?.certificateId || 0, + sslForced: !!data?.meta?.sslForced, } as any } onSubmit={onSubmit} @@ -247,6 +279,20 @@ export default function DefaultSite() { )} )} + {values.value !== "congratulations" && ( +
+ + +
+ )}
diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js index a925b2d374..2bb3a143fe 100644 --- a/test/cypress/e2e/api/Settings.cy.js +++ b/test/cypress/e2e/api/Settings.cy.js @@ -122,4 +122,186 @@ describe('Settings endpoints', () => { expect(data.meta.html).to.be.equal('

hello world

'); }); }); + + it('Default Site with SSL disabled (certificate_id 0)', () => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: 0, + ssl_forced: false, + }, + }, + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.equal('default-site'); + expect(data).to.have.property('meta'); + expect(data.meta.certificate_id).to.be.equal(0); + expect(data.meta.ssl_forced).to.be.equal(false); + }); + }); + + it('Default Site with SSL enabled (certificate_id > 0, ssl_forced true)', () => { + // Create a custom cert and upload PEM material so the rendered + // nginx config can actually point at on-disk files. + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { + provider: 'other', + nice_name: 'Default Site Cert', + }, + }).then((cert) => { + expect(cert).to.have.property('id'); + const certID = cert.id; + + cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${certID}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => { + // Attach the cert + force SSL to the default site + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: certID, + ssl_forced: true, + }, + }, + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data); + expect(data.meta.certificate_id).to.be.equal(certID); + expect(data.meta.ssl_forced).to.be.equal(true); + // Response must NOT leak the joined certificate row or + // the resolved certificate_id / ssl_forced at the top + // level — those are template-only fields. + expect(data).to.not.have.property('certificate'); + expect(data).to.not.have.property('certificate_id'); + expect(data).to.not.have.property('ssl_forced'); + }); + + // Clean up so the next test starts fresh + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${certID}`, + }); + }); + }); + }); + + it('Default Site is unaffected when an unrelated certificate is deleted', () => { + // Two custom certs: A is attached to the default-site, B is unrelated. + // Deleting B must not touch the default-site's meta. + const createCert = (name) => cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { provider: 'other', nice_name: name }, + }).then((cert) => { + return cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${cert.id}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => cert.id); + }); + + createCert('Default-Site Cert A').then((aID) => { + createCert('Unrelated Cert B').then((bID) => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { certificate_id: aID, ssl_forced: true }, + }, + }); + + // Delete B (the unrelated one) + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${bID}`, + }).then(() => { + cy.task('backendApiGet', { + token: token, + path: '/api/settings/default-site', + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data); + // Default-site should still reference A unchanged + expect(data.meta.certificate_id).to.be.equal(aID); + expect(data.meta.ssl_forced).to.be.equal(true); + }); + + // Cleanup + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${aID}`, + }); + }); + }); + }); + }); + + it('Default Site falls back to no-SSL when its certificate is deleted', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/certificates', + data: { + provider: 'other', + nice_name: 'Disposable Cert', + }, + }).then((cert) => { + const certID = cert.id; + + cy.task('backendApiPostFiles', { + token: token, + path: `/api/nginx/certificates/${certID}/upload`, + files: { + certificate: 'test.example.com.pem', + certificate_key: 'test.example.com-key.pem', + }, + }).then(() => { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/default-site', + data: { + value: '404', + meta: { + certificate_id: certID, + ssl_forced: true, + }, + }, + }).then((data) => { + expect(data.meta.certificate_id).to.be.equal(certID); + expect(data.meta.ssl_forced).to.be.equal(true); + }); + + // Delete the cert that the default-site references + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/certificates/${certID}`, + }).then(() => { + // The default-site row should have been cleaned up + cy.task('backendApiGet', { + token: token, + path: '/api/settings/default-site', + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data); + expect(data.meta.certificate_id).to.be.equal(0); + expect(data.meta.ssl_forced).to.be.equal(false); + }); + }); + }); + }); + }); });