From 16903ae9efe0766b4e72db2a2d2d96ae7f690bfa Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Thu, 30 Oct 2025 22:25:04 +0000 Subject: [PATCH] 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 --- .gitignore | 2 + Common/Models/DatabaseModels/GlobalConfig.ts | 70 +++++++++ Common/Server/EnvironmentConfig.ts | 3 + .../OneuptimeSslCertificateService.ts | 143 ++++++++++++++++++ Common/Server/Utils/Greenlock/Greenlock.ts | 125 +++++++++------ HelmChart/Public/oneuptime/README.md | 1 + .../Public/oneuptime/templates/_helpers.tpl | 2 + HelmChart/Public/oneuptime/values.schema.json | 5 + HelmChart/Public/oneuptime/values.yaml | 1 + Nginx/Index.ts | 2 + Nginx/Jobs/WriteOneuptimeCertToDisk.ts | 128 ++++++++++++++++ Nginx/default.conf.template | 18 +++ Nginx/run.sh | 30 ++++ Worker/Jobs/OneuptimeCerts/OneuptimeCerts.ts | 28 ++++ Worker/Routes.ts | 1 + config.example.env | 11 +- docker-compose.base.yml | 2 + 17 files changed, 517 insertions(+), 55 deletions(-) create mode 100644 Common/Server/Services/OneuptimeSslCertificateService.ts create mode 100644 Nginx/Jobs/WriteOneuptimeCertToDisk.ts create mode 100644 Worker/Jobs/OneuptimeCerts/OneuptimeCerts.ts diff --git a/.gitignore b/.gitignore index 905957c45c..75d6a76ff9 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,8 @@ logs.txt Certs/StatusPageCerts/*.crt Certs/StatusPageCerts/*.key +Certs/OneUptime/*.crt +Certs/OneUptime/*.key Certs/ServerCerts/*.crt Certs/ServerCerts/*.key diff --git a/Common/Models/DatabaseModels/GlobalConfig.ts b/Common/Models/DatabaseModels/GlobalConfig.ts index 573e6e01da..61d7cbbcf9 100644 --- a/Common/Models/DatabaseModels/GlobalConfig.ts +++ b/Common/Models/DatabaseModels/GlobalConfig.ts @@ -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; } diff --git a/Common/Server/EnvironmentConfig.ts b/Common/Server/EnvironmentConfig.ts index a8dbce614e..0859283cea 100644 --- a/Common/Server/EnvironmentConfig.ts +++ b/Common/Server/EnvironmentConfig.ts @@ -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"; diff --git a/Common/Server/Services/OneuptimeSslCertificateService.ts b/Common/Server/Services/OneuptimeSslCertificateService.ts new file mode 100644 index 0000000000..e757a1b290 --- /dev/null +++ b/Common/Server/Services/OneuptimeSslCertificateService.ts @@ -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 { + 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); + } +} diff --git a/Common/Server/Utils/Greenlock/Greenlock.ts b/Common/Server/Utils/Greenlock/Greenlock.ts index 210f10d382..210c5bb382 100644 --- a/Common/Server/Utils/Greenlock/Greenlock.ts +++ b/Common/Server/Utils/Greenlock/Greenlock.ts @@ -135,6 +135,13 @@ export default class GreenlockUtil { public static async orderCert(data: { domain: string; validateCname: (domain: string) => Promise; + onCertificateIssued?: (info: { + certificate: string; + certificateKey: string; + issuedAt: Date; + expiresAt: Date; + }) => Promise; + persistAcmeCertificate?: boolean; }): Promise { 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}`); diff --git a/HelmChart/Public/oneuptime/README.md b/HelmChart/Public/oneuptime/README.md index ef8c2fa52a..a817f30000 100644 --- a/HelmChart/Public/oneuptime/README.md +++ b/HelmChart/Public/oneuptime/README.md @@ -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` | | diff --git a/HelmChart/Public/oneuptime/templates/_helpers.tpl b/HelmChart/Public/oneuptime/templates/_helpers.tpl index 7c99a86a65..16d8f0649d 100644 --- a/HelmChart/Public/oneuptime/templates/_helpers.tpl +++ b/HelmChart/Public/oneuptime/templates/_helpers.tpl @@ -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 }} diff --git a/HelmChart/Public/oneuptime/values.schema.json b/HelmChart/Public/oneuptime/values.schema.json index 39bf149115..da1b1e1895 100644 --- a/HelmChart/Public/oneuptime/values.schema.json +++ b/HelmChart/Public/oneuptime/values.schema.json @@ -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"] }, diff --git a/HelmChart/Public/oneuptime/values.yaml b/HelmChart/Public/oneuptime/values.yaml index 31976277b1..07e8e35eda 100644 --- a/HelmChart/Public/oneuptime/values.yaml +++ b/HelmChart/Public/oneuptime/values.yaml @@ -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. diff --git a/Nginx/Index.ts b/Nginx/Index.ts index d03a9fd248..c5da06eb07 100644 --- a/Nginx/Index.ts +++ b/Nginx/Index.ts @@ -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 => { AcmeWriteCertificatesJob.init(); WriteCustomCertsToDiskJob.init(); + WriteOneuptimeCertToDiskJob.init(); // add default routes await App.addDefaultRoutes(); diff --git a/Nginx/Jobs/WriteOneuptimeCertToDisk.ts b/Nginx/Jobs/WriteOneuptimeCertToDisk.ts new file mode 100644 index 0000000000..1b40c6b0b3 --- /dev/null +++ b/Nginx/Jobs/WriteOneuptimeCertToDisk.ts @@ -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 { + 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 = [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 { + if (!host && !originalHost) { + return; + } + + const variants: Set = new Set(); + + 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(",")}`, + ); + } +} diff --git a/Nginx/default.conf.template b/Nginx/default.conf.template index 198c9eda2c..baaaa570e6 100644 --- a/Nginx/default.conf.template +++ b/Nginx/default.conf.template @@ -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; diff --git a/Nginx/run.sh b/Nginx/run.sh index 67eb88257c..2ffa259dc5 100644 --- a/Nginx/run.sh +++ b/Nginx/run.sh @@ -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 diff --git a/Worker/Jobs/OneuptimeCerts/OneuptimeCerts.ts b/Worker/Jobs/OneuptimeCerts/OneuptimeCerts.ts new file mode 100644 index 0000000000..992176a3a8 --- /dev/null +++ b/Worker/Jobs/OneuptimeCerts/OneuptimeCerts.ts @@ -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(); + }, +); diff --git a/Worker/Routes.ts b/Worker/Routes.ts index f5c1d84fa0..b21e110c95 100644 --- a/Worker/Routes.ts +++ b/Worker/Routes.ts @@ -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"; diff --git a/config.example.env b/config.example.env index f69cb45dcb..7693b05dcd 100644 --- a/config.example.env +++ b/config.example.env @@ -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 diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 2b53e21428..59a8566ebb 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -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}