From eb0b6cdb09024c0941e07c8c10b094a0be439ae4 Mon Sep 17 00:00:00 2001 From: Shaan Date: Sat, 20 Dec 2025 22:32:49 +0600 Subject: [PATCH] feat: Domain name expiry (#6413) Co-authored-by: AiroPi <47398145+AiroPi@users.noreply.github.com> Co-authored-by: Frank Elsinga --- .../2025-09-02-0000-add-domain-expiry.js | 21 ++ package-lock.json | 318 +++++++++++++++++- package.json | 2 + server/model/domain_expiry.js | 270 +++++++++++++++ server/model/monitor.js | 34 ++ server/server.js | 17 + server/util-server.js | 4 +- src/components/settings/Notifications.vue | 69 +++- src/lang/en.json | 4 + src/mixins/socket.js | 6 + src/pages/Details.vue | 19 ++ src/pages/EditMonitor.vue | 29 ++ src/pages/Settings.vue | 4 + test/backend-test/test-domain.js | 70 ++++ test/backend-test/test-util.js | 18 + test/mock-testdb.js | 27 ++ test/mock-webhook.js | 28 ++ 17 files changed, 926 insertions(+), 14 deletions(-) create mode 100644 db/knex_migrations/2025-09-02-0000-add-domain-expiry.js create mode 100644 server/model/domain_expiry.js create mode 100644 test/backend-test/test-domain.js create mode 100644 test/backend-test/test-util.js create mode 100644 test/mock-testdb.js create mode 100644 test/mock-webhook.js diff --git a/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js b/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js new file mode 100644 index 000000000..ede2e1889 --- /dev/null +++ b/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js @@ -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"); +}; diff --git a/package-lock.json b/package-lock.json index 53e639e1a..1df648a9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 56ac1e202..04247d031 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/server/model/domain_expiry.js b/server/model/domain_expiry.js new file mode 100644 index 000000000..b796a81d1 --- /dev/null +++ b/server/model/domain_expiry.js @@ -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} 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[]} notificationList List of notification providers + * @returns {Promise} + */ +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} 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} 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} + */ + 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[]} notificationList notification List + * @returns {Promise} + */ + 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; diff --git a/server/model/monitor.js b/server/model/monitor.js index d0f71e815..1c9a112cf 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -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? diff --git a/server/server.js b/server/server.js index c639d4597..c8f2373fa 100644 --- a/server/server.js +++ b/server/server.js @@ -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); diff --git a/server/util-server.js b/server/util-server.js index d28f05a97..9ab324402 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -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 diff --git a/src/components/settings/Notifications.vue b/src/components/settings/Notifications.vue index 2a65d796e..ab8096b99 100644 --- a/src/components/settings/Notifications.vue +++ b/src/components/settings/Notifications.vue @@ -60,13 +60,35 @@
{{ day }} {{ $tc("day", day) }} -
- + +
+
+ +
+ + +
+
{{ $t("settingsDomainExpiry") }}
+

{{ $t("domainExpiryDescription") }}

+

{{ $t("notificationDescription") }}

+
+
+ {{ day }} {{ $tc("day", day) }} + +
+
+
+
+
+

{{ $t("labelDomainExpiry") }}

+

+ () +

+ + {{ domainInfo.daysRemaining }} {{ $tc("day", domainInfo.daysRemaining ) }} + +
@@ -626,6 +641,10 @@ export default { return null; }, + domainInfo() { + return this.$root.domainInfoList[this.monitor.id] || null; + }, + showCertInfoBox() { return this.tlsInfo != null && this.toggleCertInfoBox; }, diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 824b5be2e..7522608c4 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -797,6 +797,14 @@ +
+ + +
+
+
@@ -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://"; diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 96bb1fee1..eac9dd48d 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -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; } diff --git a/test/backend-test/test-domain.js b/test/backend-test/test-domain.js new file mode 100644 index 000000000..aa3102e37 --- /dev/null +++ b/test/backend-test/test-domain.js @@ -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); +}); diff --git a/test/backend-test/test-util.js b/test/backend-test/test-util.js new file mode 100644 index 000000000..470113329 --- /dev/null +++ b/test/backend-test/test-util.js @@ -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); +}); diff --git a/test/mock-testdb.js b/test/mock-testdb.js new file mode 100644 index 000000000..1e6f9a9bc --- /dev/null +++ b/test/mock-testdb.js @@ -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; diff --git a/test/mock-webhook.js b/test/mock-webhook.js new file mode 100644 index 000000000..23bf192c7 --- /dev/null +++ b/test/mock-webhook.js @@ -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} 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;