diff --git a/Dockerfile b/Dockerfile index f259564..7a833ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,16 @@ FROM node:21-alpine -RUN mkdir /app + +RUN apk add --no-cache openssl + WORKDIR /app -COPY . . -RUN mkdir dist + +COPY package*.json ./ RUN npm install +COPY . . + +RUN npm run build + VOLUME /app/vol -CMD npm run start \ No newline at end of file +CMD npm start \ No newline at end of file diff --git a/README.md b/README.md index 07dc36d..5787775 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,12 @@ const config: { type Domain = { /** * Name of folder with domain certificates + * If you leave it blank and AUTO_CERTS - 1, certificate will be generated automatically * Will use this two files: * * %CERTS_PATH%/%cert-name%/privkey.pem * * %CERTS_PATH%/%cert-name%/fullchain.pem */ - 'cert-name': string + 'cert-name'?: string } & { [path: string]: StaticProxyInfo | DynamicProxyInfo } diff --git a/docker-compose.yml b/docker-compose.yml index cc00fae..1d5499a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: # VOLUME_PATH: '/app/vol' # Not required because its '/app/vol' by default CERTS_PATH: /etc/letsencrypt/live/ # Will be ignored because LOAD_CERTS_FROM_VOLUME is 1 LOAD_CERTS_FROM_VOLUME: 1 # CERTS_PATH will be inherited from VOLUME_PATH + AUTO_CERTS: 1 # Generate a cert for domain if field 'cert-name' is missed networks: - your_network # choose network with your containers volumes: diff --git a/package.json b/package.json index 1b558ac..2f68476 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "dist/index.js", "scripts": { - "start": "tsc && node dist/index.js" + "build": "tsc", + "start": "node dist/index.js" }, "author": "", "license": "ISC", diff --git a/src/config.ts b/src/config.ts index dafb75b..1cd5d32 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,7 +28,6 @@ export class Config implements IConfig { return { domains: { "test.com": { - [CERT_NAME]: "test.com", "/": { domain: "web-docker-container", port: 8080 @@ -41,7 +40,6 @@ export class Config implements IConfig { }, }, "mirror.test.com": { - [CERT_NAME]: "test.com", "/": { domain: "test.cc", https: true, @@ -50,7 +48,7 @@ export class Config implements IConfig { } }, "static.test.com": { - [CERT_NAME]: "test.com", + [CERT_NAME]: "custom-certificate-folder", "/": { folder: "volFolder", cors: '*' @@ -106,6 +104,7 @@ export class Config implements IConfig { key, typeof val[CERT_NAME] == 'string' ? val[CERT_NAME] : null ]) + .filter(([_, value]) => value) ) } diff --git a/src/https.ts b/src/https.ts index 3a567b5..28895cf 100644 --- a/src/https.ts +++ b/src/https.ts @@ -4,37 +4,123 @@ import tls from "tls" import fs from "fs" import { Config } from "./config" import { join } from "path" +import { execSync } from "child_process" +export function getHttps(cfg: Config, certsPath: string, autoCerts: boolean) { + const secureContexts = getSecureContexts(cfg, certsPath) -export function getHttps(cfg: Config, letsencryptPath: string) { - const secureContexts = getSecureContexts(cfg, letsencryptPath) + function resolveCert(cb: (...args: any[]) => void, domain: string) { + const ctx = secureContexts[domain] + if (cb) { + ctx ? cb(null, ctx) : cb(Error(`No ctx for domain ${domain}`)) + return + } else if (ctx) { + return ctx + } + + throw Error(`No ctx for domain ${domain}`) + } const options: https.ServerOptions = { // A function that will be called if the client supports SNI TLS extension. SNICallback: (domain, cb) => { + if (!(domain in secureContexts) && autoCerts) { + const certName = `${domain}-auto-generated` + const certDir = getCertPath(certsPath, certName) + console.log(`${certName} certificate verification`) - const ctx = secureContexts[domain] + try { + if (fs.existsSync(certDir)) { + if (isValidCert(certsPath, certName)) { + console.log("Found!") + secureContexts[domain] = loadCert(certName, certsPath) + return resolveCert(cb, domain) + } - if (cb) { - ctx ? cb(null, ctx) : cb(Error("No ctx")) - } else if (ctx) { - return ctx + fs.rmSync(certDir, { recursive: true, force: true }) + } + else + console.log("Not found!") + + console.log("Generating a new certificate...") + + secureContexts[domain] = generateCert(domain, certName, certsPath) + + console.log("Generated!") + } catch (error) { + console.error("Certificate handling failed:", error) + } } + return resolveCert(cb, domain) }, } return (app: RequestListener) => https.createServer(options, app) } -function getSecureContexts(config: Config, letsencryptPath: string) { +function getCertPath(certsPath: string, certName: string, key: string | null = null) { + return key ? join(certsPath, certName, `${key}.pem`) : join(certsPath, certName) +} + +function isValidCert(certsPath: string, certName: string) { + try { + const fullchainPath = getCertPath(certsPath, certName, "fullchain"); + const privkeyPath = getCertPath(certsPath, certName, "privkey"); + + // Check if both certificate and key exist + if (!fs.existsSync(fullchainPath) + || !fs.existsSync(privkeyPath)) + throw new Error("Certificate was deleted") + + // Verify certificate validity using OpenSSL's built-in check + const checkSeconds = 30 * 86400; // 30 days in seconds + execSync( + `openssl x509 -checkend ${checkSeconds} -noout -in "${fullchainPath}"`, + { stdio: 'ignore' } + ); + + // Additional verification of the key pair + execSync( + `openssl rsa -check -noout -in "${privkeyPath}"`, + { stdio: 'ignore' } + ); + + return true; + } catch (error) { + console.debug("Certificate validation failed:", error); + return false; + } +} + +function generateCert(domain: string, certName: string, certsPath: string) { + const certDir = getCertPath(certsPath, certName) + fs.mkdirSync(certDir, { recursive: true }) + + const privkeyPath = getCertPath(certsPath, certName, "privkey") + const fullchainPath = getCertPath(certsPath, certName, "fullchain") + + // Generate private key + execSync(`openssl genrsa -out ${privkeyPath} 2048`) + + // Generate self-signed certificate + execSync(`openssl req -new -x509 -key ${privkeyPath} -out ${fullchainPath} -days 395 -subj "/CN=${domain}"`) + + return loadCert(certName, certsPath) +} + +function loadCert(certName: string, certsPath: string) { + return tls.createSecureContext({ + key: fs.readFileSync(getCertPath(certsPath, certName, "privkey")), + cert: fs.readFileSync(getCertPath(certsPath, certName, "fullchain")), + ca: null, + }) +} + +/** Obtaining certs specified by the user in the 'cert-name' field */ +function getSecureContexts(config: Config, certsPath: string) { return Object.fromEntries( Object.entries(config.getCerts()) - .filter(([_, value]) => value) .map(([key, value]) => - [key, tls.createSecureContext({ - key: fs.readFileSync(join(letsencryptPath, value, "/privkey.pem")), - cert: fs.readFileSync(join(letsencryptPath, value, "/fullchain.pem")), - ca: null, - })]) + [key, loadCert(value, certsPath)]) ) } diff --git a/src/index.ts b/src/index.ts index 773aec5..bc7d0c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { env } from "./env" const HTTP_PORT = env.HTTP_PORT(52080), HTTPS_PORT = env.HTTPS_PORT(-1), VOLUME_PATH = env.VOLUME_PATH('./vol'), + AUTO_CERTS = env.AUTO_CERTS(1) != 0, CERTS_PATH = env.LOAD_CERTS_FROM_VOLUME(0) ? VOLUME_PATH : env.CERTS_PATH('/etc/letsencrypt/live/') console.log("ENV Loaded:", { @@ -25,7 +26,7 @@ async function main() { console.log("Listening http on port: " + HTTP_PORT) }) if (HTTPS_PORT != -1) { - const httpsServer = getHttps(config, CERTS_PATH) + const httpsServer = getHttps(config, CERTS_PATH, AUTO_CERTS) httpsServer(app).on('upgrade', upgrade).listen(HTTPS_PORT, function () { console.log("Listening https on port: " + HTTPS_PORT) })