feat(ssl): add automatic Let's Encrypt provisioning for primary OneUptime host

- Introduce ENABLE_SSL_PROVIONING_FOR_ONEUPTIME env flag (EnvironmentConfig, docker-compose, config.example)
- Add Helm chart support: values.yaml, values.schema.json, _helpers.tpl, and README entry
- Add DB fields to GlobalConfig for oneuptime SSL certificate, key, issuedAt and expiresAt
- Implement OneuptimeSslCertificateService to request/renew certs and persist to GlobalConfig
- Add worker cron (OneuptimeCerts) to ensure provisioning runs regularly
- Add WriteOneuptimeCertToDisk job and hook into Nginx startup to write certs to /etc/nginx/certs/OneUptime
- Update Nginx templates and run.sh to load certificate directives, serve ACME challenge endpoint and handle redirects
- Extend Greenlock.orderCert to support onCertificateIssued callback and optional persistence of ACME certificates
- Minor: update .gitignore to include OneUptime cert paths
This commit is contained in:
Nawaz Dhandala
2025-10-30 22:25:04 +00:00
parent 13860be56d
commit 16903ae9ef
17 changed files with 517 additions and 55 deletions

2
.gitignore vendored
View File

@@ -76,6 +76,8 @@ logs.txt
Certs/StatusPageCerts/*.crt
Certs/StatusPageCerts/*.key
Certs/OneUptime/*.crt
Certs/OneUptime/*.key
Certs/ServerCerts/*.crt
Certs/ServerCerts/*.key

View File

@@ -497,4 +497,74 @@ export default class GlobalConfig extends GlobalConfigModel {
transformer: Email.getDatabaseTransformer(),
})
public adminNotificationEmail?: Email = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "OneUptime SSL Certificate",
description:
"TLS certificate issued by Let's Encrypt for the primary OneUptime host.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: false,
})
public oneuptimeSslCertificate?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "OneUptime SSL Certificate Key",
description:
"Private key that pairs with the primary OneUptime TLS certificate.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: false,
})
public oneuptimeSslCertificateKey?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "OneUptime SSL Issued At",
description: "Date when the primary TLS certificate was issued.",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public oneuptimeSslIssuedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "OneUptime SSL Expires At",
description: "Expiry date of the primary OneUptime TLS certificate.",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public oneuptimeSslExpiresAt?: Date = undefined;
}

View File

@@ -239,6 +239,9 @@ export const ClickhousePort: Port = new Port(
process.env["CLICKHOUSE_PORT"] || "8123",
);
export const EnableSslProvisioningForOneuptime: boolean =
process.env["ENABLE_SSL_PROVIONING_FOR_ONEUPTIME"] === "true";
export const ClickhouseUsername: string =
process.env["CLICKHOUSE_USER"] || "default";

View File

@@ -0,0 +1,143 @@
import GlobalConfigService from "./GlobalConfigService";
import GlobalConfig from "../../Models/DatabaseModels/GlobalConfig";
import ObjectID from "../../Types/ObjectID";
import Hostname from "../../Types/API/Hostname";
import OneUptimeDate from "../../Types/Date";
import logger from "../Utils/Logger";
import GreenlockUtil from "../Utils/Greenlock/Greenlock";
import {
EnableSslProvisioningForOneuptime,
Host,
} from "../EnvironmentConfig";
export default class OneuptimeSslCertificateService {
private static isProvisioning: boolean = false;
public static async ensureCertificateProvisioned(): Promise<void> {
if (!EnableSslProvisioningForOneuptime) {
logger.debug(
"Oneuptime SSL provisioning is disabled. Skipping certificate check.",
);
return;
}
let host: string = Host.trim();
if (!host) {
logger.error(
"ENABLE_SSL_PROVIONING_FOR_ONEUPTIME is true but HOST is not set. Skipping certificate provisioning.",
);
return;
}
try {
host = Hostname.fromString(host).toString().toLowerCase();
} catch (err) {
logger.error("Failed to parse HOST for SSL provisioning.");
logger.error(err);
return;
}
if (host === "localhost" || host === "127.0.0.1") {
logger.warn(
"Skipping OneUptime SSL provisioning because HOST resolves to a loopback address.",
);
return;
}
if (this.isProvisioning) {
logger.debug(
"SSL provisioning already in progress for OneUptime host. Skipping concurrent run.",
);
return;
}
const globalConfig: GlobalConfig | null = await GlobalConfigService.findOneBy({
query: {
_id: ObjectID.getZeroObjectID().toString(),
},
select: {
oneuptimeSslCertificate: true,
oneuptimeSslCertificateKey: true,
oneuptimeSslIssuedAt: true,
oneuptimeSslExpiresAt: true,
},
props: {
isRoot: true,
},
});
if (!globalConfig) {
logger.error("GlobalConfig document not found. Unable to provision SSL certificate.");
return;
}
const hasCertificate: boolean = Boolean(
globalConfig.oneuptimeSslCertificate &&
globalConfig.oneuptimeSslCertificateKey,
);
const expiresAt: Date | undefined = globalConfig.oneuptimeSslExpiresAt
? new Date(globalConfig.oneuptimeSslExpiresAt)
: undefined;
const shouldRenew: boolean = !expiresAt
? true
: OneuptimeSslCertificateService.isExpiringSoon(expiresAt, 30);
if (hasCertificate && !shouldRenew) {
logger.debug(
"Existing OneUptime SSL certificate is still valid. No renewal required.",
);
return;
}
this.isProvisioning = true;
try {
logger.debug(`Ordering SSL certificate for OneUptime host: ${host}`);
await GreenlockUtil.orderCert({
domain: host,
validateCname: async () => {
return true;
},
persistAcmeCertificate: false,
onCertificateIssued: async (info) => {
await GlobalConfigService.updateOneById({
id: ObjectID.getZeroObjectID(),
data: {
oneuptimeSslCertificate: info.certificate,
oneuptimeSslCertificateKey: info.certificateKey,
oneuptimeSslIssuedAt: info.issuedAt,
oneuptimeSslExpiresAt: info.expiresAt,
},
props: {
isRoot: true,
},
});
},
});
logger.debug(
`SSL certificate provisioning completed for OneUptime host: ${host}`,
);
} catch (err) {
logger.error(
"Failed to provision OneUptime SSL certificate via Let's Encrypt.",
);
logger.error(err);
} finally {
this.isProvisioning = false;
}
}
private static isExpiringSoon(expiresAt: Date, renewBeforeDays: number): boolean {
const renewThreshold: Date = OneUptimeDate.addRemoveDays(
expiresAt,
-1 * renewBeforeDays,
);
return OneUptimeDate.isOnOrAfter(OneUptimeDate.getCurrentDate(), renewThreshold);
}
}

View File

@@ -135,6 +135,13 @@ export default class GreenlockUtil {
public static async orderCert(data: {
domain: string;
validateCname: (domain: string) => Promise<boolean>;
onCertificateIssued?: (info: {
certificate: string;
certificateKey: string;
issuedAt: Date;
expiresAt: Date;
}) => Promise<void>;
persistAcmeCertificate?: boolean;
}): Promise<void> {
try {
logger.debug(
@@ -262,61 +269,81 @@ export default class GreenlockUtil {
logger.debug(`Certificate expires at: ${expiresAt}`);
logger.debug(`Certificate issued at: ${issuedAt}`);
// check if the certificate is already in the database.
const existingCertificate: AcmeCertificate | null =
await AcmeCertificateService.findOneBy({
query: {
domain: domain,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
const certificateInfo: {
certificate: string;
certificateKey: string;
issuedAt: Date;
expiresAt: Date;
} = {
certificate: certificate.toString(),
certificateKey: certificateKey.toString(),
issuedAt,
expiresAt,
};
if (existingCertificate) {
logger.debug(`Updating certificate for domain: ${domain}`);
if (data.onCertificateIssued) {
await data.onCertificateIssued(certificateInfo);
}
// update the certificate
await AcmeCertificateService.updateBy({
query: {
domain: domain,
},
limit: 1,
skip: 0,
data: {
certificate: certificate.toString(),
certificateKey: certificateKey.toString(),
issuedAt: issuedAt,
expiresAt: expiresAt,
},
props: {
isRoot: true,
},
});
const shouldPersist: boolean = data.persistAcmeCertificate !== false;
logger.debug(`Certificate updated for domain: ${domain}`);
} else {
logger.debug(`Creating certificate for domain: ${domain}`);
// create the certificate
const acmeCertificate: AcmeCertificate = new AcmeCertificate();
if (shouldPersist) {
// check if the certificate is already in the database.
const existingCertificate: AcmeCertificate | null =
await AcmeCertificateService.findOneBy({
query: {
domain: domain,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
acmeCertificate.domain = domain;
acmeCertificate.certificate = certificate.toString();
acmeCertificate.certificateKey = certificateKey.toString();
acmeCertificate.issuedAt = issuedAt;
acmeCertificate.expiresAt = expiresAt;
if (existingCertificate) {
logger.debug(`Updating certificate for domain: ${domain}`);
await AcmeCertificateService.create({
data: acmeCertificate,
props: {
isRoot: true,
},
});
// update the certificate
await AcmeCertificateService.updateBy({
query: {
domain: domain,
},
limit: 1,
skip: 0,
data: {
certificate: certificateInfo.certificate,
certificateKey: certificateInfo.certificateKey,
issuedAt: issuedAt,
expiresAt: expiresAt,
},
props: {
isRoot: true,
},
});
logger.debug(`Certificate created for domain: ${domain}`);
logger.debug(`Certificate updated for domain: ${domain}`);
} else {
logger.debug(`Creating certificate for domain: ${domain}`);
// create the certificate
const acmeCertificate: AcmeCertificate = new AcmeCertificate();
acmeCertificate.domain = domain;
acmeCertificate.certificate = certificateInfo.certificate;
acmeCertificate.certificateKey = certificateInfo.certificateKey;
acmeCertificate.issuedAt = issuedAt;
acmeCertificate.expiresAt = expiresAt;
await AcmeCertificateService.create({
data: acmeCertificate,
props: {
isRoot: true,
},
});
logger.debug(`Certificate created for domain: ${domain}`);
}
}
} catch (e) {
logger.error(`Error ordering certificate for domain: ${data.domain}`);

View File

@@ -79,6 +79,7 @@ The following table lists the configurable parameters of the OneUptime chart and
| `global.storageClass` | Storage class to be used for all persistent volumes | `nil` | 🚨 |
| `host` | Hostname for the ingress | `localhost` | 🚨 |
| `httpProtocol` | If the server is hosted with SSL/TLS cert then change this value to https | `http` | 🚨 |
| `enableSslProvisioningForOneuptime` | When true the ingress will request and renew Let's Encrypt certificates for the primary OneUptime host (requires letsEncrypt.* settings) | `false` | ⚠️ |
| `oneuptimeSecret` | Value used to define ONEUPTIME_SECRET | `nil` | |
| `encryptionSecret` | Value used to define ENCRYPTION_SECRET | `nil` | |
| `global.clusterDomain` | Kubernetes Cluster Domain | `cluster.local` | |

View File

@@ -196,6 +196,8 @@ Usage:
- name: LETS_ENCRYPT_ACCOUNT_KEY
value: {{ $.Values.letsEncrypt.accountKey }}
- name: ENABLE_SSL_PROVIONING_FOR_ONEUPTIME
value: {{ $.Values.enableSslProvisioningForOneuptime | quote }}
- name: ENCRYPTION_SECRET
{{- if $.Values.encryptionSecret }}

View File

@@ -22,6 +22,11 @@
"type": "string",
"enum": ["http", "https"]
},
"enableSslProvisioningForOneuptime": {
"type": "boolean",
"description": "Enable automatic Let's Encrypt provisioning for the primary OneUptime host",
"default": false
},
"oneuptimeSecret": {
"type": ["string", "null"]
},

View File

@@ -5,6 +5,7 @@ global:
# Please change this to the domain name / IP where OneUptime server is hosted on.
host: localhost
httpProtocol: http
enableSslProvisioningForOneuptime: false
# Important: You do need to set this to a long random values if you're using OneUptime in production.
# Please set this to string.

View File

@@ -1,5 +1,6 @@
import AcmeWriteCertificatesJob from "./Jobs/AcmeWriteCertificates";
import WriteCustomCertsToDiskJob from "./Jobs/WriteCustomCertsToDisk";
import WriteOneuptimeCertToDiskJob from "./Jobs/WriteOneuptimeCertToDisk";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PostgresAppInstance from "Common/Server/Infrastructure/PostgresDatabase";
import InfrastructureStatus from "Common/Server/Infrastructure/Status";
@@ -37,6 +38,7 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
AcmeWriteCertificatesJob.init();
WriteCustomCertsToDiskJob.init();
WriteOneuptimeCertToDiskJob.init();
// add default routes
await App.addDefaultRoutes();

View File

@@ -0,0 +1,128 @@
import { EVERY_FIFTEEN_MINUTE, EVERY_MINUTE } from "Common/Utils/CronTime";
import { EnableSslProvisioningForOneuptime, Host, IsDevelopment } from "Common/Server/EnvironmentConfig";
import BasicCron from "Common/Server/Utils/BasicCron";
import LocalFile from "Common/Server/Utils/LocalFile";
import logger from "Common/Server/Utils/Logger";
import GlobalConfigService from "Common/Server/Services/GlobalConfigService";
import ObjectID from "Common/Types/ObjectID";
import GlobalConfig from "Common/Models/DatabaseModels/GlobalConfig";
const CERT_DIRECTORY: string = "/etc/nginx/certs/OneUptime";
export default class WriteOneuptimeCertToDiskJob {
public static init(): void {
BasicCron({
jobName: "OneuptimeCerts:WriteToDisk",
options: {
schedule: IsDevelopment ? EVERY_MINUTE : EVERY_FIFTEEN_MINUTE,
runOnStartup: true,
},
runFunction: async () => {
await this.syncCertificateToDisk();
},
});
}
public static async syncCertificateToDisk(): Promise<void> {
const originalHost: string = Host.trim();
const host: string = originalHost.toLowerCase();
if (!EnableSslProvisioningForOneuptime || !host) {
await this.removeCertificateFromDisk(originalHost, host);
return;
}
const globalConfig: GlobalConfig | null = await GlobalConfigService.findOneBy({
query: {
_id: ObjectID.getZeroObjectID().toString(),
},
select: {
oneuptimeSslCertificate: true,
oneuptimeSslCertificateKey: true,
},
props: {
isRoot: true,
},
});
if (!globalConfig) {
logger.error(
"GlobalConfig record not found while attempting to write OneUptime SSL certificate to disk.",
);
await this.removeCertificateFromDisk(originalHost, host);
return;
}
const certificate: string | undefined =
globalConfig.oneuptimeSslCertificate || undefined;
const certificateKey: string | undefined =
globalConfig.oneuptimeSslCertificateKey || undefined;
if (!certificate || !certificateKey) {
await this.removeCertificateFromDisk(originalHost, host);
return;
}
try {
await LocalFile.makeDirectory(CERT_DIRECTORY);
} catch (err) {
logger.error("Failed to ensure directory for OneUptime certificates exists.");
logger.error(err);
return;
}
const fileVariants: Array<string> = [host];
if (originalHost && originalHost !== host) {
fileVariants.push(originalHost);
}
for (const variant of fileVariants) {
const certPath: string = `${CERT_DIRECTORY}/${variant}.crt`;
const keyPath: string = `${CERT_DIRECTORY}/${variant}.key`;
await LocalFile.write(certPath, certificate);
await LocalFile.write(keyPath, certificateKey);
}
logger.debug(
`Wrote primary OneUptime SSL certificate to disk for host variants: ${fileVariants.join(",")}`,
);
}
private static async removeCertificateFromDisk(
originalHost: string,
host: string,
): Promise<void> {
if (!host && !originalHost) {
return;
}
const variants: Set<string> = new Set<string>();
if (host) {
variants.add(host);
}
if (originalHost) {
variants.add(originalHost);
}
for (const variant of variants) {
const certPath: string = `${CERT_DIRECTORY}/${variant}.crt`;
const keyPath: string = `${CERT_DIRECTORY}/${variant}.key`;
await LocalFile.deleteFile(certPath).catch(() => {
// ignore delete errors.
});
await LocalFile.deleteFile(keyPath).catch(() => {
// ignore delete errors.
});
}
logger.debug(
`Removed OneUptime SSL certificate artifacts from disk for host variants: ${Array.from(variants).join(",")}`,
);
}
}

View File

@@ -417,8 +417,10 @@ server {
listen ${NGINX_LISTEN_ADDRESS}7849 ${NGINX_LISTEN_OPTIONS};
http2 on;
${ONEUPTIME_SSL_LISTEN_DIRECTIVES}
server_name localhost ingress ${HOST}; #All domains
${ONEUPTIME_SSL_CERT_DIRECTIVES}
proxy_busy_buffers_size 512k;
proxy_buffers 4 512k;
@@ -441,6 +443,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
${ONEUPTIME_SSL_REDIRECT_SNIPPET}
# If billing_enabled is true then proxy to home otherwise to dashboard because we dont need marketing paages for on-prem install.
if ($billing_enabled = true) {
@@ -452,6 +455,21 @@ server {
}
}
# ACME challenge endpoint for OneUptime host cert provisioning.
location /.well-known {
resolver 127.0.0.1 valid=30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://app/api/status-page/.well-known;
}
# PWA manifest and service worker with proper headers for home
location ~* ^/(manifest\.json|service-worker\.js)$ {
resolver 127.0.0.1 valid=30s;

View File

@@ -1,5 +1,35 @@
#!/bin/bash
if [ "${ENABLE_SSL_PROVIONING_FOR_ONEUPTIME}" = "true" ] && [ -n "${HOST}" ]; then
ONEUPTIME_SSL_LISTEN_DIRECTIVES=$(cat <<'EOF'
listen ${NGINX_LISTEN_ADDRESS}7850 ssl http2 ${NGINX_LISTEN_OPTIONS};
EOF
)
ONEUPTIME_SSL_CERT_DIRECTIVES=$(cat <<'EOF'
ssl_certificate /etc/nginx/certs/OneUptime/$ssl_server_name.crt;
ssl_certificate_key /etc/nginx/certs/OneUptime/$ssl_server_name.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
EOF
)
ONEUPTIME_SSL_REDIRECT_SNIPPET=$(cat <<'EOF'
if ($scheme = "http") {
return 301 https://$host$request_uri;
}
EOF
)
else
export ONEUPTIME_SSL_LISTEN_DIRECTIVES=""
export ONEUPTIME_SSL_CERT_DIRECTIVES=""
export ONEUPTIME_SSL_REDIRECT_SNIPPET=""
fi
export ONEUPTIME_SSL_LISTEN_DIRECTIVES
export ONEUPTIME_SSL_CERT_DIRECTIVES
export ONEUPTIME_SSL_REDIRECT_SNIPPET
# Run envsubst on template
/etc/nginx/envsubst-on-templates.sh

View File

@@ -0,0 +1,28 @@
import RunCron from "../../Utils/Cron";
import { EVERY_DAY, EVERY_FIFTEEN_MINUTE } from "Common/Utils/CronTime";
import OneUptimeDate from "Common/Types/Date";
import {
EnableSslProvisioningForOneuptime,
IsDevelopment,
} from "Common/Server/EnvironmentConfig";
import OneuptimeSslCertificateService from "Common/Server/Services/OneuptimeSslCertificateService";
import logger from "Common/Server/Utils/Logger";
RunCron(
"OneuptimeCerts:EnsureProvisioned",
{
schedule: IsDevelopment ? EVERY_FIFTEEN_MINUTE : EVERY_DAY,
runOnStartup: true,
timeoutInMS: OneUptimeDate.convertMinutesToMilliseconds(15),
},
async () => {
if (!EnableSslProvisioningForOneuptime) {
logger.debug(
"ENABLE_SSL_PROVIONING_FOR_ONEUPTIME is disabled. Skipping OneUptime certificate provisioning run.",
);
return;
}
await OneuptimeSslCertificateService.ensureCertificateProvisioned();
},
);

View File

@@ -62,6 +62,7 @@ import "./Jobs/ServerMonitor/CheckOnlineStatus";
// // Certs Routers
import "./Jobs/StatusPageCerts/StatusPageCerts";
import "./Jobs/OneuptimeCerts/OneuptimeCerts";
// Status Page Announcements
import "./Jobs/StatusPageOwners/SendAnnouncementCreatedNotification";

View File

@@ -9,13 +9,12 @@ ONEUPTIME_HTTP_PORT=80
# ==============================================
# SETTING UP TLS/SSL CERTIFICATES
# ==============================================
# OneUptime DOES NOT support setting up SSL/TLS certificates. You need to setup SSL/TLS certificates on your own.
# If you need to use SSL/TLS certificates, then you need to use a reverse proxy like Nginx/Caddy and use LetsEncrypt to provision the certificates.
# You then need to point the reverse proxy to the OneUptime server.
# Once you have done that,
# - You can set the HTTP_PROTOCOL to https
# - Change the HOST to the domain name of the server where reverse proxy is hosted.
# OneUptime can now provision TLS certificates for the primary host via Let's Encrypt.
# - Set ENABLE_SSL_PROVIONING_FOR_ONEUPTIME=true to automatically request and renew certificates inside the ingress container.
# - You must still supply LETS_ENCRYPT_NOTIFICATION_EMAIL and LETS_ENCRYPT_ACCOUNT_KEY for provisioning to succeed.
# If you prefer managing TLS yourself in front of OneUptime, leave this option disabled and continue using an external reverse proxy.
HTTP_PROTOCOL=http
ENABLE_SSL_PROVIONING_FOR_ONEUPTIME=false
# Secrets - PLEASE CHANGE THESE. Please change these to something random. All of these can be different values.
ONEUPTIME_SECRET=please-change-this-to-random-value

View File

@@ -83,6 +83,8 @@ x-common-server-variables: &common-server-variables
<<: *common-variables
ONEUPTIME_SECRET: ${ONEUPTIME_SECRET}
ENABLE_SSL_PROVIONING_FOR_ONEUPTIME: ${ENABLE_SSL_PROVIONING_FOR_ONEUPTIME}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_USERNAME: ${DATABASE_USERNAME}