Skip to content

Commit

Permalink
Automatically replace staging certificates with production
Browse files Browse the repository at this point in the history
  • Loading branch information
Netfloex committed May 12, 2022
1 parent f2ba478 commit 7a560c9
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 53 deletions.
13 changes: 12 additions & 1 deletion Dockerfile.debug
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ FROM nginx:stable-alpine AS runner
# Remove builtin configs from parent container
RUN rm /etc/nginx/conf.d/*

COPY src/nginx/builtin /etc/nginx/conf.d
WORKDIR /app


RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/v3.11/main/ nodejs=12.22.6-r0 yarn
# Dependencies

# Bash
RUN apk add --no-cache bash && \
# Node
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/v3.11/main/ nodejs=12.22.6-r0 && \
# Certbot
apk add --no-cache certbot && \
# Directory for certificates
mkdir -p /var/www/letsencrypt && \
# Yarn for development
apk add --no-cache yarn

ENV FORCE_COLOR 1
ENV DATA_PATH /app/data
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"test": "jest",
"lint": "eslint .",
"ts": "ts-node -T src",
"dev": "tsnd --respawn -T --rs --cls -r tsconfig-paths/register --experimental-modules -- src",
"dev": "tsnd --respawn -T --rs --cls -r tsconfig-paths/register --experimental-modules -- src run",
"prepare": "husky install"
},
"dependencies": {},
Expand Down
19 changes: 11 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,24 +167,22 @@ const main = async ({
);

const sslServers = config.servers.filter((server) => !server.disable_cert);
const serversWithKeys = await filterServersWithValidSslFiles(sslServers);
const serversWithoutKeys = sslServers.filter(
(server) => !serversWithKeys.includes(server)
);
const { validServers, invalidSslServers } =
await filterServersWithValidSslFiles(sslServers);

promises.push(
...createConfigFiles(
[
...serversWithKeys, // SSL enabled servers, with all files for it
...validServers, // SSL enabled servers, with all files for it
...config.servers.filter((server) => server.disable_cert) // SSL Disabled Servers
],
config.username
)
);

promises.push(certbot(serversWithoutKeys));
promises.push(certbot(invalidSslServers));

if (serversWithoutKeys.length) promises.push(createDHPemIfNotExists());
if (invalidSslServers.length) promises.push(createDHPemIfNotExists());

// Make sure all certificate files are created
await Promise.all(promises);
Expand All @@ -193,7 +191,12 @@ const main = async ({
// Try again
await Promise.all(
createConfigFiles(
await filterServersWithValidSslFiles(serversWithoutKeys, true),
await (
await filterServersWithValidSslFiles(
invalidSslServers.map(({ server }) => server),
true
)
).validServers,
config.username
)
);
Expand Down
28 changes: 14 additions & 14 deletions src/lib/certbot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import execSh from "exec-sh";

import { logger } from "@lib/logger";
import { InvalidSslServer } from "@utils/filterServersWithValidSslFiles";
import settings from "@utils/settings";

import { SimpleServer } from "@models/ParsedConfig";

const exec = execSh.promise;

const hasMail = (): boolean => {
Expand Down Expand Up @@ -33,23 +32,23 @@ const certbotDisabled = (): boolean => {
return false;
};

export const certbot = async (servers: SimpleServer[]): Promise<void> => {
if (!servers.length) {
export const certbot = async (
invalidSslServers: InvalidSslServer[]
): Promise<void> => {
if (!invalidSslServers.length) {
return logger.allValid();
}
if (certbotDisabled() || !hasMail() || !(await hasCertbot())) {
return logger.skippingCertbot();
}

logger.requestingCertificates({ count: servers.length });
logger.requestingCertificates({ count: invalidSslServers.length });

const certNames = [
...new Set(
servers.map((server) => server.certbot_name ?? server.server_name)
)
];
for (const invalidSsl of invalidSslServers) {
const certName =
invalidSsl.server.certbot_name ?? invalidSsl.server.server_name;
const force = invalidSsl.reason == "staging";

for (const certName of certNames) {
const command = createShellCommand("certbot certonly", {
"agree-tos": "",
keep: "",
Expand All @@ -61,7 +60,8 @@ export const certbot = async (servers: SimpleServer[]): Promise<void> => {
"preferred-challenges": "http-01",
email: settings.certbotMail,
"non-interactive": "",
...(settings.staging && { "test-cert": "" })
...(settings.staging && { "test-cert": "" }),
...(force && { "force-renewal": "" })
});

await exec(command, true)
Expand All @@ -71,8 +71,8 @@ export const certbot = async (servers: SimpleServer[]): Promise<void> => {
.then((output) => {
if (output && output.stdout) {
logger.certbotLog({
index: certNames.indexOf(certName),
size: certNames.length,
index: invalidSslServers.indexOf(invalidSsl),
size: invalidSslServers.length,
certificate: certName,
log: output.stdout.split("\n")[0]
});
Expand Down
17 changes: 11 additions & 6 deletions src/lib/logMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,18 @@ export const logMessages = defineLogList({
],
certificateValid: ({
serverName,
days = -1
days = -1,
staging
}: {
serverName: string;
days: number;
staging: boolean;
}) => [
Log.info,
Tag.certbot,
chalk`The certificate for {dim ${serverName}} is valid for {bold ${gradientNumber(
Math.round(days),
0,
90
)}} days`
chalk`The certificate for {dim ${serverName}}${
staging ? chalk` ({yellow staging})` : ""
} is valid for {bold ${gradientNumber(Math.round(days), 0, 90)}} days`
],
certificateExpiry: ({
serverName,
Expand All @@ -190,6 +190,11 @@ export const logMessages = defineLogList({
}}`
];
},
certificateStaging: ({ serverName }: { serverName: string }) => [
Log.info,
Tag.certbot,
chalk`{yellow The certificate for {dim ${serverName}} is using the {yellow Staging} environment, renewing for production.}`
],
certificateParseFailed: ({
file,
error
Expand Down
4 changes: 4 additions & 0 deletions src/models/cert2json.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ declare module "cert2json" {
notBefore: Date;
notAfter: Date;
};

issuer: {
full: string;
};
};
}
export const parse: (certificate: string) => Certificate;
Expand Down
1 change: 1 addition & 0 deletions src/tests/__snapshots__/log.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ exports[`Logger All log Messages 1`] = `
"[NCM] [ERROR] [CERTBOT] The certificate files for undefined could not be created, please see the error above. This domain is now disabled.",
"[NCM] [INFO] [CERTBOT] The certificate for undefined is valid for -1 days",
"[NCM] [INFO] [CERTBOT] The certificate for undefined, expired 1 days ago",
"[NCM] [INFO] [CERTBOT] The certificate for undefined is using the Staging environment, renewing for production.",
"[NCM] [ERROR] [CERTBOT] Parsing of the certificate undefined failed: undefined",
"[NCM] [INFO] [DHPARAMS] Creating a 2048 bit Diffie-Hellman parameter... This could take a while.",
"[NCM] [DONE] [DHPARAMS] Diffie-Hellman parameters are created successfully, took NaN seconds. /etc/letsencrypt/dhparams/dhparam.pem",
Expand Down
10 changes: 6 additions & 4 deletions src/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createHash } from "@utils/createHash";
import { dnsLookup } from "@utils/dnsLookup";
import { fixedLength } from "@utils/fixedLength";
import { msToDays } from "@utils/msToDays";
import { parseCertificateExpiry } from "@utils/parseCertificateExpiry";
import { parseCertificateFile } from "@utils/parseCertificateFile";
import { parseIntDefault } from "@utils/parseIntDefault";
import { plural } from "@utils/plural";
import { sslFilesFor } from "@utils/sslFilesFor";
Expand Down Expand Up @@ -68,16 +68,18 @@ describe("Utilities", () => {

test("Parse Certificate Expiry", async () => {
const [exists, notExisting] = await Promise.all([
parseCertificateExpiry(
parseCertificateFile(
join(process.cwd(), "src", "tests", "certs", "example.pem")
),
parseCertificateExpiry("notExisting.pem")
parseCertificateFile("notExisting.pem")
]);

expect(exists).not.toBe(false);
expect(notExisting).toBe(false);
if (exists !== false)
expect(exists.toUTC().toISO()).toEqual("2013-10-04T12:47:15.000Z");
expect(exists.expiry.toISOString()).toEqual(
"2013-10-04T12:47:15.000Z"
);
});

test("Parse Integer with Default", () => {
Expand Down
49 changes: 36 additions & 13 deletions src/utils/filterServersWithValidSslFiles.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { pathExists } from "fs-extra";

import { logger } from "@lib/logger";
import { parseCertificateExpiry } from "@utils/parseCertificateExpiry";
import { msToDays } from "@utils/msToDays";
import { parseCertificateFile } from "@utils/parseCertificateFile";
import settings from "@utils/settings";
import { sslFileFor, sslFilesFor } from "@utils/sslFilesFor";

import { SimpleServer } from "@models/ParsedConfig";

export interface InvalidSslServer {
server: SimpleServer;
reason: "expired" | "staging" | "missing";
}

export interface FilteredServers {
validServers: SimpleServer[];
invalidSslServers: InvalidSslServer[];
}

/**
* Returns a list of servers which has valid certificates
*
Expand All @@ -20,8 +31,9 @@ import { SimpleServer } from "@models/ParsedConfig";
export const filterServersWithValidSslFiles = async (
servers: SimpleServer[],
last = false
): Promise<SimpleServer[]> => {
const out: SimpleServer[] = [];
): Promise<FilteredServers> => {
const validServers: SimpleServer[] = [];
const invalidSslServers: InvalidSslServer[] = [];

server: for (const server of servers) {
const sslFiles = sslFilesFor(server);
Expand All @@ -42,35 +54,46 @@ export const filterServersWithValidSslFiles = async (
This setting allows for a config without certificate files
If it is the second try, return all configs
*/

