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
22 changes: 22 additions & 0 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
},

Expand Down
80 changes: 78 additions & 2 deletions backend/internal/setting.js
Original file line number Diff line number Diff line change
@@ -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<Object>} 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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
9 changes: 9 additions & 0 deletions backend/schema/paths/settings/settingID/put.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 17 additions & 0 deletions backend/templates/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
50 changes: 48 additions & 2 deletions frontend/src/pages/Settings/DefaultSite.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<label className="form-check form-switch mt-1">
<input
className={checked ? toggleEnabled : toggleClasses}
type="checkbox"
checked={checked}
onChange={(e) => setFieldValue(name, e.target.checked)}
disabled={disabled}
/>
<span className="form-check-label">
<T id={label} />
</span>
</label>
);
}

export default function DefaultSite() {
const { data, isLoading, error } = useSetting("default-site");
const { mutate: setSetting } = useSetSetting();
Expand All @@ -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,
},
};

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -247,6 +279,20 @@ export default function DefaultSite() {
)}
</Field>
)}
{values.value !== "congratulations" && (
<div className="mt-5">
<SSLCertificateField
name="certificateId"
label="ssl-certificate"
forHttp={true}
/>
<SSLToggle
name="sslForced"
label="domains.force-ssl"
disabled={!values.certificateId || values.certificateId === 0}
/>
</div>
)}
</div>
<div className="card-footer bg-transparent mt-auto">
<div className="btn-list justify-content-end">
Expand Down
Loading