yusufkhon-guard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +734 -0
- package/bin/cli.js +39 -0
- package/bin/renew.js +149 -0
- package/bin/scan.js +124 -0
- package/bin/ssl.js +181 -0
- package/package.json +80 -0
- package/src/index.js +206 -0
- package/src/modules/acme/client.js +240 -0
- package/src/modules/acme/csr.js +172 -0
- package/src/modules/acme/detectDomains.js +132 -0
- package/src/modules/acme/index.js +211 -0
- package/src/modules/acme/jose.js +125 -0
- package/src/modules/acme/renew.js +89 -0
- package/src/modules/adaptiveRateLimiter.js +154 -0
- package/src/modules/alerts.js +168 -0
- package/src/modules/bodyLimit.js +106 -0
- package/src/modules/botDetector.js +144 -0
- package/src/modules/connectionGuard.js +128 -0
- package/src/modules/csrf.js +130 -0
- package/src/modules/dashboard.js +257 -0
- package/src/modules/dashboardHtml.js +235 -0
- package/src/modules/datacenterRanges.js +121 -0
- package/src/modules/ddosGuard.js +323 -0
- package/src/modules/firewall.js +239 -0
- package/src/modules/geoBlock.js +169 -0
- package/src/modules/headers.js +130 -0
- package/src/modules/honeypot.js +149 -0
- package/src/modules/httpsRedirect.js +67 -0
- package/src/modules/integrity.js +164 -0
- package/src/modules/ipBlocker.js +372 -0
- package/src/modules/ja3.js +155 -0
- package/src/modules/logExport.js +134 -0
- package/src/modules/nosqlGuard.js +143 -0
- package/src/modules/osFirewall.js +182 -0
- package/src/modules/rateLimiter.js +196 -0
- package/src/modules/sanitizer.js +253 -0
- package/src/modules/threatDetector.js +202 -0
- package/src/scanner/checks/dependencies.js +125 -0
- package/src/scanner/checks/environment.js +118 -0
- package/src/scanner/checks/exposure.js +99 -0
- package/src/scanner/checks/headers.js +119 -0
- package/src/scanner/checks/ssl.js +151 -0
- package/src/scanner/index.js +209 -0
- package/src/utils/cidr.js +140 -0
- package/src/utils/logger.js +194 -0
- package/src/utils/privilege.js +212 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file detectDomains.js
|
|
5
|
+
* @description VPS'da sozlangan domen(lar)ni AVTOMAT aniqlaydi — foydalanuvchi
|
|
6
|
+
* `--domain` bermasa, `npx yusufkhon-guard ssl` o'zi topib, admin'ga
|
|
7
|
+
* tasdiqlash uchun ko'rsatadi.
|
|
8
|
+
*
|
|
9
|
+
* Manbalar: nginx `server_name`, apache `ServerName`/`ServerAlias`,
|
|
10
|
+
* tizim hostnomi (FQDN bo'lsa). Yo'llar test uchun sozlanadi.
|
|
11
|
+
* @module modules/acme/detectDomains
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
/** Standart nginx konfiguratsiya yo'llari. @type {string[]} */
|
|
19
|
+
const DEFAULT_NGINX = ['/etc/nginx/sites-enabled', '/etc/nginx/conf.d', '/etc/nginx/nginx.conf'];
|
|
20
|
+
/** Standart apache konfiguratsiya yo'llari. @type {string[]} */
|
|
21
|
+
const DEFAULT_APACHE = ['/etc/apache2/sites-enabled', '/etc/httpd/conf.d'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Nomzod haqiqiy, ommaviy domenmi? (localhost, IP, wildcard, .local rad etiladi)
|
|
25
|
+
* @param {string} d
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isValidDomain(d) {
|
|
29
|
+
if (!d) return false;
|
|
30
|
+
const s = String(d).toLowerCase().trim().replace(/;$/, '');
|
|
31
|
+
if (s === 'localhost' || s === '_' || s === '""') return false;
|
|
32
|
+
if (s.startsWith('*.') || s.includes('*')) return false; // wildcard -> DNS-01 kerak
|
|
33
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return false; // IPv4
|
|
34
|
+
if (s.includes(':')) return false; // IPv6/port
|
|
35
|
+
if (/\.(local|lan|localdomain|internal|test|example|invalid)$/.test(s)) return false;
|
|
36
|
+
if (!s.includes('.')) return false; // FQDN bo'lishi shart
|
|
37
|
+
return /^[a-z0-9.-]+$/.test(s) && !s.startsWith('.') && !s.endsWith('.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fayl yoki papkadan barcha matn kontentini o'qiydi (xatolarni e'tiborsiz qoldiradi).
|
|
42
|
+
* @param {string} target
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
function readAll(target) {
|
|
46
|
+
const out = [];
|
|
47
|
+
try {
|
|
48
|
+
const st = fs.statSync(target);
|
|
49
|
+
if (st.isDirectory()) {
|
|
50
|
+
for (const name of fs.readdirSync(target)) {
|
|
51
|
+
try { out.push(fs.readFileSync(path.join(target, name), 'utf8')); } catch { /* skip */ }
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
out.push(fs.readFileSync(target, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
} catch { /* mavjud emas — skip */ }
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* nginx kontentidan `server_name` domenlarini ajratadi.
|
|
62
|
+
* @param {string} content
|
|
63
|
+
* @returns {string[]}
|
|
64
|
+
*/
|
|
65
|
+
function parseNginx(content) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const re = /server_name\s+([^;]+);/gi;
|
|
68
|
+
let m;
|
|
69
|
+
while ((m = re.exec(content))) {
|
|
70
|
+
for (const tok of m[1].trim().split(/\s+/)) out.push(tok);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* apache kontentidan `ServerName`/`ServerAlias` domenlarini ajratadi.
|
|
77
|
+
* @param {string} content
|
|
78
|
+
* @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
function parseApache(content) {
|
|
81
|
+
const out = [];
|
|
82
|
+
let m;
|
|
83
|
+
const reName = /^\s*ServerName\s+(\S+)/gim;
|
|
84
|
+
while ((m = reName.exec(content))) out.push(m[1]);
|
|
85
|
+
const reAlias = /^\s*ServerAlias\s+([^\n]+)/gim;
|
|
86
|
+
while ((m = reAlias.exec(content))) for (const tok of m[1].trim().split(/\s+/)) out.push(tok);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {Object} DetectedDomain
|
|
92
|
+
* @property {string} domain
|
|
93
|
+
* @property {string} source - 'nginx' | 'apache' | 'hostname'
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* VPS'dagi sozlangan domenlarni aniqlaydi.
|
|
98
|
+
* @param {Object} [options]
|
|
99
|
+
* @param {string[]} [options.nginxPaths] - nginx yo'llari (test uchun).
|
|
100
|
+
* @param {string[]} [options.apachePaths] - apache yo'llari (test uchun).
|
|
101
|
+
* @param {string} [options.hostname] - tizim hostnomi (test uchun).
|
|
102
|
+
* @returns {DetectedDomain[]} Takrorlanmas, haqiqiy domenlar (aniqlanish tartibida).
|
|
103
|
+
*/
|
|
104
|
+
function detectDomains(options = {}) {
|
|
105
|
+
/** @type {DetectedDomain[]} */
|
|
106
|
+
const found = [];
|
|
107
|
+
const add = (domain, source) => {
|
|
108
|
+
const d = String(domain).toLowerCase().trim().replace(/;$/, '');
|
|
109
|
+
if (isValidDomain(d) && !found.some((f) => f.domain === d)) found.push({ domain: d, source });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const p of options.nginxPaths || DEFAULT_NGINX) {
|
|
113
|
+
for (const content of readAll(p)) for (const d of parseNginx(content)) add(d, 'nginx');
|
|
114
|
+
}
|
|
115
|
+
for (const p of options.apachePaths || DEFAULT_APACHE) {
|
|
116
|
+
for (const content of readAll(p)) for (const d of parseApache(content)) add(d, 'apache');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const hostname = options.hostname !== undefined ? options.hostname : os.hostname();
|
|
120
|
+
add(hostname, 'hostname');
|
|
121
|
+
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
detectDomains,
|
|
127
|
+
isValidDomain,
|
|
128
|
+
parseNginx,
|
|
129
|
+
parseApache,
|
|
130
|
+
DEFAULT_NGINX,
|
|
131
|
+
DEFAULT_APACHE,
|
|
132
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file acme/index.js
|
|
5
|
+
* @description Let's Encrypt (ACME v2) uchun yuqori darajali, foydalanish oson API.
|
|
6
|
+
* HTTP-01 challenge'ni bajarish orqali domen uchun bepul SSL/TLS
|
|
7
|
+
* sertifikatini avtomat oladi. Tashqi kutubxonasiz (faqat Node ichki
|
|
8
|
+
* `crypto`, `fetch`, `fs`).
|
|
9
|
+
*
|
|
10
|
+
* DIQQAT: HTTP-01 uchun domen 80-portda ilovangizga yo'naltirilgan
|
|
11
|
+
* va internetdan ochiq bo'lishi kerak. Challenge middleware'ni
|
|
12
|
+
* firewall'dan OLDIN ulang.
|
|
13
|
+
* @module modules/acme
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const jose = require('./jose');
|
|
19
|
+
const { createCsr } = require('./csr');
|
|
20
|
+
const { AcmeClient, DIRECTORY_URLS } = require('./client');
|
|
21
|
+
const { detectDomains } = require('./detectDomains');
|
|
22
|
+
const { checkCert, needsRenewal, certDomains, daysUntilExpiry } = require('./renew');
|
|
23
|
+
const { defaultLogger } = require('../../utils/logger');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HTTP-01 challenge javoblarini saqlovchi oddiy xotira ombori.
|
|
27
|
+
* @class
|
|
28
|
+
*/
|
|
29
|
+
class ChallengeStore {
|
|
30
|
+
constructor() {
|
|
31
|
+
/** @type {Map<string, string>} token -> keyAuthorization */
|
|
32
|
+
this.map = new Map();
|
|
33
|
+
}
|
|
34
|
+
/** @param {string} token @param {string} keyAuth @returns {void} */
|
|
35
|
+
set(token, keyAuth) { this.map.set(token, keyAuth); }
|
|
36
|
+
/** @param {string} token @returns {string|undefined} */
|
|
37
|
+
get(token) { return this.map.get(token); }
|
|
38
|
+
/** @param {string} token @returns {void} */
|
|
39
|
+
remove(token) { this.map.delete(token); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `/.well-known/acme-challenge/<token>` so'rovlariga javob beruvchi Express
|
|
44
|
+
* middleware. Uni firewall/guard'dan OLDIN ulang.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const acme = require('yusufkhon-guard').acme;
|
|
48
|
+
* const store = new acme.ChallengeStore();
|
|
49
|
+
* app.use(acme.challengeMiddleware(store)); // guard'dan oldin!
|
|
50
|
+
*
|
|
51
|
+
* @param {ChallengeStore} store
|
|
52
|
+
* @returns {import('express').RequestHandler}
|
|
53
|
+
*/
|
|
54
|
+
function challengeMiddleware(store) {
|
|
55
|
+
const PREFIX = '/.well-known/acme-challenge/';
|
|
56
|
+
return function acmeChallengeMiddleware(req, res, next) {
|
|
57
|
+
const url = req.originalUrl || req.url || '';
|
|
58
|
+
if (req.method === 'GET' && url.startsWith(PREFIX)) {
|
|
59
|
+
const token = url.slice(PREFIX.length).split('?')[0];
|
|
60
|
+
const keyAuth = store.get(token);
|
|
61
|
+
if (keyAuth) {
|
|
62
|
+
res.statusCode = 200;
|
|
63
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
64
|
+
res.end(keyAuth);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.statusCode = 404;
|
|
68
|
+
res.end('Not found');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
return next();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hisob kalitini fayldan yuklaydi yoki yangisini yaratib saqlaydi.
|
|
77
|
+
* @private
|
|
78
|
+
* @param {string|undefined} keyPath
|
|
79
|
+
* @param {import('../../utils/logger').Logger} logger
|
|
80
|
+
* @returns {import('crypto').KeyObject}
|
|
81
|
+
*/
|
|
82
|
+
function loadOrCreateAccountKey(keyPath, logger) {
|
|
83
|
+
if (keyPath && fs.existsSync(keyPath)) {
|
|
84
|
+
logger.info('Mavjud ACME hisob kaliti yuklandi.');
|
|
85
|
+
return jose.importPrivateKeyPem(fs.readFileSync(keyPath, 'utf8'));
|
|
86
|
+
}
|
|
87
|
+
const { privateKey } = jose.generateAccountKey();
|
|
88
|
+
if (keyPath) {
|
|
89
|
+
fs.mkdirSync(path.dirname(path.resolve(keyPath)), { recursive: true });
|
|
90
|
+
fs.writeFileSync(keyPath, jose.exportPrivateKeyPem(privateKey), { mode: 0o600 });
|
|
91
|
+
logger.info('Yangi ACME hisob kaliti yaratildi va saqlandi.');
|
|
92
|
+
}
|
|
93
|
+
return privateKey;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {Object} ObtainOptions
|
|
98
|
+
* @property {string|string[]} domains - Sertifikat qamrab oladigan domen(lar).
|
|
99
|
+
* @property {string} [email] - Bog'lanish uchun email (tavsiya etiladi).
|
|
100
|
+
* @property {ChallengeStore} challengeStore - challengeMiddleware bilan bir xil ombor.
|
|
101
|
+
* @property {'staging'|'production'} [environment='staging'] - Sinov (staging) yoki jonli.
|
|
102
|
+
* @property {string} [certDir='./certs'] - Sertifikat saqlanadigan papka.
|
|
103
|
+
* @property {string} [accountKeyPath] - Hisob kaliti saqlanadigan fayl (qayta ishlatish uchun).
|
|
104
|
+
* @property {import('../../utils/logger').Logger} [logger=defaultLogger] - Logger.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {Object} ObtainResult
|
|
109
|
+
* @property {string} certificate - PEM sertifikat zanjiri (fullchain).
|
|
110
|
+
* @property {string} privateKey - Domen yopiq kaliti (PEM).
|
|
111
|
+
* @property {{ fullchain: string, privkey: string }} paths - Yozilgan fayllar yo'llari.
|
|
112
|
+
* @property {string} environment - Ishlatilgan muhit.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Domen(lar) uchun ACME (Let's Encrypt) orqali SSL sertifikatini to'liq avtomat oladi.
|
|
117
|
+
*
|
|
118
|
+
* Standart holatda **staging** muhitida ishlaydi (soxta, lekin xavfsiz sertifikat) —
|
|
119
|
+
* jonli sertifikat uchun `environment: 'production'` bering.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* const acme = require('yusufkhon-guard').acme;
|
|
123
|
+
* const store = new acme.ChallengeStore();
|
|
124
|
+
* app.use(acme.challengeMiddleware(store));
|
|
125
|
+
* // ... server 80-portda ishga tushgach:
|
|
126
|
+
* const res = await acme.obtainCertificate({
|
|
127
|
+
* domains: 'example.com',
|
|
128
|
+
* email: 'admin@example.com',
|
|
129
|
+
* challengeStore: store,
|
|
130
|
+
* environment: 'production',
|
|
131
|
+
* });
|
|
132
|
+
*
|
|
133
|
+
* @param {ObtainOptions} options
|
|
134
|
+
* @returns {Promise<ObtainResult>}
|
|
135
|
+
*/
|
|
136
|
+
async function obtainCertificate(options) {
|
|
137
|
+
const logger = options.logger || defaultLogger;
|
|
138
|
+
const domains = Array.isArray(options.domains) ? options.domains : [options.domains];
|
|
139
|
+
const environment = options.environment || 'staging';
|
|
140
|
+
const certDir = path.resolve(options.certDir || './certs');
|
|
141
|
+
const store = options.challengeStore;
|
|
142
|
+
|
|
143
|
+
if (!store) throw new Error('obtainCertificate(): `challengeStore` majburiy.');
|
|
144
|
+
if (!domains.length || !domains[0]) throw new Error('obtainCertificate(): `domains` majburiy.');
|
|
145
|
+
|
|
146
|
+
logger.info(`ACME sertifikat jarayoni boshlandi [${environment}]: ${domains.join(', ')}`);
|
|
147
|
+
|
|
148
|
+
const accountKey = loadOrCreateAccountKey(options.accountKeyPath, logger);
|
|
149
|
+
const client = new AcmeClient({ environment, accountKey, logger });
|
|
150
|
+
|
|
151
|
+
await client.init();
|
|
152
|
+
await client.createAccount({ email: options.email });
|
|
153
|
+
|
|
154
|
+
const { order, orderUrl } = await client.createOrder(domains);
|
|
155
|
+
logger.info(`Buyurtma ochildi (${order.authorizations.length} ta avtorizatsiya).`);
|
|
156
|
+
|
|
157
|
+
// Har bir domen avtorizatsiyasini HTTP-01 orqali bajaramiz.
|
|
158
|
+
for (const authUrl of order.authorizations) {
|
|
159
|
+
const auth = await client.getAuthorization(authUrl);
|
|
160
|
+
const challenge = (auth.challenges || []).find((c) => c.type === 'http-01');
|
|
161
|
+
if (!challenge) throw new Error(`http-01 challenge topilmadi: ${auth.identifier && auth.identifier.value}`);
|
|
162
|
+
|
|
163
|
+
const keyAuth = client.keyAuthorization(challenge.token);
|
|
164
|
+
store.set(challenge.token, keyAuth);
|
|
165
|
+
logger.info(`Challenge o'rnatildi: ${auth.identifier.value}`);
|
|
166
|
+
|
|
167
|
+
await client.completeChallenge(challenge.url);
|
|
168
|
+
await client.poll(authUrl, ['valid'], { tries: 20, intervalMs: 3000 });
|
|
169
|
+
store.remove(challenge.token);
|
|
170
|
+
logger.success(`Domen tasdiqlandi: ${auth.identifier.value}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// CSR yaratib, buyurtmani yakunlaymiz.
|
|
174
|
+
const csr = createCsr(domains);
|
|
175
|
+
await client.finalizeOrder(order.finalize, csr.csrDerBase64Url);
|
|
176
|
+
const finalOrder = await client.poll(orderUrl, ['valid'], { tries: 20, intervalMs: 3000 });
|
|
177
|
+
|
|
178
|
+
const certificate = await client.downloadCertificate(finalOrder.certificate);
|
|
179
|
+
|
|
180
|
+
// Fayllarga yozamiz.
|
|
181
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
182
|
+
const fullchainPath = path.join(certDir, 'fullchain.pem');
|
|
183
|
+
const privkeyPath = path.join(certDir, 'privkey.pem');
|
|
184
|
+
fs.writeFileSync(fullchainPath, certificate);
|
|
185
|
+
fs.writeFileSync(privkeyPath, csr.privateKeyPem, { mode: 0o600 });
|
|
186
|
+
|
|
187
|
+
logger.success(`✅ Sertifikat olindi va saqlandi: ${certDir}`);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
certificate,
|
|
191
|
+
privateKey: csr.privateKeyPem,
|
|
192
|
+
paths: { fullchain: fullchainPath, privkey: privkeyPath },
|
|
193
|
+
environment,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
obtainCertificate,
|
|
199
|
+
challengeMiddleware,
|
|
200
|
+
ChallengeStore,
|
|
201
|
+
AcmeClient,
|
|
202
|
+
createCsr,
|
|
203
|
+
jose,
|
|
204
|
+
DIRECTORY_URLS,
|
|
205
|
+
// Auto-detect va yangilash (renewal)
|
|
206
|
+
detectDomains,
|
|
207
|
+
checkCert,
|
|
208
|
+
needsRenewal,
|
|
209
|
+
certDomains,
|
|
210
|
+
daysUntilExpiry,
|
|
211
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file jose.js
|
|
5
|
+
* @description ACME v2 (RFC 8555) uchun JOSE/JWS kriptografik yordamchilari.
|
|
6
|
+
* Faqat Node.js ichki `crypto` modulidan foydalanadi (zero-dependency).
|
|
7
|
+
* Hisob (account) kaliti sifatida ECDSA P-256 (ES256) ishlatiladi.
|
|
8
|
+
* @module modules/acme/jose
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Buffer yoki matnni base64url ko'rinishiga o'giradi (to'ldiruvchi `=` siz).
|
|
15
|
+
* @param {Buffer|string} input
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function base64url(input) {
|
|
19
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(String(input));
|
|
20
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Obyektni JSON -> base64url ko'rinishiga o'giradi.
|
|
25
|
+
* @param {Object} obj
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function base64urlJson(obj) {
|
|
29
|
+
return base64url(JSON.stringify(obj));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Yangi ECDSA P-256 (ES256) kalit juftligini yaratadi.
|
|
34
|
+
* @returns {{ publicKey: crypto.KeyObject, privateKey: crypto.KeyObject }}
|
|
35
|
+
*/
|
|
36
|
+
function generateAccountKey() {
|
|
37
|
+
return crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* PEM matnidan yopiq (private) kalitni tiklaydi.
|
|
42
|
+
* @param {string} pem - PKCS#8 PEM.
|
|
43
|
+
* @returns {crypto.KeyObject}
|
|
44
|
+
*/
|
|
45
|
+
function importPrivateKeyPem(pem) {
|
|
46
|
+
return crypto.createPrivateKey(pem);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Yopiq kalitni PKCS#8 PEM ko'rinishida eksport qiladi (faylga saqlash uchun).
|
|
51
|
+
* @param {crypto.KeyObject} privateKey
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function exportPrivateKeyPem(privateKey) {
|
|
55
|
+
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ochiq kalitning ACME talab qiladigan JWK (faqat kerakli maydonlar) ko'rinishi.
|
|
60
|
+
* Maydonlar tartibi thumbprint uchun muhim (leksikografik).
|
|
61
|
+
* @param {crypto.KeyObject} publicKey
|
|
62
|
+
* @returns {{ crv: string, kty: string, x: string, y: string }}
|
|
63
|
+
*/
|
|
64
|
+
function publicJwk(publicKey) {
|
|
65
|
+
const jwk = publicKey.export({ format: 'jwk' });
|
|
66
|
+
// RFC 7638 bo'yicha thumbprint uchun aynan shu tartib va maydonlar.
|
|
67
|
+
return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* JWK thumbprint (RFC 7638) — SHA-256, base64url. HTTP-01 kalit avtorizatsiyasida
|
|
72
|
+
* ishlatiladi.
|
|
73
|
+
* @param {{ crv: string, kty: string, x: string, y: string }} jwk
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function jwkThumbprint(jwk) {
|
|
77
|
+
const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
|
78
|
+
return base64url(crypto.createHash('sha256').update(canonical).digest());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ACME "flattened JWS" imzolangan so'rov tanasini tuzadi.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} params
|
|
85
|
+
* @param {crypto.KeyObject} params.privateKey - Hisob yopiq kaliti (ES256).
|
|
86
|
+
* @param {string} params.url - So'rov yuboriladigan ACME URL (protected header'ga kiradi).
|
|
87
|
+
* @param {string} params.nonce - Replay-Nonce qiymati.
|
|
88
|
+
* @param {Object|string} params.payload - Yuk (bo'sh POST-as-GET uchun "" bering).
|
|
89
|
+
* @param {string} [params.kid] - Hisob URL'i (mavjud bo'lsa `jwk` o'rniga ishlatiladi).
|
|
90
|
+
* @param {{crv:string,kty:string,x:string,y:string}} [params.jwk] - newAccount uchun ochiq JWK.
|
|
91
|
+
* @returns {string} `application/jose+json` uchun tayyor JSON matn.
|
|
92
|
+
*/
|
|
93
|
+
function signedJws({ privateKey, url, nonce, payload, kid, jwk }) {
|
|
94
|
+
/** @type {Object} */
|
|
95
|
+
const protectedHeader = { alg: 'ES256', nonce, url };
|
|
96
|
+
if (kid) protectedHeader.kid = kid;
|
|
97
|
+
else protectedHeader.jwk = jwk;
|
|
98
|
+
|
|
99
|
+
const protected64 = base64urlJson(protectedHeader);
|
|
100
|
+
const payload64 = payload === '' ? '' : base64urlJson(payload);
|
|
101
|
+
const signingInput = `${protected64}.${payload64}`;
|
|
102
|
+
|
|
103
|
+
// ES256: ECDSA imzosi JOSE formatida (r||s, 64 bayt) bo'lishi shart.
|
|
104
|
+
const signature = crypto.sign('SHA256', Buffer.from(signingInput), {
|
|
105
|
+
key: privateKey,
|
|
106
|
+
dsaEncoding: 'ieee-p1363',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return JSON.stringify({
|
|
110
|
+
protected: protected64,
|
|
111
|
+
payload: payload64,
|
|
112
|
+
signature: base64url(signature),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
base64url,
|
|
118
|
+
base64urlJson,
|
|
119
|
+
generateAccountKey,
|
|
120
|
+
importPrivateKeyPem,
|
|
121
|
+
exportPrivateKeyPem,
|
|
122
|
+
publicJwk,
|
|
123
|
+
jwkThumbprint,
|
|
124
|
+
signedJws,
|
|
125
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file renew.js
|
|
5
|
+
* @description Sertifikat yangilash yordamchilari. Mavjud sertifikatning amal
|
|
6
|
+
* qilish muddatini va SAN domenlarini o'qiydi hamda `<N kun` qolganini
|
|
7
|
+
* (yangilash kerakligini) aniqlaydi. Node ichki `crypto.X509Certificate`
|
|
8
|
+
* dan foydalanadi.
|
|
9
|
+
* @module modules/acme/renew
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PEM sertifikatdan SAN (subjectAltName) domenlarini oladi.
|
|
17
|
+
* @param {string} pem
|
|
18
|
+
* @returns {string[]} DNS domenlari.
|
|
19
|
+
*/
|
|
20
|
+
function certDomains(pem) {
|
|
21
|
+
const cert = new crypto.X509Certificate(pem);
|
|
22
|
+
const san = cert.subjectAltName || '';
|
|
23
|
+
return san
|
|
24
|
+
.split(',')
|
|
25
|
+
.map((s) => s.trim())
|
|
26
|
+
.filter((s) => s.toUpperCase().startsWith('DNS:'))
|
|
27
|
+
.map((s) => s.slice(4).trim().toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PEM sertifikat tugashiga necha kun qolganini qaytaradi (o'tgan bo'lsa manfiy).
|
|
32
|
+
* @param {string} pem
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
function daysUntilExpiry(pem) {
|
|
36
|
+
const cert = new crypto.X509Certificate(pem);
|
|
37
|
+
const to = new Date(cert.validTo).getTime();
|
|
38
|
+
return Math.floor((to - Date.now()) / (24 * 60 * 60 * 1000));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} CertStatus
|
|
43
|
+
* @property {boolean} exists - Fayl mavjudmi.
|
|
44
|
+
* @property {number} [daysLeft] - Tugashiga qolgan kunlar.
|
|
45
|
+
* @property {string[]} [domains] - SAN domenlari.
|
|
46
|
+
* @property {boolean} needsRenewal - Yangilash kerakmi.
|
|
47
|
+
* @property {string} reason
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sertifikat holatini tekshiradi (mavjudmi, qancha qolgan, yangilash kerakmi).
|
|
52
|
+
* @param {string} certPath - fullchain.pem yo'li.
|
|
53
|
+
* @param {number} [thresholdDays=30] - Shu kundan kam qolsa — yangilanadi.
|
|
54
|
+
* @returns {CertStatus}
|
|
55
|
+
*/
|
|
56
|
+
function checkCert(certPath, thresholdDays = 30) {
|
|
57
|
+
let pem;
|
|
58
|
+
try {
|
|
59
|
+
pem = fs.readFileSync(certPath, 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
return { exists: false, needsRenewal: true, reason: 'sertifikat topilmadi' };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const daysLeft = daysUntilExpiry(pem);
|
|
65
|
+
const domains = certDomains(pem);
|
|
66
|
+
const need = daysLeft < thresholdDays;
|
|
67
|
+
return {
|
|
68
|
+
exists: true,
|
|
69
|
+
daysLeft,
|
|
70
|
+
domains,
|
|
71
|
+
needsRenewal: need,
|
|
72
|
+
reason: need ? `${daysLeft} kun qoldi (< ${thresholdDays})` : `${daysLeft} kun qoldi — hali yangi`,
|
|
73
|
+
};
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { exists: true, needsRenewal: true, reason: 'sertifikatni o\'qib bo\'lmadi: ' + e.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sertifikatni yangilash kerakmi (qisqa yordamchi).
|
|
81
|
+
* @param {string} certPath
|
|
82
|
+
* @param {number} [thresholdDays=30]
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function needsRenewal(certPath, thresholdDays = 30) {
|
|
86
|
+
return checkCert(certPath, thresholdDays).needsRenewal;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { certDomains, daysUntilExpiry, checkCert, needsRenewal };
|