if (settings.enableConfigMissingCerts) {
out.push(server);
validServers.push(server);
continue server;
}
}
invalidSslServers.push({ server, reason: "missing" });

continue server;
}
}

const expiry = await parseCertificateExpiry(
const certificate = await parseCertificateFile(
sslFileFor(server, "cert.pem")
);

if (!expiry) continue server;
const days = expiry.diffNow().as("days");
if (certificate == false) continue server;
const days = msToDays(certificate.expiry.valueOf() - Date.now());

if (days < 30) {
// Certificate expires in less than 30 days
logger.certificateExpiry({
serverName: server.server_name,
days: days
days
});
continue server;
}

logger.certificateValid({ serverName: server.server_name, days: days });
invalidSslServers.push({ server, reason: "expired" });
} else if (certificate.staging && settings.staging == false) {
console.log("Is Staging");
invalidSslServers.push({ server, reason: "staging" });
} else {
logger.certificateValid({
serverName: server.server_name,
days,
staging: certificate.staging
});

out.push(server);
validServers.push(server);
}
}

return out;
return { invalidSslServers, validServers };
};
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { parse } from "cert2json";
import { pathExists, readFile } from "fs-extra";
import { DateTime } from "luxon";

import { logger } from "@lib/logger";

/**
* Checks the expiry of a certificate file
* Parses a certificate file
* Returns useful information
* or false if the file does not exist
* @param certificateFile The path to the certificate
* @returns A {@link DateTime} when the certificate expires
* @returns An object containing expiry and whether the certificate is staging
*/

export const parseCertificateExpiry = async (
export interface ParsedCertificate {
expiry: Date;
staging: boolean;
}

export const parseCertificateFile = async (
certificateFile: string
): Promise<DateTime | false> => {
): Promise<ParsedCertificate | false> => {
if (!(await pathExists(certificateFile))) {
logger.certificateParseFailed({
file: certificateFile,
Expand All @@ -25,7 +31,10 @@ export const parseCertificateExpiry = async (

try {
const cert = parse(certificate);
return DateTime.fromJSDate(cert.tbs.validity.notAfter);
return {
expiry: cert.tbs.validity.notAfter,
staging: cert.tbs.issuer.full.includes("STAGING")
};
} catch (error) {
if (error instanceof Error)
logger.certificateParseFailed({
Expand Down

0 comments on commit 7a560c9

Please sign in to comment.