feat: Domain name expiry (#6413)

Co-authored-by: AiroPi <47398145+AiroPi@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
Shaan 2025-12-20 22:32:49 +06:00 committed by GitHub
parent f3c76dbc6f
commit eb0b6cdb09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 926 additions and 14 deletions

View File

@ -0,0 +1,21 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.boolean("domain_expiry_notification").defaultTo(1);
})
.createTable("domain_expiry", (table) => {
table.increments("id");
table.datetime("last_check");
table.text("domain").unique().notNullable();
table.datetime("expiry");
table.integer("last_expiry_notification_sent").defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.boolean("domain_expiry_notification").alter();
})
.dropTable("domain_expiry");
};

318
package-lock.json generated
View File

@ -60,6 +60,7 @@
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~6.9.13",
"nostr-tools": "^2.10.4",
@ -85,6 +86,7 @@
"tar": "~6.2.1",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tldts": "^7.0.19",
"tough-cookie": "~4.1.3",
"web-push": "^3.6.7",
"ws": "^8.13.0"
@ -3646,7 +3648,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
@ -3656,7 +3657,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
@ -8783,6 +8783,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -10467,6 +10476,29 @@
"node": ">=0.4.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -10674,6 +10706,27 @@
"integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg==",
"license": "MIT"
},
"node_modules/formdata-node": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -11797,7 +11850,6 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
@ -12995,6 +13047,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/locko": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/locko/-/locko-1.1.0.tgz",
"integrity": "sha512-pYB2dzRY93fJkg2RIl41AMNgTQftEjyTK9vlPrGOJvuGQsOjb267VJBw15BjiN3RBd1oBoKkOu9E2dRdFKIfAA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -13546,7 +13604,6 @@
"resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
"integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
@ -13559,7 +13616,6 @@
"resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
"integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
@ -13902,6 +13958,26 @@
"command-exists": "^1.2.9"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -13922,6 +13998,211 @@
}
}
},
"node_modules/node-fetch-cache": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-fetch-cache/-/node-fetch-cache-5.1.0.tgz",
"integrity": "sha512-4j3rRHNGIKGX7VzXSrBT0bh7+wFuyJv1DxCfCLDHsnDahJWoD9lXe3BzL3BJg/GEIJiM7KIvqVs3byW1GFtRsQ==",
"license": "MIT",
"dependencies": {
"cacache": "^20.0.1",
"formdata-node": "^6.0.3",
"locko": "^1.1.0",
"node-fetch": "3.3.2"
},
"engines": {
"node": ">=18.19.0"
}
},
"node_modules/node-fetch-cache/node_modules/@npmcli/fs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz",
"integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==",
"license": "ISC",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-fetch-cache/node_modules/cacache": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
"integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==",
"license": "ISC",
"dependencies": {
"@npmcli/fs": "^5.0.0",
"fs-minipass": "^3.0.0",
"glob": "^13.0.0",
"lru-cache": "^11.1.0",
"minipass": "^7.0.3",
"minipass-collect": "^2.0.1",
"minipass-flush": "^1.0.5",
"minipass-pipeline": "^1.2.4",
"p-map": "^7.0.2",
"ssri": "^13.0.0",
"unique-filename": "^5.0.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-fetch-cache/node_modules/fs-minipass": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
"integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/node-fetch-cache/node_modules/glob": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-fetch-cache/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/node-fetch-cache/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-fetch-cache/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/node-fetch-cache/node_modules/minipass-collect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz",
"integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/node-fetch-cache/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch-cache/node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-fetch-cache/node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-fetch-cache/node_modules/ssri": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz",
"integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.3"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-fetch-cache/node_modules/unique-filename": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz",
"integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==",
"license": "ISC",
"dependencies": {
"unique-slug": "^6.0.0"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-fetch-cache/node_modules/unique-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz",
"integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==",
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@ -17987,6 +18268,24 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
"integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
"integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@ -18939,6 +19238,15 @@
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@ -122,6 +122,7 @@
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-utils": "~1.2.0",
"node-fetch-cache": "^5.1.0",
"nodemailer": "~6.9.13",
"nostr-tools": "^2.10.4",
"notp": "~2.0.3",
@ -146,6 +147,7 @@
"tar": "~6.2.1",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tldts": "^7.0.19",
"tough-cookie": "~4.1.3",
"web-push": "^3.6.7",
"ws": "^8.13.0"

View File

@ -0,0 +1,270 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const { log } = require("../../src/util");
const { parse: parseTld } = require("tldts");
const { getDaysRemaining, getDaysBetween, setting, setSetting } = require("../util-server");
const { Notification } = require("../notification");
const { default: NodeFetchCache, MemoryCache } = require("node-fetch-cache");
const TABLE = "domain_expiry";
const urlTypes = [ "websocket-upgrade", "http", "keyword", "json-query", "real-browser" ];
const excludeTypes = [ "docker", "group", "push", "manual", "rabbitmq", "redis" ];
const cachedFetch = process.env.NODE_ENV ? NodeFetchCache.create({
// cache for 8h
cache: new MemoryCache({ ttl: 1000 * 60 * 60 * 8 })
}) : fetch;
/**
* Find the RDAP server for a given TLD
* @param {string} tld TLD
* @returns {Promise<string>} First RDAP server found
*/
async function getRdapServer(tld) {
let rdapList;
try {
const res = await cachedFetch("https://data.iana.org/rdap/dns.json");
rdapList = await res.json();
} catch (error) {
log.debug("rdap", error);
return null;
}
for (const service of rdapList["services"]) {
const [ tlds, urls ] = service;
if (tlds.includes(tld)) {
return urls[0];
}
}
return null;
}
/**
* Request RDAP server to retrieve the expiry date of a domain
* @param {string} domain Domain to retrieve the expiry date from
* @returns {Promise<(Date|null)>} Expiry date from RDAP server
*/
async function getRdapDomainExpiryDate(domain) {
const tld = DomainExpiry.parseTld(domain).publicSuffix;
const rdapServer = await getRdapServer(tld);
if (rdapServer === null) {
log.warn("rdap", `No RDAP server found, TLD ${tld} not supported.`);
return null;
}
const url = `${rdapServer}domain/${domain}`;
let rdapInfos;
try {
const res = await fetch(url);
if (res.status !== 200) {
return null;
}
rdapInfos = await res.json();
} catch {
log.warn("rdap", "Not able to get expiry date from RDAP");
return null;
}
if (rdapInfos["events"] === undefined) {
return null;
}
for (const event of rdapInfos["events"]) {
if (event["eventAction"] === "expiration") {
return new Date(event["eventDate"]);
}
}
return null;
}
/**
* Send a certificate notification when domain expires in less than target days
* @param {string} domain Domain we monitor
* @param {number} daysRemaining Number of days remaining on certificate
* @param {number} targetDays Number of days to alert after
* @param {LooseObject<any>[]} notificationList List of notification providers
* @returns {Promise<void>}
*/
async function sendDomainNotificationByTargetDays(domain, daysRemaining, targetDays, notificationList) {
let sent = false;
log.debug("domain", `Send domain expiry notification for ${targetDays} deadline.`);
for (let notification of notificationList) {
try {
log.debug("domain", `Sending to ${notification.name}`);
await Notification.send(
JSON.parse(notification.config),
`Domain name ${domain} will expire in ${daysRemaining} days`
);
sent = true;
} catch (e) {
log.error("domain", `Cannot send domain notification to ${notification.name}`);
log.error("domain", e);
}
}
return sent;
}
class DomainExpiry extends BeanModel {
/**
* @param {string} domain Domain name
* @returns {Promise<DomainExpiry>} Domain bean
*/
static async findByName(domain) {
return R.findOne(TABLE, "domain = ?", [ domain ]);
}
/**
* @param {string} domain Domain name
* @returns {DomainExpiry} Domain bean
*/
static createByName(domain) {
const d = R.dispense(TABLE);
d.domain = domain;
return d;
}
static parseTld = parseTld;
/**
* @returns {(object)} parsed domain components
*/
parseName() {
return parseTld(this.domain);
}
/**
* @returns {(null|object)} parsed domain tld
*/
get tld() {
return this.parseName().publicSuffix;
}
/**
* @param {Monitor} monitor Monitor object
* @returns {Promise<DomainExpiry>} Domain expiry bean
*/
static async forMonitor(monitor) {
const m = monitor;
if (excludeTypes.includes(m.type) || m.type?.match(/sql$/)) {
return false;
}
const tld = parseTld(urlTypes.includes(m.type) ? m.url : m.type === "grpc-keyword" ? m.grpcUrl : m.hostname);
const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) {
log.warn("domain", `${tld.publicSuffix} is not supported. File a bug report if you believe it should be.`);
return false;
}
const existing = await DomainExpiry.findByName(tld.domain);
if (existing) {
return existing;
}
if (tld.domain) {
return await DomainExpiry.createByName(tld.domain);
}
}
/**
* @returns {number} number of days remaining before expiry
*/
get daysRemaining() {
return getDaysRemaining(new Date(), new Date(this.expiry));
}
/**
* @returns {(Date|null)} Expiry date from RDAP
*/
getExpiryDate() {
return getRdapDomainExpiryDate(this.domain);
}
/**
* @param {(Monitor)} monitor Monitor object
* @returns {Promise<void>}
*/
static async checkExpiry(monitor) {
let bean = await DomainExpiry.forMonitor(monitor);
let expiryDate;
if (bean?.lastCheck && getDaysBetween(new Date(bean.lastCheck), new Date()) < 1) {
log.debug("domain", `Domain expiry already checked recently for ${bean.domain}, won't re-check.`);
return bean.expiry;
} else if (bean) {
expiryDate = await bean.getExpiryDate();
if (new Date(expiryDate) > new Date(bean.expiry)) {
bean.lastExpiryNotificationSent = null;
}
bean.expiry = expiryDate;
bean.lastCheck = new Date();
await R.store(bean);
}
if (expiryDate === null) {
return;
}
return expiryDate;
}
/**
* @param {Monitor} monitor Monitor instance
* @param {LooseObject<any>[]} notificationList notification List
* @returns {Promise<void>}
*/
static async sendNotifications(monitor, notificationList) {
const domain = await DomainExpiry.forMonitor(monitor);
const name = domain.domain;
if (!notificationList.length > 0) {
// fail fast. If no notification is set, all the following checks can be skipped.
log.debug("domain", "No notification, no need to send domain notification");
return;
}
const daysRemaining = getDaysRemaining(new Date(), domain.expiry);
const lastSent = domain.lastExpiryNotificationSent;
log.debug("domain", `${name} expires in ${daysRemaining} days`);
let notifyDays = await setting("domainExpiryNotifyDays");
if (notifyDays == null || !Array.isArray(notifyDays)) {
// Reset Default
await setSetting("domainExpiryNotifyDays", [ 7, 14, 21 ], "general");
notifyDays = [ 7, 14, 21 ];
}
if (Array.isArray(notifyDays)) {
// Asc sort to avoid sending multiple notifications if daysRemaining is below multiple targetDays
notifyDays.sort((a, b) => a - b);
for (const targetDays of notifyDays) {
if (daysRemaining > targetDays) {
log.debug(
"domain",
`No need to send domain notification for ${name} (${daysRemaining} days valid) on ${targetDays} deadline.`
);
continue;
} else if (lastSent && lastSent <= targetDays) {
log.debug(
"domain",
`Notification for ${name} on ${targetDays} deadline sent already, no need to send again.`
);
continue;
}
const sent = await sendDomainNotificationByTargetDays(
name,
daysRemaining,
targetDays,
notificationList
);
if (sent) {
domain.lastExpiryNotificationSent = targetDays;
await R.store(domain);
return targetDays;
}
}
}
}
}
module.exports = DomainExpiry;

View File

@ -28,6 +28,7 @@ const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const https = require("https");
const http = require("http");
const DomainExpiry = require("./domain_expiry");
const rootCertificates = rootCertificatesFingerprints();
@ -117,6 +118,7 @@ class Monitor extends BeanModel {
keyword: this.keyword,
invertKeyword: this.isInvertKeyword(),
expiryNotification: this.isEnabledExpiryNotification(),
domainExpiryNotification: Boolean(this.domainExpiryNotification),
ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(),
packetSize: this.packetSize,
@ -934,6 +936,19 @@ class Monitor extends BeanModel {
}
}
if (bean.status !== MAINTENANCE && Boolean(this.domainExpiryNotification)) {
try {
const domainExpiryDate = await DomainExpiry.checkExpiry(this);
if (domainExpiryDate) {
DomainExpiry.sendNotifications(this, await Monitor.getNotificationList(this) || []);
} else {
log.debug("monitor", `Failed getting expiration date for domain ${this.name}`);
}
} catch (error) {
log.warn("monitor", `Failed to get domain expiry for ${this.name} : ${error.message}`);
}
}
if (bean.status === UP) {
log.debug("monitor", `Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else if (bean.status === PENDING) {
@ -1201,6 +1216,9 @@ class Monitor extends BeanModel {
// Send Cert Info
await Monitor.sendCertInfo(io, monitorID, userID);
// Send domain info
await Monitor.sendDomainInfo(io, monitorID, userID);
} else {
log.debug("monitor", "No clients in the room, no need to send stats");
}
@ -1222,6 +1240,22 @@ class Monitor extends BeanModel {
}
}
/**
* Send domain name information to client
* @param {Server} io Socket server instance
* @param {number} monitorID ID of monitor to send
* @param {number} userID ID of user to send to
* @returns {void}
*/
static async sendDomainInfo(io, monitorID, userID) {
const monitor = await R.findOne("monitor", "id = ?", [ monitorID ]);
const domain = await DomainExpiry.forMonitor(monitor);
if (domain?.expiry) {
io.to(userID).emit("domainInfo", monitorID, domain.daysRemaining, new Date(domain.expiry));
}
}
/**
* Has status of monitor changed since last beat?
* @param {boolean} isFirstBeat Is this the first beat of this monitor?

View File

@ -843,6 +843,7 @@ let needSetup = false;
bean.invertKeyword = monitor.invertKeyword;
bean.ignoreTls = monitor.ignoreTls;
bean.expiryNotification = monitor.expiryNotification;
bean.domainExpiryNotification = monitor.domainExpiryNotification;
bean.upsideDown = monitor.upsideDown;
bean.packetSize = monitor.packetSize;
bean.maxredirects = monitor.maxredirects;
@ -981,6 +982,22 @@ let needSetup = false;
}
});
socket.on("checkMointor", async (partial, callback) => {
try {
checkLogin(socket);
const DomainExpiry = require("./model/domain_expiry");
callback({
ok: true,
domain: (await DomainExpiry.forMonitor(partial))?.domain || null
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorBeats", async (monitorID, period, callback) => {
try {
checkLogin(socket);

View File

@ -483,6 +483,7 @@ exports.setSettings = async function (type, data) {
*/
const getDaysBetween = (validFrom, validTo) =>
Math.round(Math.abs(+validFrom - +validTo) / 8.64e7);
exports.getDaysBetween = getDaysBetween;
/**
* Get days remaining from a time range
@ -492,11 +493,12 @@ const getDaysBetween = (validFrom, validTo) =>
*/
const getDaysRemaining = (validFrom, validTo) => {
const daysRemaining = getDaysBetween(validFrom, validTo);
if (new Date(validTo).getTime() < new Date().getTime()) {
if (new Date(validTo).getTime() < new Date(validFrom).getTime()) {
return -daysRemaining;
}
return daysRemaining;
};
exports.getDaysRemaining = getDaysRemaining;
/**
* Fix certificate info for display

View File

@ -60,13 +60,35 @@
<div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6">
<div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2">
<span>{{ day }} {{ $tc("day", day) }}</span>
<button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" :aria-label="$t('Remove the expiry notification')" @click="removeExpiryNotifDay(day)">
<button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" :aria-label="$t('Remove the expiry notification')" @click="removeTlsExpiryNotifDay(day)">
<font-awesome-icon icon="times" />
</button>
</div>
</div>
<div class="col-12 col-xl-6">
<ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
<ActionInput v-model="tlsExpiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
</div>
<div>
<button class="btn btn-primary" type="button" @click="saveSettings()">
{{ $t("Save") }}
</button>
</div>
</div>
<div class="my-4 pt-4">
<h5 class="my-4 settings-subheading">{{ $t("settingsDomainExpiry") }}</h5>
<p>{{ $t("domainExpiryDescription") }}</p>
<p>{{ $t("notificationDescription") }}</p>
<div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6">
<div v-for="day in settings.domainExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2">
<span>{{ day }} {{ $tc("day", day) }}</span>
<button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" :aria-label="$t('Remove the expiry notification')" @click="removeDomainExpiryNotifDay(day)">
<font-awesome-icon icon="times" />
</button>
</div>
</div>
<div class="col-12 col-xl-6">
<ActionInput v-model="domainExpiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
</div>
<div>
<button class="btn btn-primary" type="button" @click="saveSettings()">
@ -96,7 +118,8 @@ export default {
/**
* Variable to store the input for new certificate expiry day.
*/
expiryNotifInput: null,
tlsExpiryNotifInput: null,
domainExpiryNotifInput: null,
};
},
@ -134,15 +157,15 @@ export default {
methods: {
/**
* Remove a day from expiry notification days.
* Remove a day from tls expiry notification days.
* @param {number} day The day to remove.
* @returns {void}
*/
removeExpiryNotifDay(day) {
removeTlsExpiryNotifDay(day) {
this.settings.tlsExpiryNotifyDays = this.settings.tlsExpiryNotifyDays.filter(d => d !== day);
},
/**
* Add a new expiry notification day.
* Add a new tls expiry notification day.
* Will verify:
* - day is not null or empty string.
* - day is a number.
@ -151,14 +174,44 @@ export default {
* @param {number} day The day number to add.
* @returns {void}
*/
addExpiryNotifDay(day) {
addTlsExpiryNotifDay(day) {
if (day != null && day !== "") {
const parsedDay = parseInt(day);
if (parsedDay != null && !isNaN(parsedDay) && parsedDay > 0) {
if (!this.settings.tlsExpiryNotifyDays.includes(parsedDay)) {
this.settings.tlsExpiryNotifyDays.push(parseInt(day));
this.settings.tlsExpiryNotifyDays.sort((a, b) => a - b);
this.expiryNotifInput = null;
this.tlsExpiryNotifInput = null;
}
}
}
},
/**
* Remove a day from domain expiry notification days.
* @param {number} day The day to remove.
* @returns {void}
*/
removeDomainExpiryNotifDay(day) {
this.settings.domainExpiryNotifyDays = this.settings.domainExpiryNotifyDays.filter(d => d !== day);
},
/**
* Add a new domain expiry notification day.
* Will verify:
* - day is not null or empty string.
* - day is a number.
* - day is > 0.
* - The day is not already in the list.
* @param {number} day The day number to add.
* @returns {void}
*/
addDomainExpiryNotifDay(day) {
if (day != null && day !== "") {
const parsedDay = parseInt(day);
if (parsedDay != null && !isNaN(parsedDay) && parsedDay > 0) {
if (!this.settings.domainExpiryNotifyDays.includes(parsedDay)) {
this.settings.domainExpiryNotifyDays.push(parseInt(day));
this.settings.domainExpiryNotifyDays.sort((a, b) => a - b);
this.domainExpiryNotifInput = null;
}
}
}

View File

@ -1232,6 +1232,10 @@
"Browser not supported": "Browser not supported",
"Unable to get permission to notify": "Unable to get permission to notify (request either denied or ignored).",
"Webpush Helptext": "Web push only works with SSL (HTTPS) connections. For iOS devices, webpage must be added to homescreen beforehand.",
"settingsDomainExpiry": "Domain Expiry",
"labelDomainExpiry": "Domain Exp.",
"labelDomainNameExpiryNotification": "Domain Name Expiry Notification",
"domainExpiryDescription": "Trigger notification when domain names expires in:",
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
"lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors."
}

View File

@ -45,6 +45,7 @@ export default {
avgPingList: { },
uptimeList: { },
tlsInfoList: {},
domainInfoList: {},
notificationList: [],
dockerHostList: [],
remoteBrowserList: [],
@ -250,6 +251,11 @@ export default {
this.tlsInfoList[monitorID] = JSON.parse(data);
});
socket.on("domainInfo", (monitorID, daysRemaining, expiresOn) => {
this.domainInfoList[monitorID] = { daysRemaining: daysRemaining,
expiresOn: expiresOn };
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("Reconnecting...")}`;

View File

@ -298,6 +298,21 @@
<font-awesome-icon v-if="tlsInfo.hostnameMatchMonitorUrl === false" class="cert-info-warn" icon="exclamation-triangle" :title="$t('certHostnameMismatch')" />
</span>
</div>
<div
v-if="domainInfo"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("labelDomainExpiry") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(<Datetime
:value="domainInfo.expiresOn"
date-only
/>)
</p>
<span class="col-4 col-sm-12 num">
{{ domainInfo.daysRemaining }} {{ $tc("day", domainInfo.daysRemaining ) }}
</span>
</div>
</div>
</div>
@ -626,6 +641,10 @@ export default {
return null;
},
domainInfo() {
return this.$root.domainInfoList[this.monitor.id] || null;
},
showCertInfoBox() {
return this.tlsInfo != null && this.toggleCertInfoBox;
},

View File

@ -797,6 +797,14 @@
</div>
</div>
<div v-if="hasDomain" class="my-3 form-check">
<input id="domain-expiry-notification" v-model="monitor.domainExpiryNotification" class="form-check-input" type="checkbox">
<label class="form-check-label" for="domain-expiry-notification">
{{ $t("labelDomainNameExpiryNotification") }}
</label>
<div class="form-text">
</div>
</div>
<div v-if="monitor.type === 'websocket-upgrade' " class="my-3 form-check">
<input id="wsIgnoreSecWebsocketAcceptHeader" v-model="monitor.wsIgnoreSecWebsocketAcceptHeader" class="form-check-input" type="checkbox">
<i18n-t tag="label" keypath="Ignore Sec-WebSocket-Accept header" class="form-check-label" for="wsIgnoreSecWebsocketAcceptHeader">
@ -1331,6 +1339,7 @@ const monitorDefaults = {
ignoreTls: false,
upsideDown: false,
expiryNotification: false,
domainExpiryNotification: true,
maxredirects: 10,
accepted_statuscodes: [ "200-299" ],
dns_resolve_type: "A",
@ -1387,6 +1396,7 @@ export default {
notificationIDList: {},
// Do not add default value here, please check init() method
},
hasDomain: false,
acceptedStatusCodeOptions: [],
dnsresolvetypeOptions: [],
kafkaSaslMechanismOptions: [],
@ -1461,6 +1471,16 @@ export default {
return null;
},
monitorTypeUrlHost() {
const { type, url, hostname, grpcUrl } = this.monitor;
return {
type,
url,
hostname,
grpcUrl
};
},
pageName() {
let name = "Add New Monitor";
if (this.isClone) {
@ -1738,6 +1758,15 @@ message HealthCheckResponse {
}
},
"monitorTypeUrlHost"(data) {
this.$root.getSocket().emit("checkMointor", data, (res) => {
this.hasDomain = !!res?.domain;
if (!res?.domain) {
this.monitor.domainExpiryNotification = false;
}
});
},
"monitor.type"(newType, oldType) {
if (oldType && this.monitor.type === "websocket-upgrade") {
this.monitor.url = "wss://";

View File

@ -179,6 +179,10 @@ export default {
this.settings.tlsExpiryNotifyDays = [ 7, 14, 21 ];
}
if (this.settings.domainExpiryNotifyDays === undefined) {
this.settings.domainExpiryNotifyDays = [ 7, 14, 21 ];
}
if (this.settings.trustProxy === undefined) {
this.settings.trustProxy = false;
}

View File

@ -0,0 +1,70 @@
process.env.UPTIME_KUMA_HIDE_LOG = [ "info_db", "info_server" ].join(",");
const test = require("node:test");
const assert = require("node:assert");
const DomainExpiry = require("../../server/model/domain_expiry");
const mockWebhook = require("../mock-webhook");
const TestDB = require("../mock-testdb");
const { R } = require("redbean-node");
const { Notification } = require("../../server/notification");
const { Settings } = require("../../server/settings");
const { setSetting } = require("../../server/util-server");
const testDb = new TestDB();
test("Domain Expiry", async (t) => {
await testDb.create();
Notification.init();
const monHttpCom = {
type: "http",
url: "https://www.google.com",
domainExpiryNotification: true
};
await t.test("Should get expiry date for .wiki with no A record", async () => {
const d = DomainExpiry.createByName("google.wiki");
assert.deepEqual(await d.getExpiryDate(), new Date("2026-11-26T23:59:59.000Z"));
});
await t.test("Should get expiration date for .com from RDAP", async () => {
const domain = await DomainExpiry.forMonitor(monHttpCom);
const expiryFromRdap = await domain.getExpiryDate(); // from RDAP
assert.deepEqual(expiryFromRdap, new Date("2028-09-14T04:00:00.000Z"));
});
await t.test("Should have expiration date cached in database", async () => {
await DomainExpiry.checkExpiry(monHttpCom); // RDAP -> Cache
const domain = await DomainExpiry.findByName("google.com");
assert(Date.now() - domain.lastCheck < 5 * 1000);
});
await t.test("Should trigger notify for expiring domain", async () => {
await DomainExpiry.findByName("google.com");
const hook = {
"port": 3010,
"url": "capture"
};
await setSetting("domainExpiryNotifyDays", [ 1, 2, 1500 ], "general");
const notif = R.convertToBean("notification", {
"config": JSON.stringify({
type: "webhook",
httpMethod: "post",
webhookContentType: "json",
webhookURL: `http://127.0.0.1:${hook.port}/${hook.url}`
}),
"active": 1,
"user_id": 1,
"name": "Testhook"
});
const manyDays = 1500;
setSetting("domainExpiryNotifyDays", [ 7, 14, manyDays ], "general");
const [ notifRet, data ] = await Promise.all([
DomainExpiry.sendNotifications(monHttpCom, [ notif ]),
mockWebhook(hook.port, hook.url)
]);
assert.equal(notifRet, manyDays);
assert.match(data.msg, /will expire in/);
});
}).finally(() => {
setTimeout(async () => {
Settings.stopCacheCleaner();
await testDb.destroy();
}, 200);
});

View File

@ -0,0 +1,18 @@
const test = require("node:test");
const assert = require("node:assert");
const { getDaysRemaining, getDaysBetween } = require("../../server/util-server");
test("Test getDaysBetween", async (t) => {
let days = getDaysBetween(new Date(2025, 9, 7), new Date(2025, 9, 10));
assert.strictEqual(days, 3);
days = getDaysBetween(new Date(2024, 9, 7), new Date(2025, 9, 10));
assert.strictEqual(days, 368);
});
test("Test getDaysRemaining", async (t) => {
let days = getDaysRemaining(new Date(2025, 9, 7), new Date(2025, 9, 10));
assert.strictEqual(days, 3);
days = getDaysRemaining(new Date(2025, 9, 10), new Date(2025, 9, 7));
assert.strictEqual(days, -3);
});

27
test/mock-testdb.js Normal file
View File

@ -0,0 +1,27 @@
const { sync: rimrafSync } = require("rimraf");
const Database = require("../server/database");
class TestDB {
dataDir;
constructor(dir = "./data/test") {
this.dataDir = dir;
}
async create() {
Database.initDataDir({ "data-dir": this.dataDir });
Database.dbConfig = {
type: "sqlite"
};
Database.writeDBConfig(Database.dbConfig);
await Database.connect(true);
await Database.patch();
}
async destroy() {
await Database.close();
this.dataDir && rimrafSync(this.dataDir);
}
}
module.exports = TestDB;

28
test/mock-webhook.js Normal file
View File

@ -0,0 +1,28 @@
const express = require("express");
const bodyParser = require("body-parser");
/**
* @param {number} port Port number
* @param {string} url Webhook URL
* @param {number} timeout Timeout
* @returns {Promise<object>} Webhook data
*/
async function mockWebhook(port, url, timeout = 2500) {
return new Promise((resolve, reject) => {
const app = express();
const tmo = setTimeout(() => {
server.close();
reject({ reason: "Timeout" });
}, timeout);
app.use(bodyParser.json()); // Middleware to parse JSON bodies
app.post(`/${url}`, (req, res) => {
res.status(200).send("OK");
server.close();
tmo && clearTimeout(tmo);
resolve(req.body);
});
const server = app.listen(port);
});
}
module.exports = mockWebhook;