Compare commits
889 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bbaf26813 | |||
| 39f449cbe4 | |||
| e0386beb15 | |||
| 1d7e5495fa | |||
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a | |||
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f | |||
| 19398ea836 | |||
| 4aba8cc353 | |||
| 5fd036eeb6 | |||
| cfcb66f1ee | |||
| 501f4f9de6 | |||
| fa926eb10b | |||
| f2d0a9ec1b | |||
| 035173702d | |||
| 07a3365496 | |||
| 1c4f7dbb11 | |||
| 1fdff79dd0 | |||
| 59b52d08fa | |||
| 2cdc392a40 | |||
| 433047bbf1 | |||
| 0b81c95de2 | |||
| 196e5dfc1b | |||
| 60d095cd78 | |||
| 2861511d20 | |||
| b582d44502 | |||
| 36a2ebc94e | |||
| ed52a3188d | |||
| 93cc5c7b06 | |||
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 | |||
| d320590ce2 | |||
| 0ee57f433b | |||
| b28b5eea84 | |||
| 27d7489af9 | |||
| 940c7dc92e | |||
| 7fa6d82e58 | |||
| f29ed9757e | |||
| ad45d1b8b9 | |||
| 68473f8550 | |||
| 07cfe76cac | |||
| 3775957bf2 | |||
| 31ce18a025 | |||
| 0cccec5526 | |||
| 0373f02f86 | |||
| 52dac0339f | |||
| b6f7f5f63f | |||
| 6271bb1079 | |||
| 0fa65f31c3 | |||
| 93d6c7d341 | |||
| b2ccd54079 | |||
| 4e9b09616d | |||
| ddb420835e | |||
| 505fd044c0 | |||
| 7711204fef | |||
| d7b6fbb241 | |||
| a670b27a1c | |||
| c2f57b086f | |||
| 083f16d7b4 | |||
| 2994b6e686 | |||
| ba15c169d7 | |||
| bbd5707711 | |||
| 1ddf83b28d | |||
| 25365678e0 | |||
| 96d215fc66 | |||
| 648ba9e61d | |||
| fcc1d9fede | |||
| 336e8aa4cc | |||
| c8f19cf783 | |||
| 12b2cc11da | |||
| ffcc35be64 | |||
| 59e0d41bdb | |||
| 9509d87b1e | |||
| b835e2d0eb | |||
| 6c3d8714a2 | |||
| 94f53f0259 | |||
| 1004f8579f | |||
| a77ec6884a | |||
| 6112e4e884 | |||
| 4a6913d4bb | |||
| f6a9e344e5 | |||
| b3296c6522 | |||
| 10a2b922d3 | |||
| ee5cdde225 | |||
| d2e9efccd0 | |||
| a07901a28a | |||
| a3954d6eb5 | |||
| 9685fcd89d | |||
| 74c23ce5ff | |||
| 746fbb15e6 | |||
| 415065b246 | |||
| 30aeef7bbd | |||
| dba1c70fa7 | |||
| f9cfb3d36b | |||
| 43b92b784d | |||
| b62a322c54 | |||
| a3a64e9a02 | |||
| 491e51f40b | |||
| b46247d9cb | |||
| 9c0e46ff4e | |||
| f62bc4a526 | |||
| 8f23600ec1 | |||
| 141f185fbf | |||
| 6f4a5f19e7 | |||
| 9d8354e58f | |||
| 947637eed7 | |||
| 5202c2ea27 | |||
| 6684dc43da | |||
| 04ec387ce5 | |||
| f145798f39 | |||
| 55f5465a9a | |||
| 0577f45ced | |||
| 7d23617f15 | |||
| 02415f8c53 | |||
| 73a47e5a97 | |||
| 5e980812b0 | |||
| 76e9735cde | |||
| 8bfc0c2fa2 | |||
| 55699f6618 | |||
| 6344c2deae | |||
| c1452131fa | |||
| 81f8e543e1 | |||
| bb6c26484d | |||
| 193a4bb180 | |||
| 0d9e6a4925 | |||
| ece9e46be9 | |||
| 918390a6a4 | |||
| 4ec0b67a71 | |||
| 356d6eca77 | |||
| 39c77accf8 | |||
| b8fba52cb3 | |||
| f247c77807 | |||
| e88938cf95 | |||
| 4f705a591e | |||
| 29687670e8 | |||
| 95daee1d8f | |||
| 11ca64a1cd | |||
| cfb727b86d | |||
| 1e4b9997f4 | |||
| bb32f23d77 | |||
| 1aa6451dba | |||
| eb0408c036 | |||
| 098a2567fa | |||
| c6534df362 | |||
| 2e4b375ad5 | |||
| 802bcf1c3d | |||
| bad0bd9053 | |||
| ca990781b0 | |||
| 6807aefce8 | |||
| 450ec4816e | |||
| ab4310b775 | |||
| 6efd986406 | |||
| 7370d7f0e7 | |||
| e733067c25 | |||
| bc2ed808f9 | |||
| 61d856f371 | |||
| a8d52a4709 | |||
| f685ce9928 | |||
| 699aa8a8e1 | |||
| 6fa7206f86 | |||
| 11cce23e21 | |||
| d109554134 | |||
| cc3a7cb5b6 | |||
| d53cff6a94 | |||
| eb211348d2 | |||
| 43618abeba | |||
| dd9769b814 | |||
| 99b40fea3f | |||
| 6f72e4fdbc | |||
| fbe845cd8e | |||
| 31413d28be | |||
| cd286cede6 | |||
| 36a3060cce | |||
| d2b108317e | |||
| dcd75f5e47 | |||
| 3d443fa147 | |||
| 2efdd2f16b | |||
| ec0348a83c | |||
| 6c4adf70c7 | |||
| 29d6076355 | |||
| fa96a41e68 | |||
| 1ea38ed5d2 | |||
| 7209903d02 | |||
| 20eda1ab3e | |||
| 44f2a7f0a9 | |||
| 0195a21f30 | |||
| 4dca747386 | |||
| 7663f502fa | |||
| 104cd417d8 | |||
| 93254d5d3d | |||
| 9a3f121a9c | |||
| bef74eb1aa | |||
| 308d8e4851 | |||
| dc010dc3ae | |||
| 61d5d3b1ad | |||
| dd70790d40 | |||
| 2f8c04edc4 | |||
| 474cc328dd | |||
| 39ff159bf7 | |||
| c7fe7aeb50 | |||
| 2cf362020f | |||
| b62bad3616 | |||
| 3d372863a4 | |||
| 1045dc04fe | |||
| 89ef7597df | |||
| 0804544564 | |||
| 671e72452a | |||
| 647c705b81 | |||
| 40c3202082 | |||
| 3b91ed3d5a | |||
| 133b17f136 | |||
| efa45dfdc9 | |||
| 79b4ea6bd9 | |||
| b483412a2e | |||
| d964515ff9 | |||
| e2c453423e | |||
| c44b7d513a | |||
| 2487f77b8a | |||
| ea80ef005c | |||
| dd45b7fbe7 | |||
| ca73da7b9b | |||
| f6e1951aa2 | |||
| 76fd563e21 | |||
| ee831ea057 | |||
| a65c2ec096 | |||
| 65822278d5 | |||
| aa3955fc67 | |||
| d4605062bb | |||
| cd3f08d55f | |||
| 6d447f0086 | |||
| c7de3873d8 | |||
| 6d4e30e8a9 | |||
| 0e308b692b | |||
| 9f74b6e063 | |||
| 1d0f47f256 | |||
| 4e9301ae2a | |||
| 7e2142ce53 | |||
| 67190605a6 | |||
| 9479a07ddf | |||
| fbed56092f | |||
| 547b82b35b | |||
| 3dc63fa02e | |||
| e0154f5b70 | |||
| b268409897 | |||
| f3a9fd12c5 | |||
| ef741d84fb | |||
| b0ea97b922 | |||
| d1560811f5 | |||
| 5e872c4e6a | |||
| 3620e4549a | |||
| b32865e790 | |||
| ebe71a2a94 | |||
| 877a2ad0ee | |||
| 7be1aaedb3 | |||
| 05eb8e9723 | |||
| d95d89ea6f | |||
| 5d1b988579 | |||
| bae85eea9e | |||
| 2be7974991 | |||
| ac03b1f081 | |||
| 5ca209dd5a | |||
| 867e93b246 | |||
| aa9c4c1c28 | |||
| 207f21cb77 | |||
| 96a47ef588 | |||
| 3bac80eb41 | |||
| 19d67a644c | |||
| 341e4113bd | |||
| 81eb19a9ab | |||
| e0221c0d05 | |||
| e6a1f23c84 | |||
| f77772e1c0 | |||
| 0a706c03c4 | |||
| df5547fda0 | |||
| a677ee081c | |||
| 7339a91513 | |||
| cd27645489 | |||
| 71f2d53e45 | |||
| f74fcc68de | |||
| abcaf9c858 | |||
| c9f185dd82 | |||
| 5e3186d311 | |||
| db35c01a09 | |||
| 3cf6c84a60 | |||
| d570d5c916 | |||
| 4f6afb62f3 | |||
| e5edfdc052 | |||
| 55cbb92a00 | |||
| 928be6a5c6 | |||
| dff3e3c80d | |||
| 8db2118a78 | |||
| dc6da4ce34 | |||
| 63eb99ae5d | |||
| 6b533fba9f | |||
| d3e102b6d2 | |||
| 2bc9761d21 | |||
| d60b9fb8e2 | |||
| b2705f3e88 | |||
| 4dea263cb0 | |||
| 54593d0a9b | |||
| 225a75a81b | |||
| 72ae4e8a9b | |||
| b171590179 | |||
| c46f79914f | |||
| 914223972a | |||
| 559435d13c | |||
| 47300e4ced | |||
| a35a35f302 | |||
| 3b09587ca9 | |||
| 1cb2de2c3e | |||
| f972c38784 | |||
| 90736c3668 | |||
| f27ef5c768 | |||
| 68ecd18646 | |||
| e3d7c91705 | |||
| d3c2fa436c | |||
| 53b7da7e3f | |||
| 8e312d80c0 | |||
| c52d0ca759 | |||
| ca024345f6 | |||
| 89ea875ca8 | |||
| f15414e509 | |||
| cfaafab057 | |||
| 0aed608578 | |||
| 00a24051c9 | |||
| 310b43d1a6 | |||
| e7f56fb870 | |||
| 1f6eee20d3 | |||
| 7db7f92abd | |||
| fdf995cf61 | |||
| fa5adbbf61 | |||
| 1bdda9f501 | |||
| 39a5d6aaa6 | |||
| 1d46ec709b | |||
| 2b4bf42812 | |||
| 6faccc643b | |||
| 9f63908f7d | |||
| 5889eb5210 | |||
| 1de40c0d4e | |||
| e201efe0b4 | |||
| f8c582ee9b | |||
| bf9e85f518 | |||
| 0366ec8160 | |||
| 58bd4a0d33 | |||
| bbff76814e | |||
| 292486c33b | |||
| 2df8937d86 | |||
| 14aa1fa1d4 | |||
| 7a4214a7b8 | |||
| bd6130013c | |||
| 0f814bbcdd | |||
| 8ec94b7dae | |||
| d5dfe439c7 | |||
| aaf3c9cb1c | |||
| abde872ab2 | |||
| ca2d2b09ad | |||
| fb7d4d988b | |||
| 26e6eea5d5 | |||
| 2458dd08d8 | |||
| dee648b3bc | |||
| f4ed32cee4 | |||
| e9c72952ab | |||
| 1bd485c43e | |||
| 421a0390ba | |||
| c7f87a7c22 | |||
| 390d5c648f | |||
| ec651c1cdb | |||
| 6f82c393e7 | |||
| afdb48367b | |||
| 53526ca3ba | |||
| 07e8f4489b | |||
| 14101a09d3 | |||
| 5344d53806 | |||
| 971535926c | |||
| c13a4ae4be | |||
| e7a03c48ae | |||
| a682329a3f | |||
| c4580f9874 | |||
| b331065b8c | |||
| 4675ca3e89 | |||
| 70e2c8e17d | |||
| db53d87cc5 | |||
| ff6244d3d1 | |||
| f0aafe9027 | |||
| 487f2acac8 | |||
| 0a5e35c58e | |||
| 34c0cab5dc | |||
| 3a666e9300 | |||
| cbe1b5d37d | |||
| 30f2044d9f | |||
| 593b000ca3 | |||
| 60c298c396 | |||
| d7f1c16454 | |||
| 4290d4be86 | |||
| bc34cb5eab | |||
| eda12f3ce3 | |||
| 65f19aac72 | |||
| 29a992a695 | |||
| dbb2166a8f | |||
| 22691329a5 | |||
| e098e1a2ad | |||
| 16d64ec988 | |||
| cb1332ff76 | |||
| 3e52060788 | |||
| f041891a3f | |||
| f902c2c1db | |||
| e1a9e1f997 | |||
| d7b39a3017 | |||
| 0f41b0d8c7 | |||
| 2d33c037ba | |||
| dca7b37eb8 | |||
| b56598ba00 | |||
| bbf550b183 | |||
| f4fc5eb1fd | |||
| d9e88cf5f9 | |||
| eccb9706f2 | |||
| 285e681413 | |||
| 4f3958d94d | |||
| d19f22255d | |||
| 87ec55619a | |||
| b91dab0f85 | |||
| df573d498e | |||
| da2b838019 | |||
| 107adeee1d | |||
| 45f933b473 | |||
| ad16bc44f1 | |||
| 96d5b7e01a | |||
| 93ffcf86b3 | |||
| de98b070db | |||
| d3d2bde440 | |||
| 0840b2b571 | |||
| fa2e784eaa | |||
| 64f2854023 | |||
| 03e3261755 | |||
| c724e68b8c | |||
| f8f66d1392 | |||
| c66bdc9f88 | |||
| 8d57547ace | |||
| 54eaf23298 | |||
| 7148306381 | |||
| d3aefef78d | |||
| ecd0cc0066 | |||
| eac490297a | |||
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b | |||
| 86e6c4f600 | |||
| 0618755236 | |||
| b21f3385e1 | |||
| dd61e0c962 | |||
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 | |||
| 018efa32f6 | |||
| 2530918dc6 | |||
| 0b09ea1573 | |||
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 | |||
| 4e32745a8f | |||
| 121573de2f | |||
| cd957526e2 | |||
| 7aa5f07731 | |||
| 5b6f7b30c3 | |||
| 18cc21a49e | |||
| 46fa2f6ade | |||
| 0a6315f177 | |||
| 841f99e19d | |||
| 8e9de46cd2 | |||
| 2d44528345 | |||
| 28a38252da | |||
| dfb268bbfc | |||
| 6532c7ff22 | |||
| d2c63cf170 | |||
| 09d66e4528 | |||
| 3078fa9d7b | |||
| 57fbb128e6 | |||
| d73266eeb8 | |||
| 2dbdf2d2b1 | |||
| 383e0adc23 | |||
| d7789f5a44 | |||
| 2638990667 | |||
| c33ecdc26f | |||
| b033d80927 | |||
| cf5d616769 | |||
| 8e722f5ab6 | |||
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 | |||
| 85b6c4fa51 | |||
| ee550e6f25 | |||
| 108a8bb51d | |||
| 3c5b26d1c1 | |||
| 01fbc3db95 | |||
| 8dd9770339 | |||
| 77842647fd | |||
| a309145829 | |||
| 5de8d38b78 | |||
| 2d6dbc552e | |||
| f0fae866dc | |||
| 87c039a63f | |||
| 2c875cbb18 | |||
| 735464e8e6 | |||
| e6a1f50554 | |||
| 530ebbf3e4 | |||
| 048f038e36 | |||
| e375adb80a | |||
| 9d7da5bc25 | |||
| 41fe7a8a47 | |||
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd | |||
| fcea194cf6 | |||
| b90650c660 | |||
| 2206abd04b | |||
| d54831765b | |||
| dd4ac9fa3d | |||
| aed9151998 | |||
| 5d4bf4eff8 | |||
| 9027125520 | |||
| ee561c0823 | |||
| 95cb5d7840 | |||
| 2f46b3c9f3 | |||
| 7bd94884f4 | |||
| 405990563b | |||
| bf9f805c71 | |||
| 28cbf84f97 | |||
| d24e51117d | |||
| 92fde9d0d7 | |||
| b81bda6ce8 | |||
| 9b3f5c458d | |||
| 3ba47f9a71 | |||
| 2ab2e30336 | |||
| 8ce6c88d58 | |||
| facae93e4b | |||
| 0eb4963247 | |||
| 02dd3c77b5 | |||
| 93995d5031 | |||
| 554d245c0c | |||
| e3cb35a036 | |||
| 3a95ea9f4e | |||
| 99f57dba76 | |||
| 415e28038d | |||
| 7bda406624 | |||
| 8282610307 | |||
| 5269c20770 | |||
| f1fb4c8495 | |||
| 5faca8c1b6 | |||
| 61778bdba8 | |||
| ab19130904 | |||
| 646aa7106b | |||
| b0f167f6da | |||
| 4d8d802006 | |||
| 6ee1d6e917 | |||
| f877ad9676 | |||
| fe817dde00 | |||
| 272973702e | |||
| c776dab2c0 | |||
| 74692c4aa5 | |||
| 71183b35c0 | |||
| ae73de19b2 | |||
| a2b413a78f | |||
| 739eeb63aa | |||
| eb26a62a87 | |||
| ad0ab6c103 | |||
| 37e1ecefd2 | |||
| e6251ab655 | |||
| 53b64025f3 | |||
| 40db395591 | |||
| 2c244c4a9a | |||
| 0baf2562b7 | |||
| 64da8d9100 | |||
| b11fea7334 | |||
| 6c8458f63c | |||
| 455b0085ec | |||
| 2b2fe940c4 | |||
| e1a7b3e8f7 | |||
| 191c4160c1 | |||
| 2e75961d1c | |||
| 88099e120a | |||
| 77ff948404 | |||
| 0e610cba16 | |||
| 8d59d617f1 | |||
| 6aa54d974e | |||
| 2aeb52bf13 | |||
| 243a45d24c | |||
| cfea44742a | |||
| 073c8378c7 | |||
| af408d38c9 | |||
| c3b14c0f58 | |||
| 69304dc839 | |||
| a3721f7a74 | |||
| 20583beb35 | |||
| b8ea8f660e | |||
| 5a45d6cd45 | |||
| 84196f9b13 | |||
| 4c9fd22a86 | |||
| 5b33623c2d | |||
| 58f4a123d2 | |||
| 11a2ae6b27 | |||
| 4e4c7df558 | |||
| 3d669ed9dd | |||
| 6e19e30f87 | |||
| dc5c0b2584 | |||
| 35712b18bc | |||
| 9958c036a0 | |||
| 14c9fbdc3c | |||
| 4fd3ec2958 | |||
| f2e9ff0a51 | |||
| cb52446f65 | |||
| 0907949f8a | |||
| 9629329bc2 | |||
| f651cd1c2f | |||
| a7438a7cd6 | |||
| e0f6e3237b | |||
| 1b141ec8f3 | |||
| 7d28d23bbd | |||
| 53f5e30b23 | |||
| 7344bf0f70 | |||
| 4905595cbb | |||
| f058b2d1e7 | |||
| 6fcc3feb73 | |||
| 50350bd78d | |||
| f065a9c952 | |||
| 72898c67b7 | |||
| ca53816b41 | |||
| ac419e7b79 | |||
| 7c0f9b4e44 | |||
| d584f3584c | |||
| a4353b10bb | |||
| b2f25c49b6 | |||
| d3255a7e14 | |||
| 2564d0874b | |||
| ca111f4783 | |||
| b6dd281a54 | |||
| 645790d0c2 | |||
| 535b055664 | |||
| 2eeb731669 | |||
| c3ae995372 | |||
| 15e7a3032c | |||
| 10ab09894b | |||
| 38811dbf23 | |||
| 3f220996ee | |||
| b0a0078ad0 | |||
| ecb913843c | |||
| 162795802f | |||
| b1890f59ee | |||
| 5c85188183 | |||
| f37cddf26d | |||
| f3f06ed06d | |||
| 07f03eb834 | |||
| e7174e8630 | |||
| 186e94c1a2 | |||
| fb424d814c | |||
| 0ad5dfd6ee | |||
| fbaafa909b | |||
| f1cc7fd340 | |||
| deec61da42 | |||
| 190ae11667 | |||
| f4ace3999d | |||
| 8b857e3d1d | |||
| 7aaf8f2595 | |||
| 39b634b6bb | |||
| 4624fdbe10 | |||
| 858794799b | |||
| cb33dd26d0 | |||
| d3d197d9d3 | |||
| 0e914a3366 | |||
| 747478f0f9 | |||
| b61de33ee0 | |||
| 970c0d5c60 | |||
| fe2069c48e | |||
| 63781ab1bd | |||
| 0b155d6925 | |||
| 076aac27ce | |||
| 7f84405279 | |||
| 13ef31c13f | |||
| 5cf4c0f150 | |||
| 04b7552b34 | |||
| 1528d29b0d | |||
| 9d895898b1 | |||
| 45be1e0a42 | |||
| ba39392c1b | |||
| f704dc78aa | |||
| 7e931d6c52 | |||
| 630e911589 | |||
| f6377d1973 | |||
| c852e954c9 | |||
| 2ee66ef967 | |||
| 5ad43470f3 | |||
| efd64d6304 | |||
| a29cff2fc5 | |||
| d161fe4f19 | |||
| df9a8ad14e | |||
| 8ddad6e652 | |||
| 3d36d3d1c5 | |||
| 329320cd40 | |||
| 63ecf60543 | |||
| 87917f68fb | |||
| 018b499010 | |||
| a4d79c2d01 | |||
| 90d3e75963 | |||
| 4887ec9d93 | |||
| 983e6cb623 | |||
| e9b2ec0f59 | |||
| c084de9c78 | |||
| 2b207833ce | |||
| 4dc095e662 | |||
| c1311f493f | |||
| 97cbe6e398 | |||
| 0bb9c5e1e5 | |||
| cf90560243 | |||
| 8def86494a | |||
| db46e01f6e | |||
| 7baf747972 | |||
| 4a17a1073e | |||
| 8997ded81d | |||
| f177d8e9ab | |||
| 808a9cc856 | |||
| be1c8d1164 | |||
| 2ecb2f3aa0 | |||
| 01dcdebda5 | |||
| 2adcc249de | |||
| 543e696bfc | |||
| 796e0204ca | |||
| f5a36ab53a |
@@ -1 +1,7 @@
|
||||
node_modules/
|
||||
.nogit/
|
||||
.git/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
test/
|
||||
test_watch/
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
|
||||
image: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -82,15 +82,13 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @git.zone/tsdocker
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci docker login
|
||||
npmci docker build
|
||||
npmci docker test
|
||||
# npmci docker push gitea.lossless.digital
|
||||
npmci docker push dockerregistry.lossless.digital
|
||||
tsdocker login
|
||||
tsdocker build
|
||||
tsdocker push
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,4 +17,8 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
# custom
|
||||
**/.claude/settings.local.json
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
.playwright-mcp/
|
||||
|
||||
85
.smartconfig.json
Normal file
85
.smartconfig.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [
|
||||
{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": [
|
||||
"ts/**/*.ts",
|
||||
"ts_*/**/*.ts",
|
||||
"test_watch/devserver.ts"
|
||||
],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*.html"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "dcrouter",
|
||||
"description": "A traffic router intended to be gating your datacenter.",
|
||||
"npmPackagename": "@serve.zone/dcrouter",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone",
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"traffic router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": ["code.foss.global"],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/dcrouter",
|
||||
"dockerregistry.lossless.digital": "serve.zone/dcrouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
}
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
17
AGENTS.md
Normal file
17
AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Agent Instructions for dcrouter
|
||||
|
||||
## Database & Migrations
|
||||
|
||||
### Collection Names
|
||||
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
|
||||
- `StoredRouteDoc` → collection `StoredRouteDoc`
|
||||
- `TargetProfileDoc` → collection `TargetProfileDoc`
|
||||
- `RouteDoc` → collection `RouteDoc`
|
||||
|
||||
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
|
||||
|
||||
### Migration Rules
|
||||
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
|
||||
- NEVER put migration logic in application code (services, managers, startup hooks).
|
||||
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
|
||||
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
|
||||
54
Dockerfile
54
Dockerfile
@@ -1,46 +1,34 @@
|
||||
# gitzone dockerfile_service
|
||||
## STAGE 1 // BUILD
|
||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
||||
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||
COPY ./ /app
|
||||
WORKDIR /app
|
||||
ARG NPMCI_TOKEN_NPM2
|
||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
||||
RUN npmci npm prepare
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN rm -rf node_modules && pnpm install
|
||||
RUN pnpm run build
|
||||
RUN rm -rf .pnpm-store node_modules && pnpm install --prod
|
||||
|
||||
## STAGE 2 // PRODUCTION
|
||||
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||
|
||||
# gcompat + libstdc++ for glibc-linked Rust binaries (smartproxy, smartmta, remoteingress)
|
||||
RUN apk add --no-cache gcompat libstdc++
|
||||
|
||||
# gitzone dockerfile_service
|
||||
## STAGE 2 // install production
|
||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
|
||||
WORKDIR /app
|
||||
COPY --from=node1 /app /app
|
||||
RUN rm -rf .pnpm-store
|
||||
ARG NPMCI_TOKEN_NPM2
|
||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
||||
RUN npmci npm prepare
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN rm -rf node_modules/ && pnpm install --prod
|
||||
COPY --from=build /app /app
|
||||
|
||||
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||
ENV DCROUTER_HEAP_SIZE=512
|
||||
ENV UV_THREADPOOL_SIZE=16
|
||||
|
||||
## STAGE 3 // rebuild dependencies for alpine
|
||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
|
||||
WORKDIR /app
|
||||
COPY --from=node2 /app /app
|
||||
ARG NPMCI_TOKEN_NPM2
|
||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
||||
RUN npmci npm prepare
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN pnpm rebuild -r
|
||||
|
||||
## STAGE 4 // the final production image with all dependencies in place
|
||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
|
||||
WORKDIR /app
|
||||
COPY --from=node3 /app /app
|
||||
|
||||
### Healthchecks
|
||||
RUN pnpm install -g @servezone/healthy
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD [ "healthy" ]
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["npm", "start"]
|
||||
LABEL org.opencontainers.image.title="dcrouter" \
|
||||
org.opencontainers.image.description="Multi-service datacenter gateway" \
|
||||
org.opencontainers.image.source="https://code.foss.global/serve.zone/dcrouter"
|
||||
|
||||
# HTTP/HTTPS, SMTP/Submission/SMTPS, DNS, RADIUS, OpsServer, RemoteIngress, dynamic range
|
||||
EXPOSE 80 443 25 587 465 53/tcp 53/udp 1812/udp 1813/udp 3000 8443 29000-30000
|
||||
|
||||
CMD ["sh", "-c", "node --max_old_space_size=${DCROUTER_HEAP_SIZE} ./cli.js"]
|
||||
|
||||
2513
changelog.md
Normal file
2513
changelog.md
Normal file
File diff suppressed because it is too large
Load Diff
4
cli.child.js
Normal file
4
cli.child.js
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.CLI_CALL = 'true';
|
||||
import * as cliTool from './ts/index.js';
|
||||
cliTool.runCli();
|
||||
121
html/index.html
Normal file
121
html/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!--gitzone default-->
|
||||
<!-- made by Lossless GmbH -->
|
||||
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--Lets set some basic meta tags-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
|
||||
|
||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||
<style>
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
projectVersion = '';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<style>
|
||||
body {
|
||||
background: #303f9f;
|
||||
font-family: Inter, Roboto, sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-top: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: #4357d9;
|
||||
}
|
||||
.contentHeader {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<div class="logo">
|
||||
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="contentHeader">We need JavaScript to run properly!</div>
|
||||
<div class="content">
|
||||
This site is being built using lit-element (made by Google). This technology works with
|
||||
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||
JavaScript.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="text/javascript" async defer>
|
||||
window.revenueEnabled = true;
|
||||
const runRevenueCheck = async () => {
|
||||
var e = document.createElement('div');
|
||||
e.id = '476kjuhzgtr764';
|
||||
e.style.display = 'none';
|
||||
document.body.appendChild(e);
|
||||
if (document.getElementById('476kjuhzgtr764')) {
|
||||
window.revenueEnabled = true;
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
</script>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "platformservice",
|
||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
||||
"npmPackagename": "@serve.zone/platformservice",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||
}
|
||||
}
|
||||
132
package.json
132
package.json
@@ -1,32 +1,122 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"version": "1.0.2",
|
||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.19.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||
"./apiclient": "./dist_ts_apiclient/index.js"
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/)",
|
||||
"start": "(node --max_old_space_size=100 ./cli.js)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild --web --allowimplicitany)"
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release:docker": "tsdocker push --verbose",
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.17",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tstest": "^1.0.28",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@push.rocks/tapbundle": "^5.0.3"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.4",
|
||||
"@api.global/typedserver": "^3.0.20",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/smartdata": "^5.0.7",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartstate": "^2.0.0"
|
||||
}
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.78.2",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.6.2",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.7.4",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.2",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"mail router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management",
|
||||
"RADIUS",
|
||||
"AAA",
|
||||
"network authentication",
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_apiclient/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"dist_ts_apiclient/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
14969
pnpm-lock.yaml
generated
14969
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
773
readme.hints.md
Normal file
773
readme.hints.md
Normal file
@@ -0,0 +1,773 @@
|
||||
# Implementation Hints and Learnings
|
||||
|
||||
## smartmta Migration (2026-02-11)
|
||||
|
||||
### Overview
|
||||
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
|
||||
|
||||
### Architecture
|
||||
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
|
||||
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
|
||||
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
|
||||
|
||||
### Key API Differences (smartmta vs old custom MTA)
|
||||
- `updateEmailRoutes()` instead of `updateRoutes()`
|
||||
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
|
||||
- `bounceManager` is private, but exposed via public methods:
|
||||
- `emailServer.getSuppressionList()`
|
||||
- `emailServer.getHardBouncedAddresses()`
|
||||
- `emailServer.getBounceHistory(email)`
|
||||
- `emailServer.removeFromSuppressionList(email)`
|
||||
- `Email` class imported from `@push.rocks/smartmta`
|
||||
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
|
||||
|
||||
### Deleted Directories
|
||||
- `ts/mail/` (60 files) — replaced by smartmta
|
||||
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
|
||||
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
|
||||
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
|
||||
|
||||
### Remaining Cache Documents
|
||||
- `CachedEmail` — kept (dcrouter-level queue persistence)
|
||||
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
|
||||
|
||||
### Dependencies Removed
|
||||
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
|
||||
|
||||
### Pre-existing Test Failures (not caused by migration)
|
||||
- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue)
|
||||
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
|
||||
|
||||
### smartmta Location
|
||||
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
|
||||
|
||||
## Dependency Upgrade (2026-02-11)
|
||||
|
||||
### SmartProxy v23.1.2 Route Validation
|
||||
- SmartProxy 23.1.2 enforces stricter route validation
|
||||
- Forward actions MUST use `targets` (array) instead of `target` (singular)
|
||||
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
|
||||
|
||||
```typescript
|
||||
// WRONG - will fail validation
|
||||
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
|
||||
|
||||
// CORRECT
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
|
||||
```
|
||||
|
||||
**Files Fixed:**
|
||||
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
|
||||
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
|
||||
|
||||
## Dependency Upgrade (2026-02-10)
|
||||
|
||||
### SmartProxy v23.1.0 Upgrade
|
||||
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
|
||||
|
||||
**Key Changes:**
|
||||
- Rust-based proxy components for improved performance
|
||||
- Rust binary runs as separate process via IPC
|
||||
- `getStatistics()` now returns `Promise<any>` (was synchronous)
|
||||
- nftables-proxy removed (not used by dcrouter)
|
||||
|
||||
**Code Changes Required:**
|
||||
```typescript
|
||||
// Old (synchronous)
|
||||
const proxyStats = this.dcRouter.smartProxy.getStatistics();
|
||||
|
||||
// New (async)
|
||||
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
|
||||
|
||||
## Dependency Upgrade (2026-02-01)
|
||||
|
||||
### Major Upgrades Completed
|
||||
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||
- `uuid`: 11.1.0 → 13.0.0
|
||||
|
||||
### Breaking Changes Fixed
|
||||
|
||||
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||
```typescript
|
||||
// Old
|
||||
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||
// New
|
||||
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||
```
|
||||
|
||||
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||
```typescript
|
||||
// Old
|
||||
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||
const json = resp.body;
|
||||
// New
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||
const json = await resp.json();
|
||||
```
|
||||
|
||||
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||
```typescript
|
||||
// Old (deprecated but supported)
|
||||
<dees-icon iconFA="check"></dees-icon>
|
||||
// New
|
||||
<dees-icon icon="fa:check"></dees-icon>
|
||||
<dees-icon icon="lucide:menu"></dees-icon>
|
||||
```
|
||||
|
||||
### TC39 Decorators
|
||||
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||
- Required for TC39 standard decorator support
|
||||
|
||||
### tswatch Configuration
|
||||
The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `.smartconfig.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## RADIUS Server Integration (2026-02-01)
|
||||
|
||||
### Overview
|
||||
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||
|
||||
### Key Features
|
||||
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||
|
||||
### Configuration Example
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
radiusConfig: {
|
||||
authPort: 1812, // Authentication port (default)
|
||||
acctPort: 1813, // Accounting port (default)
|
||||
clients: [
|
||||
{
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
vlanAssignment: {
|
||||
defaultVlan: 100, // VLAN for unknown MACs
|
||||
allowUnknownMacs: true,
|
||||
mappings: [
|
||||
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||
]
|
||||
},
|
||||
accounting: {
|
||||
enabled: true,
|
||||
retentionDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Components
|
||||
- `RadiusServer` - Main server wrapping smartradius
|
||||
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||
- `AccountingManager` - Session tracking and billing data
|
||||
|
||||
### OpsServer API Endpoints
|
||||
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||
|
||||
### Files
|
||||
- `ts/radius/` - RADIUS module
|
||||
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||
|
||||
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||
|
||||
### Issue
|
||||
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
|
||||
|
||||
### Root Cause
|
||||
The test was using outdated email config properties:
|
||||
- Used `domainRules: []` (non-existent property)
|
||||
- Used `defaultMode` (non-existent property)
|
||||
- Missing required `domains: []` property
|
||||
- Missing required `routes: []` property
|
||||
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
|
||||
|
||||
### Fix
|
||||
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
|
||||
```typescript
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [], // Required: domain configurations
|
||||
routes: [] // Required: email routing rules
|
||||
};
|
||||
```
|
||||
|
||||
And fixed the property name:
|
||||
```typescript
|
||||
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
|
||||
```
|
||||
|
||||
### Key Learning
|
||||
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
|
||||
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
|
||||
- `routes: IEmailRoute[]` is required (email routing rules)
|
||||
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
|
||||
|
||||
## Network Metrics Implementation (2025-06-23)
|
||||
|
||||
### SmartProxy Metrics API Integration
|
||||
- Updated to use new SmartProxy metrics API (v19.6.7)
|
||||
- Use `getMetrics()` for detailed metrics with grouped methods:
|
||||
```typescript
|
||||
const metrics = smartProxy.getMetrics();
|
||||
metrics.connections.active() // Current active connections
|
||||
metrics.throughput.instant() // Real-time throughput {in, out}
|
||||
metrics.connections.topIPs(10) // Top 10 IPs by connection count
|
||||
```
|
||||
- Use `getStatistics()` for basic stats
|
||||
|
||||
### Network Traffic Display
|
||||
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
|
||||
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||
- Throughput tiles and graph use same data source for consistency
|
||||
|
||||
### Requests/sec vs Connections
|
||||
- Requests/sec shows HTTP request counts (derived from connections)
|
||||
- Single connection can handle multiple requests
|
||||
- Current implementation tracks connections, not individual requests
|
||||
- Trend line shows historical request counts, not throughput
|
||||
|
||||
## DKIM Implementation Status (2025-05-30)
|
||||
|
||||
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
|
||||
|
||||
## SmartProxy Usage
|
||||
|
||||
### New Route-Based Architecture (v18+)
|
||||
- SmartProxy now uses a route-based configuration system
|
||||
- Routes define match criteria and actions instead of simple port-to-port forwarding
|
||||
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
|
||||
|
||||
```typescript
|
||||
// NEW: Route-based SmartProxy configuration
|
||||
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'https-traffic',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: ['example.com', '*.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'backend.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'fallback.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
acme: {
|
||||
accountEmail: 'admin@example.com',
|
||||
enabled: true,
|
||||
useProduction: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Migration from Old to New
|
||||
```typescript
|
||||
// OLD configuration style (deprecated)
|
||||
{
|
||||
fromPort: 443,
|
||||
toPort: 8080,
|
||||
targetIP: 'backend.server.com',
|
||||
domainConfigs: [...]
|
||||
}
|
||||
|
||||
// NEW route-based style
|
||||
{
|
||||
routes: [{
|
||||
name: 'main-route',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend.server.com', port: 8080 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Component Usage
|
||||
- Use SmartProxy components directly instead of creating your own wrappers
|
||||
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||
|
||||
### Certificate Management
|
||||
- SmartProxy has built-in ACME certificate management
|
||||
- Configure it in the `acme` property of SmartProxy options
|
||||
- Use `accountEmail` (not `email`) for the ACME contact email
|
||||
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
|
||||
|
||||
## qenv Usage
|
||||
|
||||
### Direct Usage
|
||||
- Use qenv directly instead of creating environment variable wrappers
|
||||
- Instantiate qenv with appropriate basePath and nogitPath:
|
||||
|
||||
```typescript
|
||||
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
### SmartProxy Interfaces
|
||||
- Always check the interfaces from the node_modules to ensure correct property names
|
||||
- Important interfaces for the new architecture:
|
||||
- `ISmartProxyOptions`: Main configuration with `routes` array
|
||||
- `IRouteConfig`: Individual route configuration
|
||||
- `IRouteMatch`: Match criteria for routes
|
||||
- `IRouteTarget`: Target configuration for forwarding
|
||||
- `IAcmeOptions`: ACME certificate configuration
|
||||
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
|
||||
|
||||
### New Route Configuration
|
||||
```typescript
|
||||
interface IRouteConfig {
|
||||
name: string;
|
||||
match: {
|
||||
ports: number | number[];
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
headers?: Record<string, string | RegExp>;
|
||||
};
|
||||
action: {
|
||||
type: 'forward' | 'redirect' | 'block' | 'static';
|
||||
target?: {
|
||||
host: string | string[] | ((context) => string);
|
||||
port: number | 'preserve' | ((context) => number);
|
||||
};
|
||||
};
|
||||
tls?: {
|
||||
mode: TTlsMode;
|
||||
certificate?: 'auto' | { key: string; cert: string; };
|
||||
};
|
||||
security?: {
|
||||
authentication?: IRouteAuthentication;
|
||||
rateLimit?: IRouteRateLimit;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Required Properties
|
||||
- For `ISmartProxyOptions`, `routes` array is the main configuration
|
||||
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||
- Routes must have `name`, `match`, and `action` properties
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
- Follow the project's test structure, using `@push.rocks/tapbundle`
|
||||
- Use `expect(value).toEqual(expected)` for equality checks
|
||||
- Use `expect(value).toBeTruthy()` for boolean assertions
|
||||
|
||||
```typescript
|
||||
tap.test('test description', async () => {
|
||||
const result = someFunction();
|
||||
expect(result.property).toEqual('expected value');
|
||||
expect(result.valid).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
- Include a cleanup test to ensure proper test resource handling
|
||||
- Add a `stop` test to forcefully end the test when needed:
|
||||
|
||||
```typescript
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Simplicity
|
||||
- Prefer direct usage of libraries instead of creating wrappers
|
||||
- Don't reinvent functionality that already exists in dependencies
|
||||
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
|
||||
|
||||
### Component Integration
|
||||
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||
- Use parallel operations for performance (like in the `stop()` method)
|
||||
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
||||
|
||||
## Email Integration with SmartProxy
|
||||
|
||||
### Architecture (Post-Migration)
|
||||
- Email traffic is routed through SmartProxy using automatic route generation
|
||||
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
|
||||
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
|
||||
- smartmta's Rust SMTP bridge handles SMTP protocol processing
|
||||
|
||||
### Port Mapping
|
||||
- External port 25 → Internal port 10025 (SMTP)
|
||||
- External port 587 → Internal port 10587 (Submission)
|
||||
- External port 465 → Internal port 10465 (SMTPS)
|
||||
|
||||
### TLS Handling
|
||||
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
|
||||
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
|
||||
|
||||
## SmartMetrics Integration (2025-06-12) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
|
||||
|
||||
### Key Findings
|
||||
1. **CPU Metrics:**
|
||||
- SmartMetrics provides `cpuUsageText` as a string percentage
|
||||
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
|
||||
- UI was incorrectly dividing by 2, showing half the actual CPU usage
|
||||
|
||||
2. **Memory Metrics:**
|
||||
- SmartMetrics calculates `maxMemoryMB` as minimum of:
|
||||
- V8 heap size limit
|
||||
- System total memory
|
||||
- Docker memory limit (if available)
|
||||
- Provides `memoryUsageBytes` (total process memory including children)
|
||||
- Provides `memoryPercentage` (pre-calculated percentage)
|
||||
- UI was only showing heap usage, missing actual memory constraints
|
||||
|
||||
### Changes Made
|
||||
1. **MetricsManager Enhanced:**
|
||||
- Added `maxMemoryMB` from SmartMetrics instance
|
||||
- Added `actualUsageBytes` from SmartMetrics data
|
||||
- Added `actualUsagePercentage` from SmartMetrics data
|
||||
- Kept existing memory fields for compatibility
|
||||
|
||||
2. **Interface Updated:**
|
||||
- Added optional fields to `IServerStats.memoryUsage`
|
||||
- Fields are optional to maintain backward compatibility
|
||||
|
||||
3. **UI Fixed:**
|
||||
- Removed incorrect CPU division by 2
|
||||
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
|
||||
- Shows actual memory usage vs max memory limit (not just heap)
|
||||
|
||||
### Result
|
||||
- CPU now shows accurate usage percentage
|
||||
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
|
||||
- Better monitoring for containerized environments
|
||||
|
||||
## Network UI Implementation (2025-06-20) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
|
||||
|
||||
### Architecture
|
||||
1. **MetricsManager Integration:**
|
||||
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
|
||||
- Extended with `getNetworkStats()` method to expose unused metrics:
|
||||
- `getConnectionsByIP()` - Connection counts by IP address
|
||||
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
|
||||
- `getTopIPs()` - Top connecting IPs sorted by connection count
|
||||
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
|
||||
|
||||
2. **Existing Infrastructure Leveraged:**
|
||||
- `getActiveConnections` endpoint already exists in security.handler.ts
|
||||
- Enhanced to include real SmartProxy data via MetricsManager
|
||||
- IConnectionInfo interface already supports network data structures
|
||||
|
||||
3. **State Management:**
|
||||
- Added `INetworkState` interface following existing patterns
|
||||
- Created `networkStatePart` with connections, throughput, and IP data
|
||||
- Integrated with existing auto-refresh mechanism
|
||||
|
||||
4. **UI Changes (Minimal):**
|
||||
- Removed `generateMockData()` method and all mock generation
|
||||
- Connected to real `networkStatePart` state
|
||||
- Added `renderTopIPs()` section to display top connected IPs
|
||||
- Updated traffic chart to show real request data
|
||||
- Kept all existing UI components (DeesTable, DeesChartArea)
|
||||
|
||||
### Implementation Details
|
||||
1. **Data Transformation:**
|
||||
- Converts IConnectionInfo[] to INetworkRequest[] for table display
|
||||
- Calculates traffic buckets based on selected time range
|
||||
- Maps connection data to chart-compatible format
|
||||
|
||||
2. **Real Metrics Displayed:**
|
||||
- Active connections count (from server stats)
|
||||
- Requests per second (calculated from recent connections)
|
||||
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
|
||||
- Top IPs with connection counts and percentages
|
||||
|
||||
3. **TypeScript Fixes:**
|
||||
- SmartProxy methods like `getThroughputRate()` not in base interface
|
||||
- Implemented manual fallbacks for missing methods
|
||||
- Fixed `publicIpv4` → `publicIp` property name
|
||||
|
||||
### Result
|
||||
- Network view now shows real connection activity
|
||||
- Auto-refreshes with other stats every second
|
||||
- Displays actual IPs and connection counts
|
||||
- No more mock/demo data
|
||||
- Minimal code changes (streamlined approach)
|
||||
|
||||
### Throughput Data Fix (2025-06-20)
|
||||
The throughput was showing 0 because:
|
||||
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
|
||||
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
|
||||
3. `getThroughputRate()` only exists in the extended interface
|
||||
|
||||
**Solution implemented:**
|
||||
1. Updated MetricsManager to check if methods exist at runtime and call them
|
||||
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
|
||||
3. Created new `getNetworkStats` endpoint in security.handler.ts
|
||||
4. Updated frontend to call the new endpoint for complete network metrics
|
||||
|
||||
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||
|
||||
## Email Operations Dashboard (2026-02-01)
|
||||
|
||||
### Overview
|
||||
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||
|
||||
### New Files Created
|
||||
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||
|
||||
### Key Interfaces
|
||||
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||
|
||||
### UI Changes (ops-view-emails.ts)
|
||||
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||
- **Queued**: Emails pending delivery
|
||||
- **Sent**: Successfully delivered emails
|
||||
- **Failed**: Failed emails with resend capability
|
||||
- **Security**: Security incidents from SecurityLogger
|
||||
- Removed `generateMockEmails()` method
|
||||
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||
- Added resend button for failed emails
|
||||
- Added security incident detail view
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
```
|
||||
|
||||
### Backend Data Access
|
||||
The handler accesses data from:
|
||||
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||
|
||||
## OpsServer UI Fixes (2026-02-02)
|
||||
|
||||
### Configuration Page Fix
|
||||
The configuration page had field name mismatches between frontend and backend:
|
||||
- Frontend expected `server` and `storage` sections
|
||||
- Backend returns `proxy` section (not `server`)
|
||||
- Backend has no `storage` section
|
||||
|
||||
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||
- `proxy` instead of `server`
|
||||
- Removed non-existent `storage` section
|
||||
- Added optional chaining (`?.`) for safety
|
||||
|
||||
### Auth Persistence Fix
|
||||
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||
- User login was lost on page refresh
|
||||
- State reset to logged out after browser restart
|
||||
|
||||
**Changes**:
|
||||
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||
- Now uses IndexedDB to persist across browser sessions
|
||||
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||
- Validates stored JWT hasn't expired before auto-logging in
|
||||
- Clears expired sessions and shows login form
|
||||
|
||||
## Config UI Read-Only Conversion (2026-02-03)
|
||||
|
||||
### Overview
|
||||
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||
- Removed `updateConfiguration` handler
|
||||
- Removed `updateConfiguration()` private method
|
||||
- Kept `getConfiguration` handler (read-only)
|
||||
|
||||
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||
- Removed `IReq_UpdateConfiguration` interface
|
||||
- Kept `IReq_GetConfiguration` interface
|
||||
|
||||
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||
- Removed `editingSection` and `editedConfig` state properties
|
||||
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||
- Removed Edit/Save/Cancel buttons
|
||||
- Removed warning banner about immediate changes
|
||||
- Enhanced read-only display with:
|
||||
- Status badges for boolean values (enabled/disabled)
|
||||
- Array display as pills/tags with counts
|
||||
- Section icons (mail, globe, network, shield)
|
||||
- Better formatting for numbers and byte sizes
|
||||
- Empty state handling ("Not configured", "None configured")
|
||||
- Info note explaining configuration is read-only
|
||||
|
||||
4. **State Management (`ts_web/appstate.ts`)**:
|
||||
- Removed `updateConfigurationAction`
|
||||
- Kept `fetchConfigurationAction` (read-only)
|
||||
|
||||
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||
- Added test for read-only config access
|
||||
- Kept auth flow testing with different protected endpoint
|
||||
|
||||
6. **Documentation**:
|
||||
- `readme.md`: Updated API endpoints to show config as read-only
|
||||
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||
|
||||
### Visual Display Features
|
||||
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||
- Arrays displayed as pills with count summaries
|
||||
- Section headers with relevant Lucide icons
|
||||
- Numbers formatted with locale separators
|
||||
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||
- Time values shown with "seconds" suffix
|
||||
- Nested objects with visual indentation
|
||||
|
||||
## Smartdata Cache System (2026-02-03)
|
||||
|
||||
### Overview
|
||||
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
|
||||
|
||||
### Technology Stack
|
||||
| Layer | Package | Purpose |
|
||||
|-------|---------|---------|
|
||||
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
|
||||
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
|
||||
|
||||
### TC39 Decorators
|
||||
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
|
||||
- Removed `experimentalDecorators: true`
|
||||
- Removed `emitDecoratorMetadata: true`
|
||||
|
||||
This is required for smartdata v7+ compatibility.
|
||||
|
||||
### Cache Document Classes
|
||||
Located in `ts/cache/documents/`:
|
||||
|
||||
| Class | Purpose | Default TTL |
|
||||
|-------|---------|-------------|
|
||||
| `CachedEmail` | Email queue items | 30 days |
|
||||
| `CachedIPReputation` | IP reputation lookups | 24 hours |
|
||||
|
||||
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
|
||||
|
||||
### Usage Pattern
|
||||
```typescript
|
||||
// Document classes use smartdata decorators
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
// Query examples
|
||||
const email = await CachedEmail.getInstance({ id: 'abc123' });
|
||||
const pending = await CachedEmail.getInstances({ status: 'pending' });
|
||||
await email.save();
|
||||
await email.delete();
|
||||
```
|
||||
|
||||
### Configuration
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
cacheConfig: {
|
||||
enabled: true,
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||
dbName: 'dcrouter',
|
||||
cleanupIntervalHours: 1,
|
||||
ttlConfig: {
|
||||
emails: 30, // days
|
||||
ipReputation: 1, // days
|
||||
bounces: 30, // days
|
||||
dkimKeys: 90, // days
|
||||
suppression: 30 // days
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Cache Cleaner
|
||||
- Runs hourly by default (configurable via `cleanupIntervalHours`)
|
||||
- Finds and deletes documents where `expiresAt < now()`
|
||||
- Uses smartdata's `getInstances()` + `delete()` pattern
|
||||
|
||||
### Key Files
|
||||
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
|
||||
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
|
||||
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
|
||||
- `ts/cache/documents/*.ts` - Document class definitions
|
||||
360
readme.md
360
readme.md
@@ -1,31 +1,339 @@
|
||||
# @serve.zone/platformservice
|
||||
contains the platformservice container with mail, sms, letter, ai services.
|
||||
# @serve.zone/dcrouter
|
||||
|
||||
## Availabililty and Links
|
||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@serve.zone/platformservice)
|
||||
* [gitlab.com (source)](https://gitlab.com/serve.zone/platformservice)
|
||||
* [github.com (source mirror)](https://github.com/serve.zone/platformservice)
|
||||
* [docs (typedoc)](https://serve.zone.gitlab.io/platformservice/)
|
||||

|
||||
|
||||
## Status for master
|
||||
`dcrouter` is a TypeScript control plane for running a serious multi-protocol edge or datacenter gateway from one process. It orchestrates HTTP/HTTPS and TCP routing through SmartProxy, email through smartmta, authoritative DNS and DNS-over-HTTPS, RADIUS, remote ingress tunnels, VPN access control, a typed Ops API, and a web dashboard.
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
Snyk | [](https://lossless.cloud)
|
||||
TypeScript Support | [](https://lossless.cloud)
|
||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
||||
Code Style | [](https://lossless.cloud)
|
||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
||||
It is built for operators who want one place to define routes, expose services, manage certificates, register domains and DNS providers, control VPN-only access, and inspect what is going on in production.
|
||||
|
||||
## Usage
|
||||
Use TypeScript for best in class intellisense.
|
||||
For further information read the linked docs at the top of this readme.
|
||||
## Issue Reporting and Security
|
||||
|
||||
## Legal
|
||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Why dcrouter
|
||||
|
||||
- 🌐 Run HTTP/HTTPS, TCP/SNI, email, DNS, RADIUS, VPN, and remote ingress from one orchestrated service.
|
||||
- 🔐 Keep certificates, routes, tokens, domains, and reusable route references in one management plane.
|
||||
- 🧠 Use system-managed routes for config-, email-, and DNS-derived traffic, plus API-managed routes for dynamic additions.
|
||||
- 📊 Get an Ops UI and TypedRequest API for monitoring, automation, and day-2 operations.
|
||||
- ⚡ Lean on Rust-backed data planes where it matters: proxying, DNS, email delivery, remote ingress, and VPN.
|
||||
|
||||
## What It Covers
|
||||
|
||||
| Area | What dcrouter does |
|
||||
| --- | --- |
|
||||
| HTTP / HTTPS / TCP | SmartProxy-based routing, TLS termination or passthrough, path/domain matching, optional HTTP/3 augmentation |
|
||||
| Email | smartmta-based SMTP ingress and delivery, route-based email handling, DKIM-aware domain support |
|
||||
| DNS | Authoritative DNS, DNS-over-HTTPS bootstrap routes, provider-backed and dcrouter-hosted domains and records |
|
||||
| Certificates | ACME-aware certificate management with dashboard and API support |
|
||||
| Access control | Source profiles, network targets, VPN-gated routes, API tokens, admin auth |
|
||||
| Network edge | Remote ingress hub for edge nodes tunneling traffic into the router |
|
||||
| Operations | Web dashboard, TypedRequest API, logs, metrics, health, route and token management |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
This is the smallest realistic setup: one HTTP route, embedded database enabled, and the Ops dashboard on port `3000`.
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'app',
|
||||
match: {
|
||||
domains: ['app.example.com'],
|
||||
ports: [80],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
Once the router is running, you can:
|
||||
|
||||
- open the Ops dashboard on `http://localhost:3000`
|
||||
- inspect the route in the System Routes view
|
||||
- add API-managed routes through the dashboard or API client
|
||||
- enable DNS, email, VPN, remote ingress, or RADIUS by adding the corresponding config blocks
|
||||
|
||||
## Mental Model
|
||||
|
||||
`dcrouter` is not a toy reverse proxy with a few side features. It is an orchestrator that wires multiple specialized services into one management plane.
|
||||
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| `DcRouter` | Startup order, shutdown, service wiring, configuration assembly, route hydration |
|
||||
| SmartProxy | HTTP/HTTPS, TCP/SNI, TLS, HTTP/3-capable route execution |
|
||||
| smartmta | SMTP ingress, queueing, DKIM-aware email processing and delivery |
|
||||
| SmartDNS | Authoritative DNS and DoH request handling |
|
||||
| smartradius | Network authentication, VLAN assignment, accounting |
|
||||
| remoteingress | Edge tunnel registrations and runtime forwarding into the hub |
|
||||
| smartvpn | VPN server and client access mediation for protected routes |
|
||||
| OpsServer + dashboard | Typed API and browser UI for operations |
|
||||
| smartdata-backed DB | Persistent routes, tokens, domains, records, profiles, cert metadata, caches |
|
||||
|
||||
## Route Model
|
||||
|
||||
Routes fall into two ownership classes:
|
||||
|
||||
| Route kind | Origin | Ownership | What users can do |
|
||||
| --- | --- | --- | --- |
|
||||
| System routes | `config`, `email`, `dns` | Derived from config or runtime-managed subsystems | View and toggle only |
|
||||
| API routes | `api` | Created through route-management API | Create, edit, delete, toggle |
|
||||
|
||||
Important details:
|
||||
|
||||
- system routes are persisted with a stable `systemKey`
|
||||
- config-, email-, and DNS-derived routes show up in the System Routes view
|
||||
- DoH routes are persisted as system-route templates and get their live socket handlers attached at apply time
|
||||
- system routes are managed by the system, not edited directly by operators
|
||||
|
||||
## Core Features
|
||||
|
||||
### Traffic Routing
|
||||
|
||||
- Domain-, port-, and path-based SmartProxy routes
|
||||
- HTTP/HTTPS reverse proxying and generic TCP/SNI forwarding
|
||||
- Optional HTTP/3 augmentation for qualifying HTTPS routes
|
||||
- Reusable source profiles and network targets for route composition
|
||||
- Remote ingress aware routing for edge-delivered traffic
|
||||
|
||||
### Email
|
||||
|
||||
- smartmta-based inbound email handling
|
||||
- Route-based mail actions such as forward, process, deliver, reject
|
||||
- DKIM-aware domain handling and DNS record generation support
|
||||
- Email-domain management through the Ops API and UI
|
||||
- Queue, resend, failure, and delivery inspection through the dashboard and API
|
||||
|
||||
### DNS
|
||||
|
||||
- Authoritative scopes via `dnsScopes`
|
||||
- Bootstrap nameserver domains via `dnsNsDomains`
|
||||
- DNS-over-HTTPS endpoints for `/dns-query` and `/resolve`
|
||||
- Managed domains, managed records, and provider-backed DNS integrations
|
||||
- Internal email DNS record generation for `internal-dns` email domains
|
||||
|
||||
### Certificates and ACME
|
||||
|
||||
- Certificate overview and operations through OpsServer
|
||||
- Import, export, delete, and reprovision flows
|
||||
- DB-backed ACME configuration management
|
||||
- Integration with managed DNS for certificate provisioning flows
|
||||
- Routes can declare `certificate: 'auto'`, but actual automated issuance depends on ACME being configured in the management plane
|
||||
|
||||
### VPN, RADIUS, and Remote Ingress
|
||||
|
||||
- VPN-gated routes with target-profile-based access matching
|
||||
- WireGuard-oriented VPN management with dcrouter-side client lifecycle support
|
||||
- RADIUS MAB, VLAN assignment, and accounting
|
||||
- Remote ingress hub for edge nodes tunneling traffic into central routes
|
||||
|
||||
### Operations Plane
|
||||
|
||||
- Web dashboard with overview, network, routes, access, security, domains, certificates, logs, and email views
|
||||
- TypedRequest API for automation and external control
|
||||
- API tokens with scoped access
|
||||
- Metrics, health, logs, and per-feature operational views
|
||||
|
||||
## Configuration Overview
|
||||
|
||||
The main entry point is `IDcRouterOptions`.
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `smartProxyConfig` | Main HTTP/HTTPS and TCP/SNI routing configuration |
|
||||
| `emailConfig` | smartmta server config and email routes |
|
||||
| `emailPortConfig` | External-to-internal email port mapping and email storage path tuning |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for NS bootstrap and DoH routes |
|
||||
| `dnsScopes` | Authoritative DNS zones managed by dcrouter |
|
||||
| `dnsRecords` | Static constructor-defined records |
|
||||
| `publicIp` / `proxyIps` | DNS A-record exposure strategy |
|
||||
| `dbConfig` | Embedded or external Mongo-backed persistence and seeding |
|
||||
| `radiusConfig` | RADIUS authentication, VLAN, and accounting setup |
|
||||
| `remoteIngressConfig` | Edge tunnel hub setup |
|
||||
| `vpnConfig` | VPN server and client access configuration |
|
||||
| `http3` | Global HTTP/3 behavior for qualifying routes |
|
||||
| `opsServerPort` | Dashboard and TypedRequest API port |
|
||||
|
||||
## Example: Enabling DNS, Email, and VPN
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: {
|
||||
domains: ['app.example.com'],
|
||||
ports: [443],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'internal-dns',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'inbound-mail',
|
||||
match: { recipients: '*@example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: { host: 'mail-backend.example.com', port: 25 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
publicIp: '203.0.113.10',
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
clients: [
|
||||
{
|
||||
clientId: 'ops-laptop',
|
||||
description: 'Operations laptop',
|
||||
},
|
||||
],
|
||||
},
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
## Operations API and Dashboard
|
||||
|
||||
With the database enabled, dcrouter exposes a management plane for:
|
||||
|
||||
- routes and route toggles
|
||||
- API tokens
|
||||
- source profiles and network targets
|
||||
- DNS providers, domains, and records
|
||||
- ACME configuration and certificate lifecycle
|
||||
- email domains and email operations
|
||||
- VPN clients, remote ingress edges, and RADIUS data
|
||||
|
||||
The browser dashboard is built from the `ts_web` package and is served by OpsServer. The same backend is accessible programmatically via TypedRequest or the dedicated API client package.
|
||||
|
||||
## Programmatic API Client
|
||||
|
||||
Use the API client when you want automation or integration code instead of clicking through the dashboard.
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin', 'password');
|
||||
|
||||
const { routes } = await client.routes.list();
|
||||
const systemRoutes = routes.filter((route) => route.origin !== 'api');
|
||||
|
||||
if (systemRoutes[0]) {
|
||||
await systemRoutes[0].toggle(false);
|
||||
}
|
||||
|
||||
await client.routes.build()
|
||||
.setName('api-gateway')
|
||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8081 }] })
|
||||
.save();
|
||||
```
|
||||
|
||||
See `./ts_apiclient/readme.md` for the dedicated API-client package docs.
|
||||
|
||||
## Published Modules
|
||||
|
||||
This repository publishes multiple modules from the same codebase.
|
||||
|
||||
| Module | Purpose | Docs |
|
||||
| --- | --- | --- |
|
||||
| `@serve.zone/dcrouter` | Main orchestrator and server package | `./readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Shared TypedRequest request and data interfaces | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Startup migration runner for dcrouter data | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Web dashboard entry and UI components | `./ts_web/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Typed OO API client | `./ts_apiclient/readme.md` |
|
||||
|
||||
## Development and Testing
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Target a single test file while working on one area:
|
||||
|
||||
```bash
|
||||
tstest test/test.dns-runtime-routes.node.ts --verbose
|
||||
```
|
||||
|
||||
## Notes for Operators
|
||||
|
||||
- Database-backed management features depend on `dbConfig.enabled !== false`.
|
||||
- If you disable the DB, constructor-configured services still run, but persistent management features are limited.
|
||||
- Nameserver domains are still required for DNS bootstrap and DoH route generation.
|
||||
- HTTP/3 is enabled by default for qualifying HTTPS routes unless disabled globally or per route.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
84
readme.storage.md
Normal file
84
readme.storage.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# DCRouter Storage Overview
|
||||
|
||||
DCRouter uses a **unified database layer** backed by `@push.rocks/smartdata` for all persistent data. All data is stored as typed document classes in a single database.
|
||||
|
||||
## Database Modes
|
||||
|
||||
### Embedded Mode (default)
|
||||
When no external MongoDB URL is provided, DCRouter starts an embedded `LocalSmartDb` (Rust-based MongoDB-compatible engine) via `@push.rocks/smartdb`.
|
||||
|
||||
```
|
||||
~/.serve.zone/dcrouter/tsmdb/
|
||||
```
|
||||
|
||||
### External Mode
|
||||
Connect to any MongoDB-compatible database by providing a connection URL.
|
||||
|
||||
```typescript
|
||||
dbConfig: {
|
||||
mongoDbUrl: 'mongodb://host:27017',
|
||||
dbName: 'dcrouter',
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
dbConfig: {
|
||||
enabled: true, // default: true
|
||||
mongoDbUrl: undefined, // default: embedded LocalSmartDb
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb', // default (embedded mode only)
|
||||
dbName: 'dcrouter', // default
|
||||
cleanupIntervalHours: 1, // TTL cleanup interval
|
||||
}
|
||||
```
|
||||
|
||||
## Document Classes
|
||||
|
||||
All data is stored as smartdata document classes in `ts/db/documents/`.
|
||||
|
||||
| Document Class | Collection | Unique Key | Purpose |
|
||||
|---|---|---|---|
|
||||
| `StoredRouteDoc` | storedRoutes | `id` | Programmatic routes (created via API) |
|
||||
| `RouteOverrideDoc` | routeOverrides | `routeName` | Hardcoded route enable/disable overrides |
|
||||
| `ApiTokenDoc` | apiTokens | `id` | API tokens (hashed secrets, scopes, expiry) |
|
||||
| `VpnServerKeysDoc` | vpnServerKeys | `configId` (singleton) | VPN server Noise + WireGuard keypairs |
|
||||
| `VpnClientDoc` | vpnClients | `clientId` | VPN client registrations |
|
||||
| `AcmeCertDoc` | acmeCerts | `domainName` | ACME certificates and keys |
|
||||
| `ProxyCertDoc` | proxyCerts | `domain` | SmartProxy TLS certificates |
|
||||
| `CertBackoffDoc` | certBackoff | `domain` | Per-domain cert provision backoff state |
|
||||
| `RemoteIngressEdgeDoc` | remoteIngressEdges | `id` | Edge node registrations |
|
||||
| `VlanMappingsDoc` | vlanMappings | `configId` (singleton) | MAC-to-VLAN mapping table |
|
||||
| `AccountingSessionDoc` | accountingSessions | `sessionId` | RADIUS accounting sessions |
|
||||
| `CachedEmail` | cachedEmails | `id` | Email metadata (TTL: 30 days) |
|
||||
| `CachedIPReputation` | cachedIPReputation | `ipAddress` | IP reputation results (TTL: 24 hours) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DcRouterDb (singleton)
|
||||
├── LocalSmartDb (embedded, Rust) ─── or ─── External MongoDB
|
||||
└── SmartdataDb (ORM)
|
||||
└── @Collection(() => getDb())
|
||||
├── StoredRouteDoc
|
||||
├── RouteOverrideDoc
|
||||
├── ApiTokenDoc
|
||||
├── VpnServerKeysDoc / VpnClientDoc
|
||||
├── AcmeCertDoc / ProxyCertDoc / CertBackoffDoc
|
||||
├── RemoteIngressEdgeDoc
|
||||
├── VlanMappingsDoc / AccountingSessionDoc
|
||||
├── CachedEmail (TTL)
|
||||
└── CachedIPReputation (TTL)
|
||||
```
|
||||
|
||||
### TTL Cleanup
|
||||
|
||||
`CacheCleaner` runs on a configurable interval (default: 1 hour) and removes expired documents where `expiresAt < now()`.
|
||||
|
||||
## Disabling
|
||||
|
||||
For tests or lightweight deployments without persistence:
|
||||
|
||||
```typescript
|
||||
dbConfig: { enabled: false }
|
||||
```
|
||||
443
test/readme.md
Normal file
443
test/readme.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# DCRouter SMTP Test Suite
|
||||
|
||||
```
|
||||
test/
|
||||
├── readme.md # This file
|
||||
├── helpers/
|
||||
│ ├── server.loader.ts # SMTP server lifecycle management
|
||||
│ ├── utils.ts # Common test utilities
|
||||
│ └── smtp.client.ts # Test SMTP client utilities
|
||||
└── suite/
|
||||
├── smtpserver_commands/ # SMTP command tests (CMD)
|
||||
├── smtpserver_connection/ # Connection management tests (CM)
|
||||
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
|
||||
├── smtpserver_email-processing/ # Email processing tests (EP)
|
||||
├── smtpserver_error-handling/ # Error handling tests (ERR)
|
||||
├── smtpserver_performance/ # Performance tests (PERF)
|
||||
├── smtpserver_reliability/ # Reliability tests (REL)
|
||||
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
|
||||
└── smtpserver_security/ # Security tests (SEC)
|
||||
```
|
||||
|
||||
## Test ID Convention
|
||||
|
||||
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
|
||||
|
||||
Examples:
|
||||
- `test.cmd-01.ehlo-command.ts` - EHLO command test
|
||||
- `test.cm-01.tls-connection.ts` - TLS connection test
|
||||
- `test.sec-01.authentication.ts` - Authentication test
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Connection Management (CM)
|
||||
|
||||
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
|
||||
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
|
||||
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
|
||||
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
|
||||
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
|
||||
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
|
||||
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
|
||||
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
|
||||
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
|
||||
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
|
||||
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
|
||||
|
||||
### 2. SMTP Commands (CMD)
|
||||
|
||||
Tests for validating proper SMTP protocol command implementation.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
|
||||
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
|
||||
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
|
||||
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
|
||||
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
|
||||
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
|
||||
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
|
||||
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
|
||||
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
|
||||
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
|
||||
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
|
||||
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
|
||||
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
|
||||
|
||||
### 3. Email Processing (EP)
|
||||
|
||||
Tests for validating email content handling, parsing, and delivery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
|
||||
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
|
||||
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
|
||||
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
|
||||
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
|
||||
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
|
||||
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
|
||||
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
|
||||
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
|
||||
|
||||
### 4. Security (SEC)
|
||||
|
||||
Tests for validating security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
|
||||
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
|
||||
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
|
||||
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
|
||||
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
|
||||
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
|
||||
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
|
||||
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
|
||||
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
|
||||
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
|
||||
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
|
||||
|
||||
### 5. Error Handling (ERR)
|
||||
|
||||
Tests for validating proper error handling and recovery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
|
||||
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
|
||||
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
|
||||
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
|
||||
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
|
||||
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
|
||||
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
|
||||
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
|
||||
|
||||
### 6. Performance (PERF)
|
||||
|
||||
Tests for validating performance characteristics and benchmarks.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|------------------------------------------|----------|----------------|
|
||||
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
|
||||
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
|
||||
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
|
||||
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
|
||||
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
|
||||
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
|
||||
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
|
||||
|
||||
### 7. Reliability (REL)
|
||||
|
||||
Tests for validating system reliability and stability.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
|
||||
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
|
||||
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
|
||||
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
|
||||
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
|
||||
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
|
||||
|
||||
### 8. Edge Cases (EDGE)
|
||||
|
||||
Tests for validating handling of unusual or extreme scenarios.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
|
||||
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
|
||||
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
|
||||
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
|
||||
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
|
||||
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
|
||||
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
|
||||
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
|
||||
|
||||
### 9. RFC Compliance (RFC)
|
||||
|
||||
Tests for validating compliance with SMTP-related RFCs.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
|
||||
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
|
||||
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
|
||||
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
|
||||
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
|
||||
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
|
||||
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
|
||||
|
||||
## SMTP Client Test Suite
|
||||
|
||||
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
|
||||
|
||||
### Client Test Organization
|
||||
|
||||
```
|
||||
test/
|
||||
└── suite/
|
||||
├── smtpclient_connection/ # Client connection management tests (CCM)
|
||||
├── smtpclient_commands/ # Client command execution tests (CCMD)
|
||||
├── smtpclient_email-composition/ # Email composition tests (CEP)
|
||||
├── smtpclient_security/ # Client security tests (CSEC)
|
||||
├── smtpclient_error-handling/ # Client error handling tests (CERR)
|
||||
├── smtpclient_performance/ # Client performance tests (CPERF)
|
||||
├── smtpclient_reliability/ # Client reliability tests (CREL)
|
||||
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
|
||||
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
|
||||
```
|
||||
|
||||
### 10. Client Connection Management (CCM)
|
||||
|
||||
Tests for validating how the SMTP client establishes and manages connections to servers.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
|
||||
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
|
||||
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
|
||||
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
|
||||
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
|
||||
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
|
||||
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
|
||||
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
|
||||
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
|
||||
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
|
||||
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
|
||||
|
||||
### 11. Client Command Execution (CCMD)
|
||||
|
||||
Tests for validating how the client sends SMTP commands and processes responses.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
|
||||
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
|
||||
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
|
||||
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
|
||||
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
|
||||
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
|
||||
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
|
||||
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
|
||||
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
|
||||
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
|
||||
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
|
||||
|
||||
### 12. Client Email Composition (CEP)
|
||||
|
||||
Tests for validating email composition, formatting, and encoding.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
|
||||
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
|
||||
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
|
||||
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
|
||||
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
|
||||
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
|
||||
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
|
||||
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
|
||||
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
|
||||
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
|
||||
|
||||
### 13. Client Security (CSEC)
|
||||
|
||||
Tests for client-side security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
|
||||
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
|
||||
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
|
||||
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
|
||||
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
|
||||
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
|
||||
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
|
||||
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
|
||||
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
|
||||
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
|
||||
|
||||
### 14. Client Error Handling (CERR)
|
||||
|
||||
Tests for how the client handles various error conditions.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
|
||||
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
|
||||
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
|
||||
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
|
||||
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
|
||||
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
|
||||
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
|
||||
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
|
||||
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
|
||||
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
|
||||
|
||||
### 15. Client Performance (CPERF)
|
||||
|
||||
Tests for client performance characteristics and optimization.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
|
||||
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
|
||||
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
|
||||
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
|
||||
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
|
||||
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
|
||||
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
|
||||
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
|
||||
|
||||
### 16. Client Reliability (CREL)
|
||||
|
||||
Tests for client reliability and resilience.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
|
||||
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
|
||||
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
|
||||
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
|
||||
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
|
||||
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
|
||||
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
|
||||
|
||||
### 17. Client Edge Cases (CEDGE)
|
||||
|
||||
Tests for unusual scenarios and edge cases.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
|
||||
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
|
||||
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
|
||||
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
|
||||
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
|
||||
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
|
||||
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
|
||||
|
||||
### 18. Client RFC Compliance (CRFC)
|
||||
|
||||
Tests for RFC compliance from the client perspective.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
|
||||
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
|
||||
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
|
||||
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
|
||||
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
|
||||
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
|
||||
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
|
||||
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
|
||||
|
||||
## Running SMTP Client Tests
|
||||
|
||||
### Run All Client Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test test/suite/smtpclient_*
|
||||
```
|
||||
|
||||
### Run Specific Client Test Category
|
||||
```bash
|
||||
# Run all client connection tests
|
||||
pnpm test test/suite/smtpclient_connection
|
||||
|
||||
# Run all client security tests
|
||||
pnpm test test/suite/smtpclient_security
|
||||
```
|
||||
|
||||
### Run Single Client Test File
|
||||
```bash
|
||||
# Run basic TCP connection test
|
||||
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
|
||||
|
||||
# Run AUTH mechanisms test
|
||||
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
|
||||
```
|
||||
|
||||
## Client Performance Benchmarks
|
||||
|
||||
Expected performance metrics for production-ready SMTP client:
|
||||
- **Sending Rate**: >100 emails per second (with connection pooling)
|
||||
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
|
||||
- **Memory Usage**: <500MB for 1000 concurrent email operations
|
||||
- **DNS Cache Hit Rate**: >90% for repeated domains
|
||||
- **Retry Success Rate**: >95% for temporary failures
|
||||
- **Large Attachment Support**: Files up to 25MB without performance degradation
|
||||
- **Queue Processing**: >1000 emails/minute with persistent queue
|
||||
|
||||
## Client Security Requirements
|
||||
|
||||
All client security tests must pass for production deployment:
|
||||
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
|
||||
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
|
||||
- **Certificate Validation**: Proper certificate chain validation
|
||||
- **DKIM Signing**: Automatic DKIM signature generation
|
||||
- **Credential Security**: No plaintext password storage
|
||||
- **Injection Prevention**: Protection against header/command injection
|
||||
|
||||
## Client Production Readiness Criteria
|
||||
|
||||
### Production Gate 1: Core Functionality (>95% tests passing)
|
||||
- Basic connection establishment
|
||||
- Command execution and response parsing
|
||||
- Email composition and sending
|
||||
- Error handling and recovery
|
||||
|
||||
### Production Gate 2: Advanced Features (>90% tests passing)
|
||||
- Connection pooling and reuse
|
||||
- Authentication mechanisms
|
||||
- TLS/STARTTLS support
|
||||
- Retry logic and resilience
|
||||
|
||||
### Production Gate 3: Enterprise Ready (>85% tests passing)
|
||||
- High-volume sending capabilities
|
||||
- Advanced security features
|
||||
- Full RFC compliance
|
||||
- Performance under load
|
||||
|
||||
## Key Differences: Server vs Client Tests
|
||||
|
||||
| Aspect | Server Tests | Client Tests |
|
||||
|--------|--------------|--------------|
|
||||
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
|
||||
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
|
||||
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
|
||||
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
|
||||
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
|
||||
|
||||
## Test Implementation Priority
|
||||
|
||||
1. **Critical** (implement first):
|
||||
- Basic connection and command sending
|
||||
- Authentication mechanisms
|
||||
- Error handling and retry logic
|
||||
- TLS/Security features
|
||||
|
||||
2. **High Priority** (implement second):
|
||||
- Connection pooling
|
||||
- Email composition and MIME
|
||||
- Performance optimization
|
||||
- RFC compliance
|
||||
|
||||
3. **Medium Priority** (implement third):
|
||||
- Advanced features (OAuth2, etc.)
|
||||
- Edge case handling
|
||||
- Extended performance tests
|
||||
- Additional RFC extensions
|
||||
|
||||
4. **Low Priority** (implement last):
|
||||
- Proxy support
|
||||
- Certificate pinning
|
||||
- Unusual scenarios
|
||||
- Optional RFC features
|
||||
|
||||
334
test/test.apiclient.ts
Normal file
334
test/test.apiclient.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
DcRouterApiClient,
|
||||
Route,
|
||||
RouteBuilder,
|
||||
RouteManager,
|
||||
Certificate,
|
||||
CertificateManager,
|
||||
ApiToken,
|
||||
ApiTokenBuilder,
|
||||
ApiTokenManager,
|
||||
RemoteIngress,
|
||||
RemoteIngressBuilder,
|
||||
RemoteIngressManager,
|
||||
Email,
|
||||
EmailManager,
|
||||
StatsManager,
|
||||
ConfigManager,
|
||||
LogManager,
|
||||
RadiusManager,
|
||||
RadiusClientManager,
|
||||
RadiusVlanManager,
|
||||
RadiusSessionManager,
|
||||
} from '../ts_apiclient/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// Instantiation & Structure
|
||||
// =============================================================================
|
||||
|
||||
tap.test('DcRouterApiClient - should instantiate with baseUrl', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client).toBeTruthy();
|
||||
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||
expect(client.identity).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should strip trailing slashes from baseUrl', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000///' });
|
||||
expect(client.baseUrl).toEqual('https://localhost:3000');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should accept optional apiToken', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_test_token',
|
||||
});
|
||||
expect(client.apiToken).toEqual('dcr_test_token');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - should have all resource managers', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client.routes).toBeInstanceOf(RouteManager);
|
||||
expect(client.certificates).toBeInstanceOf(CertificateManager);
|
||||
expect(client.apiTokens).toBeInstanceOf(ApiTokenManager);
|
||||
expect(client.remoteIngress).toBeInstanceOf(RemoteIngressManager);
|
||||
expect(client.stats).toBeInstanceOf(StatsManager);
|
||||
expect(client.config).toBeInstanceOf(ConfigManager);
|
||||
expect(client.logs).toBeInstanceOf(LogManager);
|
||||
expect(client.emails).toBeInstanceOf(EmailManager);
|
||||
expect(client.radius).toBeInstanceOf(RadiusManager);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// buildRequestPayload
|
||||
// =============================================================================
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload includes identity when set', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const identity = {
|
||||
jwt: 'test-jwt',
|
||||
userId: 'user1',
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
client.identity = identity;
|
||||
|
||||
const payload = client.buildRequestPayload({ extra: 'data' });
|
||||
expect(payload.identity).toEqual(identity);
|
||||
expect(payload.extra).toEqual('data');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload includes apiToken when set', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_abc123',
|
||||
});
|
||||
|
||||
const payload = client.buildRequestPayload();
|
||||
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||
});
|
||||
|
||||
tap.test('DcRouterApiClient - buildRequestPayload with both identity and apiToken', async () => {
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://localhost:3000',
|
||||
apiToken: 'dcr_abc123',
|
||||
});
|
||||
client.identity = {
|
||||
jwt: 'test-jwt',
|
||||
userId: 'user1',
|
||||
name: 'Admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
const payload = client.buildRequestPayload({ foo: 'bar' });
|
||||
expect(payload.identity).toBeTruthy();
|
||||
expect(payload.apiToken).toEqual('dcr_abc123');
|
||||
expect(payload.foo).toEqual('bar');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Route Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RouteBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.routes.build();
|
||||
expect(builder).toBeInstanceOf(RouteBuilder);
|
||||
|
||||
// Fluent methods return `this` (same reference)
|
||||
const result = builder
|
||||
.setName('test-route')
|
||||
.setMatch({ ports: 443, domains: 'example.com' })
|
||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
||||
.setEnabled(true);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ApiToken Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('ApiTokenBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.apiTokens.build();
|
||||
expect(builder).toBeInstanceOf(ApiTokenBuilder);
|
||||
|
||||
const result = builder
|
||||
.setName('ci-token')
|
||||
.setScopes(['routes:read', 'routes:write'])
|
||||
.addScope('config:read')
|
||||
.setExpiresInDays(30);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RemoteIngress Builder
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RemoteIngressBuilder - should support fluent builder pattern', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const builder = client.remoteIngress.build();
|
||||
expect(builder).toBeInstanceOf(RemoteIngressBuilder);
|
||||
|
||||
const result = builder
|
||||
.setName('edge-1')
|
||||
.setListenPorts([80, 443])
|
||||
.setAutoDerivePorts(true)
|
||||
.setTags(['production']);
|
||||
|
||||
expect(result === builder).toBeTrue();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Route resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const route = new Route(client, {
|
||||
route: {
|
||||
name: 'test-route',
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
id: 'route-123',
|
||||
enabled: true,
|
||||
origin: 'api',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.id).toEqual('route-123');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.origin).toEqual('api');
|
||||
expect(route.routeConfig.match.ports).toEqual(443);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Certificate resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Certificate - should hydrate from ICertificateInfo data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const cert = new Certificate(client, {
|
||||
domain: 'example.com',
|
||||
routeNames: ['main-route'],
|
||||
status: 'valid',
|
||||
source: 'acme',
|
||||
tlsMode: 'terminate',
|
||||
expiryDate: '2027-01-01T00:00:00Z',
|
||||
issuer: "Let's Encrypt",
|
||||
canReprovision: true,
|
||||
});
|
||||
|
||||
expect(cert.domain).toEqual('example.com');
|
||||
expect(cert.status).toEqual('valid');
|
||||
expect(cert.source).toEqual('acme');
|
||||
expect(cert.canReprovision).toEqual(true);
|
||||
expect(cert.routeNames.length).toEqual(1);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ApiToken resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('ApiToken - should hydrate from IApiTokenInfo data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const token = new ApiToken(
|
||||
client,
|
||||
{
|
||||
id: 'token-1',
|
||||
name: 'ci-token',
|
||||
scopes: ['routes:read', 'routes:write'],
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
},
|
||||
'dcr_secret_value',
|
||||
);
|
||||
|
||||
expect(token.id).toEqual('token-1');
|
||||
expect(token.name).toEqual('ci-token');
|
||||
expect(token.scopes.length).toEqual(2);
|
||||
expect(token.enabled).toEqual(true);
|
||||
expect(token.tokenValue).toEqual('dcr_secret_value');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RemoteIngress resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RemoteIngress - should hydrate from IRemoteIngress data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const edge = new RemoteIngress(client, {
|
||||
id: 'edge-1',
|
||||
name: 'test-edge',
|
||||
secret: 'secret123',
|
||||
listenPorts: [80, 443],
|
||||
enabled: true,
|
||||
autoDerivePorts: true,
|
||||
tags: ['prod'],
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
effectiveListenPorts: [80, 443, 8080],
|
||||
manualPorts: [80, 443],
|
||||
derivedPorts: [8080],
|
||||
});
|
||||
|
||||
expect(edge.id).toEqual('edge-1');
|
||||
expect(edge.name).toEqual('test-edge');
|
||||
expect(edge.listenPorts.length).toEqual(2);
|
||||
expect(edge.effectiveListenPorts!.length).toEqual(3);
|
||||
expect(edge.autoDerivePorts).toEqual(true);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Email resource class
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Email - should hydrate from IEmail data', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
const email = new Email(client, {
|
||||
id: 'email-1',
|
||||
direction: 'inbound',
|
||||
status: 'delivered',
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test email',
|
||||
timestamp: '2026-03-06T00:00:00Z',
|
||||
messageId: '<msg-1@example.com>',
|
||||
size: '1234',
|
||||
});
|
||||
|
||||
expect(email.id).toEqual('email-1');
|
||||
expect(email.direction).toEqual('inbound');
|
||||
expect(email.status).toEqual('delivered');
|
||||
expect(email.from).toEqual('sender@example.com');
|
||||
expect(email.subject).toEqual('Test email');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RadiusManager structure
|
||||
// =============================================================================
|
||||
|
||||
tap.test('RadiusManager - should have sub-managers', async () => {
|
||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
||||
expect(client.radius.clients).toBeInstanceOf(RadiusClientManager);
|
||||
expect(client.radius.vlans).toBeInstanceOf(RadiusVlanManager);
|
||||
expect(client.radius.sessions).toBeInstanceOf(RadiusSessionManager);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Exports verification
|
||||
// =============================================================================
|
||||
|
||||
tap.test('Exports - all expected classes should be importable', async () => {
|
||||
expect(DcRouterApiClient).toBeTruthy();
|
||||
expect(Route).toBeTruthy();
|
||||
expect(RouteBuilder).toBeTruthy();
|
||||
expect(RouteManager).toBeTruthy();
|
||||
expect(Certificate).toBeTruthy();
|
||||
expect(CertificateManager).toBeTruthy();
|
||||
expect(ApiToken).toBeTruthy();
|
||||
expect(ApiTokenBuilder).toBeTruthy();
|
||||
expect(ApiTokenManager).toBeTruthy();
|
||||
expect(RemoteIngress).toBeTruthy();
|
||||
expect(RemoteIngressBuilder).toBeTruthy();
|
||||
expect(RemoteIngressManager).toBeTruthy();
|
||||
expect(Email).toBeTruthy();
|
||||
expect(EmailManager).toBeTruthy();
|
||||
expect(StatsManager).toBeTruthy();
|
||||
expect(ConfigManager).toBeTruthy();
|
||||
expect(LogManager).toBeTruthy();
|
||||
expect(RadiusManager).toBeTruthy();
|
||||
expect(RadiusClientManager).toBeTruthy();
|
||||
expect(RadiusVlanManager).toBeTruthy();
|
||||
expect(RadiusSessionManager).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
|
||||
// Used by the force-renew sibling-propagation logic to identify which routes
|
||||
// share a single underlying ACME certificate.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
|
||||
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
|
||||
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('example.com')).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
|
||||
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
|
||||
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
|
||||
// This is the core property: outline.task.vc and *.task.vc must yield
|
||||
// the same cert identity, otherwise sibling propagation cannot work.
|
||||
const subdomain = deriveCertDomainName('outline.task.vc');
|
||||
const wildcard = deriveCertDomainName('*.task.vc');
|
||||
expect(subdomain).toEqual(wildcard);
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
|
||||
// Matches smartacme's "deeper domains not supported" behavior.
|
||||
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
|
||||
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
|
||||
expect(deriveCertDomainName('vc')).toBeUndefined();
|
||||
expect(deriveCertDomainName('')).toBeUndefined();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
|
||||
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
|
||||
//
|
||||
// This is the regression test for Bug 1: previously the call passed only
|
||||
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
|
||||
// and break every sibling subdomain.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
|
||||
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
|
||||
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
|
||||
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
|
||||
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
|
||||
function makeStubOpsServer(opts: {
|
||||
routes: Array<{ name: string; domains: string[] }>;
|
||||
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
|
||||
}) {
|
||||
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
|
||||
const router = {
|
||||
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
|
||||
};
|
||||
|
||||
const routes = opts.routes.map((r) => ({
|
||||
name: r.name,
|
||||
match: { domains: r.domains, ports: 443 },
|
||||
action: { type: 'forward', tls: { certificate: 'auto' } },
|
||||
}));
|
||||
|
||||
const dcRouterRef: any = {
|
||||
smartProxy: {
|
||||
routeManager: { getRoutes: () => routes },
|
||||
},
|
||||
smartAcme: opts.smartAcmeStub,
|
||||
findRouteNamesForDomain: (domain: string) =>
|
||||
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
|
||||
certificateStatusMap: new Map<string, any>(),
|
||||
certProvisionScheduler: null,
|
||||
routeConfigManager: null,
|
||||
};
|
||||
|
||||
const opsServerRef: any = {
|
||||
viewRouter: router,
|
||||
adminRouter: router,
|
||||
dcRouterRef,
|
||||
};
|
||||
|
||||
return { opsServerRef, dcRouterRef, captured };
|
||||
}
|
||||
|
||||
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [
|
||||
{ name: 'outline-route', domains: ['outline.task.vc'] },
|
||||
{ name: 'pr-route', domains: ['pr.task.vc'] },
|
||||
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
|
||||
],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
// Return a cert object shaped like SmartacmeCert
|
||||
return {
|
||||
id: 'test-id',
|
||||
domainName: 'task.vc',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
|
||||
// Invoke the private reprovision method directly. The Bug 1 fix is verified
|
||||
// by inspecting the captured smartAcme call options regardless of whether
|
||||
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
|
||||
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
|
||||
|
||||
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
|
||||
// The Bug 1 fix is verified by the captured smartAcme call regardless.
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[0].domain).toEqual('outline.task.vc');
|
||||
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
|
||||
});
|
||||
|
||||
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [
|
||||
{ name: 'wildcard-route', domains: ['*.task.vc'] },
|
||||
],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
return {
|
||||
id: 'test-id',
|
||||
domainName: 'task.vc',
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
|
||||
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(calls[0].domain).toEqual('*.task.vc');
|
||||
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
|
||||
});
|
||||
|
||||
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
|
||||
const calls: Array<{ domain: string; options: any }> = [];
|
||||
|
||||
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
|
||||
smartAcmeStub: {
|
||||
getCertificateForDomain: async (domain: string, options: any) => {
|
||||
calls.push({ domain, options });
|
||||
return {} as any;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||
|
||||
const handler = new CertificateHandler(opsServerRef);
|
||||
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
|
||||
|
||||
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
|
||||
// applyRoutes and lets the cert provisioning pipeline handle it.
|
||||
expect(calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
175
test/test.config.md
Normal file
175
test/test.config.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# DCRouter Test Configuration
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Run Specific Category
|
||||
```bash
|
||||
# Run all connection tests
|
||||
tsx test/run-category.ts connection
|
||||
|
||||
# Run all security tests
|
||||
tsx test/run-category.ts security
|
||||
|
||||
# Run all performance tests
|
||||
tsx test/run-category.ts performance
|
||||
```
|
||||
|
||||
### Run Individual Test File
|
||||
```bash
|
||||
# Run TLS connection test
|
||||
tsx test/suite/connection/test.tls-connection.ts
|
||||
|
||||
# Run authentication test
|
||||
tsx test/suite/security/test.authentication.ts
|
||||
```
|
||||
|
||||
### Run Tests with Verbose Output
|
||||
```bash
|
||||
# All tests with verbose logging
|
||||
pnpm test -- --verbose
|
||||
|
||||
# Individual test with verbose
|
||||
tsx test/suite/connection/test.tls-connection.ts --verbose
|
||||
```
|
||||
|
||||
## Test Server Configuration
|
||||
|
||||
Each test file starts its own SMTP server with specific configuration. Common configurations:
|
||||
|
||||
### Basic Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost'
|
||||
});
|
||||
```
|
||||
|
||||
### TLS-Enabled Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
tlsEnabled: true
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
authRequired: true
|
||||
});
|
||||
```
|
||||
|
||||
### High-Performance Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
maxConnections: 1000,
|
||||
size: 50 * 1024 * 1024 // 50MB
|
||||
});
|
||||
```
|
||||
|
||||
## Port Allocation
|
||||
|
||||
Tests use different ports to avoid conflicts:
|
||||
- Connection tests: 2525-2530
|
||||
- Command tests: 2531-2540
|
||||
- Email processing: 2541-2550
|
||||
- Security tests: 2551-2560
|
||||
- Performance tests: 2561-2570
|
||||
- Edge cases: 2571-2580
|
||||
- RFC compliance: 2581-2590
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Server Lifecycle
|
||||
All tests follow this pattern:
|
||||
```typescript
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
let testServer;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testServer = await startTestServer({ port: 2525 });
|
||||
});
|
||||
|
||||
// Your tests here...
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
```
|
||||
|
||||
### SMTP Client Testing
|
||||
```typescript
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
const client = createTestSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525
|
||||
});
|
||||
```
|
||||
|
||||
### Low-Level SMTP Testing
|
||||
```typescript
|
||||
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
|
||||
|
||||
const socket = await connectToSmtp('localhost', 2525);
|
||||
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
Expected minimums for production:
|
||||
- Throughput: >10 emails/second
|
||||
- Concurrent connections: >100
|
||||
- Memory increase: <2% under load
|
||||
- Connection time: <5000ms
|
||||
- Error rate: <5%
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
```bash
|
||||
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
|
||||
```
|
||||
|
||||
### Check Server Logs
|
||||
Tests output server logs to console. Look for:
|
||||
- 🚀 Server start messages
|
||||
- 📧 Email processing logs
|
||||
- ❌ Error messages
|
||||
- ✅ Success confirmations
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port Already in Use**
|
||||
- Tests use unique ports
|
||||
- Check for orphaned processes: `lsof -i :2525`
|
||||
- Kill process: `kill -9 <PID>`
|
||||
|
||||
2. **TLS Certificate Errors**
|
||||
- Tests use self-signed certificates
|
||||
- Production should use real certificates
|
||||
|
||||
3. **Timeout Errors**
|
||||
- Increase timeout in test configuration
|
||||
- Check network connectivity
|
||||
- Verify server started successfully
|
||||
|
||||
4. **Authentication Failures**
|
||||
- Test servers may not validate credentials
|
||||
- Check authRequired configuration
|
||||
- Verify AUTH mechanisms supported
|
||||
265
test/test.contentscanner.ts
Normal file
265
test/test.contentscanner.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||
import { Email } from '@push.rocks/smartmta';
|
||||
|
||||
// Test instantiation
|
||||
tap.test('ContentScanner - should be instantiable', async () => {
|
||||
const scanner = ContentScanner.getInstance({
|
||||
scanBody: true,
|
||||
scanSubject: true,
|
||||
scanAttachments: true
|
||||
});
|
||||
|
||||
expect(scanner).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test singleton pattern
|
||||
tap.test('ContentScanner - should use singleton pattern', async () => {
|
||||
const scanner1 = ContentScanner.getInstance();
|
||||
const scanner2 = ContentScanner.getInstance();
|
||||
|
||||
// Both instances should be the same object
|
||||
expect(scanner1 === scanner2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test clean email can be correctly distinguished from high-risk email
|
||||
tap.test('ContentScanner - should distinguish between clean and suspicious emails', async () => {
|
||||
// Create an instance with a higher minimum threat score
|
||||
const scanner = new ContentScanner({
|
||||
minThreatScore: 50 // Higher threshold to consider clean
|
||||
});
|
||||
|
||||
// Create a truly clean email with no potentially sensitive data patterns
|
||||
const cleanEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Project Update',
|
||||
text: 'The project is on track. Let me know if you have questions.',
|
||||
html: '<p>The project is on track. Let me know if you have questions.</p>'
|
||||
});
|
||||
|
||||
// Create a highly suspicious email
|
||||
const suspiciousEmail = new Email({
|
||||
from: 'admin@bank-fake.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Your account needs verification now!',
|
||||
text: 'Click here to verify your account or it will be suspended: https://bit.ly/12345',
|
||||
html: '<p>Click here to verify your account or it will be suspended: <a href="https://bit.ly/12345">click here</a></p>'
|
||||
});
|
||||
|
||||
// Test both emails
|
||||
const cleanResult = await scanner.scanEmail(cleanEmail);
|
||||
const suspiciousResult = await scanner.scanEmail(suspiciousEmail);
|
||||
|
||||
console.log('Clean vs Suspicious results:', {
|
||||
cleanScore: cleanResult.threatScore,
|
||||
suspiciousScore: suspiciousResult.threatScore
|
||||
});
|
||||
|
||||
// Verify the scanner can distinguish between them
|
||||
// Suspicious email should have a significantly higher score
|
||||
expect(suspiciousResult.threatScore > cleanResult.threatScore + 40).toEqual(true);
|
||||
|
||||
// Verify clean email scans all expected elements
|
||||
expect(cleanResult.scannedElements.length > 0).toEqual(true);
|
||||
});
|
||||
|
||||
// Test phishing detection in subject
|
||||
tap.test('ContentScanner - should detect phishing in subject', async () => {
|
||||
// Create a dedicated scanner for this test
|
||||
const scanner = new ContentScanner({
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: false,
|
||||
customRules: []
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'security@bank-account-verify.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Verify your bank account details immediately',
|
||||
text: 'Your account will be suspended. Please verify your details.',
|
||||
html: '<p>Your account will be suspended. Please verify your details.</p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
console.log('Phishing email scan result:', result);
|
||||
|
||||
// We only care that it detected something suspicious
|
||||
expect(result.threatScore >= 20).toEqual(true);
|
||||
|
||||
// Check if any threat was detected (specific type may vary)
|
||||
expect(result.threatType).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test malware indicators in body
|
||||
tap.test('ContentScanner - should detect malware indicators in body', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'invoice@company.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Your invoice',
|
||||
text: 'Please see the attached invoice. You need to enable macros to view this document properly.',
|
||||
html: '<p>Please see the attached invoice. You need to enable macros to view this document properly.</p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType === ThreatCategory.MALWARE || result.threatType).toBeTruthy();
|
||||
expect(result.threatScore >= 30).toEqual(true);
|
||||
});
|
||||
|
||||
// Test suspicious link detection
|
||||
tap.test('ContentScanner - should detect suspicious links', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'newsletter@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Weekly Newsletter',
|
||||
text: 'Check our latest offer at https://bit.ly/2x3F5 and https://t.co/abc123',
|
||||
html: '<p>Check our latest offer at <a href="https://bit.ly/2x3F5">here</a> and <a href="https://t.co/abc123">here</a></p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.SUSPICIOUS_LINK);
|
||||
expect(result.threatScore >= 30).toEqual(true);
|
||||
});
|
||||
|
||||
// Test script injection detection
|
||||
tap.test('ContentScanner - should detect script injection', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'newsletter@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Newsletter',
|
||||
text: 'Check our website',
|
||||
html: '<p>Check our website</p><script>document.cookie="session="+localStorage.getItem("token");</script>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.XSS);
|
||||
expect(result.threatScore >= 40).toEqual(true);
|
||||
});
|
||||
|
||||
// Test executable attachment detection
|
||||
tap.test('ContentScanner - should detect executable attachments', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Software Update',
|
||||
text: 'Please install the attached software update.',
|
||||
attachments: [{
|
||||
filename: 'update.exe',
|
||||
content: Buffer.from('MZ...fake executable content...'),
|
||||
contentType: 'application/octet-stream'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.EXECUTABLE);
|
||||
expect(result.threatScore >= 70).toEqual(true);
|
||||
});
|
||||
|
||||
// Test macro document detection
|
||||
tap.test('ContentScanner - should detect macro documents', async () => {
|
||||
// Create a mock Office document with macro indicators
|
||||
const fakeDocContent = Buffer.from('Document content...vbaProject.bin...Auto_Open...DocumentOpen...Microsoft VBA...');
|
||||
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Financial Report',
|
||||
text: 'Please review the attached financial report.',
|
||||
attachments: [{
|
||||
filename: 'report.docm',
|
||||
content: fakeDocContent,
|
||||
contentType: 'application/vnd.ms-word.document.macroEnabled.12'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.MALICIOUS_MACRO);
|
||||
expect(result.threatScore >= 60).toEqual(true);
|
||||
});
|
||||
|
||||
// Test compound threat detection (multiple indicators)
|
||||
tap.test('ContentScanner - should detect compound threats', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'security@bank-verify.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Verify your account details immediately',
|
||||
text: 'Your account will be suspended unless you verify your details at https://bit.ly/2x3F5',
|
||||
html: '<p>Your account will be suspended unless you verify your details <a href="https://bit.ly/2x3F5">here</a>.</p>',
|
||||
attachments: [{
|
||||
filename: 'verification.exe',
|
||||
content: Buffer.from('MZ...fake executable content...'),
|
||||
contentType: 'application/octet-stream'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatScore > 70).toEqual(true); // Should have a high score due to multiple threats
|
||||
});
|
||||
|
||||
// Test custom rules
|
||||
tap.test('ContentScanner - should apply custom rules', async () => {
|
||||
// Create a scanner with custom rules
|
||||
const scanner = new ContentScanner({
|
||||
customRules: [
|
||||
{
|
||||
pattern: /CUSTOM_PATTERN_FOR_TESTING/,
|
||||
type: ThreatCategory.CUSTOM_RULE,
|
||||
score: 50,
|
||||
description: 'Custom pattern detected'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Custom Rule',
|
||||
text: 'This message contains CUSTOM_PATTERN_FOR_TESTING that should be detected.'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.CUSTOM_RULE);
|
||||
expect(result.threatScore >= 50).toEqual(true);
|
||||
});
|
||||
|
||||
// Test threat level classification
|
||||
tap.test('ContentScanner - should classify threat levels correctly', async () => {
|
||||
expect(ContentScanner.getThreatLevel(10)).toEqual('none');
|
||||
expect(ContentScanner.getThreatLevel(25)).toEqual('low');
|
||||
expect(ContentScanner.getThreatLevel(50)).toEqual('medium');
|
||||
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
163
test/test.dcrouter.email.ts
Normal file
163
test/test.dcrouter.email.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping: Record<number, number> = {
|
||||
25: 11025, // Custom SMTP port mapping
|
||||
587: 11587, // Custom submission port mapping
|
||||
465: 11465, // Custom SMTPS port mapping
|
||||
2525: 12525 // Additional custom port
|
||||
};
|
||||
|
||||
// Create a custom email configuration using smartmta interfaces
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465, 2525],
|
||||
hostname: 'mail.example.com',
|
||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
{
|
||||
domain: 'example.org',
|
||||
dnsMode: 'external-dns',
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'forward-example-com',
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'mail1.example.com',
|
||||
port: 25,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deliver-example-org',
|
||||
match: {
|
||||
recipients: '*@example.org',
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
process: {
|
||||
dkim: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create DcRouter options with custom email port configuration
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: customPortMapping,
|
||||
portSettings: {
|
||||
2525: {
|
||||
terminateTls: false,
|
||||
routeName: 'custom-smtp-route'
|
||||
}
|
||||
},
|
||||
},
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Verify the options are correctly set
|
||||
expect(router.options.emailPortConfig).toBeTruthy();
|
||||
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
|
||||
|
||||
// Test the generateEmailRoutes method
|
||||
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
|
||||
// Verify that all ports are configured
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the custom port configuration
|
||||
const customPortRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
||||
});
|
||||
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
|
||||
|
||||
const submissionRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
||||
});
|
||||
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: []
|
||||
};
|
||||
|
||||
// Create DcRouter options
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
opsServerPort: 3104,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Start the router to initialize email services
|
||||
await router.start();
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.emailServer).toBeTruthy();
|
||||
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
|
||||
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
|
||||
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
262
test/test.dns-runtime-routes.node.ts
Normal file
262
test/test.dns-runtime-routes.node.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { RouteConfigManager } from '../ts/config/index.js';
|
||||
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||
import { logger } from '../ts/logger.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const route of await RouteDoc.findAll()) {
|
||||
await route.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
|
||||
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||
|
||||
const persistedRoutes = await RouteDoc.findAll();
|
||||
expect(persistedRoutes.length).toEqual(2);
|
||||
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||
|
||||
const mergedRoutes = routeManager.getMergedRoutes().routes;
|
||||
expect(mergedRoutes.length).toEqual(2);
|
||||
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
|
||||
for (const routeSet of appliedRoutes) {
|
||||
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
|
||||
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
|
||||
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
|
||||
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
const staleDnsQueryRoute = new RouteDoc();
|
||||
staleDnsQueryRoute.id = 'stale-doh-query';
|
||||
staleDnsQueryRoute.route = {
|
||||
name: 'dns-over-https-dns-query',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['ns1.example.com'],
|
||||
path: '/dns-query',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any,
|
||||
};
|
||||
staleDnsQueryRoute.enabled = true;
|
||||
staleDnsQueryRoute.createdAt = Date.now();
|
||||
staleDnsQueryRoute.updatedAt = Date.now();
|
||||
staleDnsQueryRoute.createdBy = 'test';
|
||||
staleDnsQueryRoute.origin = 'dns';
|
||||
await staleDnsQueryRoute.save();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||
);
|
||||
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||
|
||||
const remainingRoutes = await RouteDoc.findAll();
|
||||
expect(remainingRoutes.length).toEqual(2);
|
||||
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
|
||||
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
|
||||
|
||||
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
|
||||
expect(queryRoute?.id).toEqual('stale-doh-query');
|
||||
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||
|
||||
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
|
||||
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(2);
|
||||
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager only allows toggling system routes', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const smartProxy = {
|
||||
updateRoutes: async (_routes: any[]) => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||
await routeManager.initialize([
|
||||
{
|
||||
name: 'system-config-route',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['app.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
tls: { mode: 'terminate' as const },
|
||||
},
|
||||
} as any,
|
||||
], [], []);
|
||||
|
||||
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
|
||||
expect(systemRoute).toBeDefined();
|
||||
|
||||
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
|
||||
route: { name: 'renamed-system-route' } as any,
|
||||
});
|
||||
expect(updateResult.success).toEqual(false);
|
||||
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
|
||||
|
||||
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
|
||||
expect(deleteResult.success).toEqual(false);
|
||||
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
|
||||
|
||||
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
|
||||
expect(toggleResult.success).toEqual(true);
|
||||
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const originalLog = logger.log.bind(logger);
|
||||
const warningMessages: string[] = [];
|
||||
|
||||
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||
if (level === 'warn') {
|
||||
warningMessages.push(message);
|
||||
}
|
||||
return originalLog(level, message, context || {});
|
||||
};
|
||||
|
||||
try {
|
||||
const existingDomain = new DomainDoc();
|
||||
existingDomain.id = 'existing-domain';
|
||||
existingDomain.name = 'example.com';
|
||||
existingDomain.source = 'dcrouter';
|
||||
existingDomain.authoritative = true;
|
||||
existingDomain.createdAt = Date.now();
|
||||
existingDomain.updatedAt = Date.now();
|
||||
existingDomain.createdBy = 'test';
|
||||
await existingDomain.save();
|
||||
|
||||
const dnsManager = new DnsManager({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||
smartProxyConfig: { routes: [] },
|
||||
});
|
||||
|
||||
await dnsManager.start();
|
||||
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||
),
|
||||
).toEqual(true);
|
||||
expect(
|
||||
warningMessages.some((message) =>
|
||||
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||
),
|
||||
).toEqual(false);
|
||||
} finally {
|
||||
(logger as any).log = originalLog;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
await clearTestState();
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
140
test/test.dns-server-config.ts
Normal file
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Test DNS server configuration and record registration
|
||||
*/
|
||||
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Test DNS configuration
|
||||
const testDnsConfig = {
|
||||
udpPort: 5353, // Use non-privileged port for testing
|
||||
httpsPort: 8443,
|
||||
httpsKey: './test/fixtures/test-key.pem',
|
||||
httpsCert: './test/fixtures/test-cert.pem',
|
||||
dnssecZone: 'test.example.com',
|
||||
records: [
|
||||
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||
]
|
||||
};
|
||||
|
||||
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||
|
||||
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||
expect(records).toBeArray();
|
||||
expect(records.length).toEqual(6);
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||
const parseDnsRecordData = (type: string, value: string): any => {
|
||||
switch (type) {
|
||||
case 'A':
|
||||
return value;
|
||||
case 'MX':
|
||||
const [priority, exchange] = value.split(' ');
|
||||
return { priority: parseInt(priority), exchange };
|
||||
case 'TXT':
|
||||
return value;
|
||||
case 'NS':
|
||||
return value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Test A record parsing
|
||||
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||
expect(aRecord).toEqual('192.168.1.1');
|
||||
|
||||
// Test MX record parsing
|
||||
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||
expect(mxRecord).toHaveProperty('priority', 10);
|
||||
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||
|
||||
// Test TXT record parsing
|
||||
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Check grouping
|
||||
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||
|
||||
// Verify each group has records
|
||||
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||
expect(domainRecords.length).toBeGreaterThan(0);
|
||||
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||
|
||||
expect(recordTypes).toContain('A');
|
||||
expect(recordTypes).toContain('MX');
|
||||
expect(recordTypes).toContain('TXT');
|
||||
expect(recordTypes).toContain('NS');
|
||||
|
||||
console.log('Unique record types:', recordTypes.join(', '));
|
||||
});
|
||||
|
||||
tap.test('DNS server - mock handler registration', async () => {
|
||||
// Mock DNS server for testing
|
||||
const mockDnsServer = {
|
||||
handlers: new Map<string, any>(),
|
||||
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||
this.handlers.set(pattern, { types, handler });
|
||||
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate record registration
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||
const matchingRecord = domainRecords.find(
|
||||
r => r.name === question.name && r.type === question.type
|
||||
);
|
||||
return matchingRecord || null;
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.start({
|
||||
throwOnError: true
|
||||
});
|
||||
149
test/test.dns-socket-handler.ts
Normal file
149
test/test.dns-socket-handler.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
opsServerPort: 3100,
|
||||
dbConfig: { enabled: false }
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that DNS server is not created
|
||||
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should generate DNS routes when dnsNsDomains is set', async () => {
|
||||
// This test checks the route generation logic WITHOUT starting the full DcRouter
|
||||
// Starting DcRouter would require DNS port 53 and cause conflicts
|
||||
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Check routes are generated correctly (without starting)
|
||||
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
|
||||
|
||||
// Check that routes have socket-handler action
|
||||
generatedRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify routes target the primary nameserver
|
||||
const dnsQueryRoute = generatedRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.test.local');
|
||||
});
|
||||
|
||||
tap.test('should create DNS routes with correct configuration', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Access the private method to generate routes
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Check first route (dns-query) - uses primary nameserver (first in array)
|
||||
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.ports).toContain(443);
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||
|
||||
// Check second route (resolve)
|
||||
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(resolveRoute.match.ports).toContain(443);
|
||||
expect(resolveRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||
});
|
||||
|
||||
tap.test('DNS socket handler should be created correctly', async () => {
|
||||
// This test verifies the socket handler creation WITHOUT starting the full router
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Get the socket handler (this doesn't require DNS server to be started)
|
||||
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
|
||||
// Create a mock socket to test the handler behavior without DNS server
|
||||
const mockSocket = new plugins.net.Socket();
|
||||
let socketEnded = false;
|
||||
|
||||
mockSocket.end = () => {
|
||||
socketEnded = true;
|
||||
return mockSocket;
|
||||
};
|
||||
|
||||
// When DNS server is not initialized, the handler should end the socket
|
||||
try {
|
||||
await socketHandler(mockSocket);
|
||||
} catch (error) {
|
||||
// Expected - DNS server not initialized
|
||||
}
|
||||
|
||||
// Socket should be ended because DNS server wasn't started
|
||||
expect(socketEnded).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DNS routes should only be generated when dnsNsDomains is configured', async () => {
|
||||
// Test without DNS configuration - should return empty routes
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithoutDns = (dcRouter as any).generateDnsRoutes();
|
||||
expect(routesWithoutDns.length).toEqual(0);
|
||||
|
||||
// Test with DNS configuration - should return routes
|
||||
const dcRouterWithDns = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithDns = (dcRouterWithDns as any).generateDnsRoutes();
|
||||
expect(routesWithDns.length).toEqual(2);
|
||||
|
||||
// Verify socket handler can be created
|
||||
const socketHandler = (dcRouterWithDns as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
65
test/test.email-dns-records.node.ts
Normal file
65
test/test.email-dns-records.node.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { buildEmailDnsRecords } from '../ts/email/index.js';
|
||||
|
||||
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
|
||||
const records = buildEmailDnsRecords({
|
||||
domain: 'example.com',
|
||||
hostname: 'mail.example.com',
|
||||
selector: 'selector1',
|
||||
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||
statuses: {
|
||||
mx: 'valid',
|
||||
spf: 'missing',
|
||||
dkim: 'valid',
|
||||
dmarc: 'unchecked',
|
||||
},
|
||||
});
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
type: 'MX',
|
||||
name: 'example.com',
|
||||
value: '10 mail.example.com',
|
||||
status: 'valid',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: 'example.com',
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: 'missing',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: 'selector1._domainkey.example.com',
|
||||
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||
status: 'valid',
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: '_dmarc.example.com',
|
||||
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
|
||||
status: 'unchecked',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
|
||||
const records = buildEmailDnsRecords({
|
||||
domain: 'example.net',
|
||||
hostname: 'smtp.example.net',
|
||||
mxPriority: 20,
|
||||
});
|
||||
|
||||
expect(records.map((record) => record.name)).toEqual([
|
||||
'example.net',
|
||||
'example.net',
|
||||
'_dmarc.example.net',
|
||||
]);
|
||||
expect(records[0].value).toEqual('20 smtp.example.net');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
193
test/test.email-domain-manager.node.ts
Normal file
193
test/test.email-domain-manager.node.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { EmailDomainManager } from '../ts/email/index.js';
|
||||
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
|
||||
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearTestState = async () => {
|
||||
for (const emailDomain of await EmailDomainDoc.findAll()) {
|
||||
await emailDomain.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
|
||||
const doc = new DomainDoc();
|
||||
doc.id = id;
|
||||
doc.name = name;
|
||||
doc.source = source;
|
||||
doc.authoritative = source === 'dcrouter';
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
doc.createdBy = 'test';
|
||||
await doc.save();
|
||||
return doc;
|
||||
};
|
||||
|
||||
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [
|
||||
{
|
||||
domain: 'static.example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
|
||||
const updateCalls: Array<{ domains?: any[] }> = [];
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
emailServer: {
|
||||
updateOptions: (options: { domains?: any[] }) => {
|
||||
updateCalls.push(options);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const created = await manager.createEmailDomain({
|
||||
linkedDomainId: linkedDomain.id,
|
||||
subdomain: 'mail',
|
||||
dkimSelector: 'selector1',
|
||||
rotateKeys: true,
|
||||
rotationIntervalDays: 30,
|
||||
});
|
||||
|
||||
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
|
||||
expect(domainsAfterCreate.length).toEqual(2);
|
||||
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
|
||||
|
||||
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(managedDomain).toBeTruthy();
|
||||
expect(managedDomain?.dnsMode).toEqual('external-dns');
|
||||
expect(managedDomain?.dkim?.selector).toEqual('selector1');
|
||||
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
|
||||
|
||||
await manager.updateEmailDomain(created.id, {
|
||||
rotateKeys: false,
|
||||
rateLimits: {
|
||||
outbound: {
|
||||
messagesPerMinute: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
|
||||
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
|
||||
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
|
||||
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
|
||||
|
||||
await manager.deleteEmailDomain(created.id);
|
||||
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
|
||||
} catch (err: unknown) {
|
||||
error = err as Error;
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Email domain already configured for static.example.com');
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
|
||||
const stored = new EmailDomainDoc();
|
||||
stored.id = 'managed-email-domain';
|
||||
stored.domain = 'mail.managed.example.com';
|
||||
stored.linkedDomainId = linkedDomain.id;
|
||||
stored.subdomain = 'mail';
|
||||
stored.dkim = {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationIntervalDays: 90,
|
||||
};
|
||||
stored.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
stored.createdAt = new Date().toISOString();
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
await stored.save();
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
|
||||
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
|
||||
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearTestState();
|
||||
await testDb.cleanup();
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
167
test/test.email-ops-api.ts
Normal file
167
test/test.email-ops-api.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3201;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
let removedQueueItemId: string | undefined;
|
||||
let lastEnqueueArgs: any[] | undefined;
|
||||
|
||||
const queueItems = [
|
||||
{
|
||||
id: 'failed-email-1',
|
||||
status: 'failed',
|
||||
attempts: 3,
|
||||
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||
lastError: '550 mailbox unavailable',
|
||||
processingMode: 'mta',
|
||||
route: undefined,
|
||||
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||
processingResult: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
cc: ['copy@example.net'],
|
||||
subject: 'Older message',
|
||||
text: 'hello',
|
||||
headers: { 'x-test': '1' },
|
||||
getMessageId: () => 'message-older',
|
||||
getAttachmentsSize: () => 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delivered-email-1',
|
||||
status: 'delivered',
|
||||
attempts: 1,
|
||||
processingMode: 'mta',
|
||||
route: undefined,
|
||||
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||
processingResult: {
|
||||
email: {
|
||||
from: 'fresh@example.com',
|
||||
to: ['new@example.net'],
|
||||
cc: [],
|
||||
subject: 'Newest message',
|
||||
},
|
||||
html: '<p>newest</p>',
|
||||
text: 'newest',
|
||||
headers: { 'x-fresh': 'true' },
|
||||
getMessageId: () => 'message-newer',
|
||||
getAttachmentsSize: () => 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
testDcRouter.emailServer = {
|
||||
getQueueItems: () => [...queueItems],
|
||||
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||
getQueueStats: () => ({
|
||||
queueSize: 2,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
failed: 1,
|
||||
deferred: 1,
|
||||
delivered: 1,
|
||||
},
|
||||
}),
|
||||
deliveryQueue: {
|
||||
enqueue: async (...args: any[]) => {
|
||||
lastEnqueueArgs = args;
|
||||
return 'resent-queue-id';
|
||||
},
|
||||
removeItem: async (id: string) => {
|
||||
removedQueueItemId = id;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin for email API tests', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
BASE_URL,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
adminIdentity = response.identity;
|
||||
expect(adminIdentity.jwt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return queued emails through the email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
|
||||
expect(response.emails[0].status).toEqual('delivered');
|
||||
expect(response.emails[1].status).toEqual('bounced');
|
||||
});
|
||||
|
||||
tap.test('should return email detail through the email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
emailId: 'failed-email-1',
|
||||
});
|
||||
|
||||
expect(response.email?.toList).toEqual(['recipient@example.net']);
|
||||
expect(response.email?.cc).toEqual(['copy@example.net']);
|
||||
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||
expect(response.email?.headers).toEqual({ 'x-test': '1' });
|
||||
});
|
||||
|
||||
tap.test('should expose queue status through the stats API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.queues.length).toEqual(1);
|
||||
expect(response.queues[0].size).toEqual(0);
|
||||
expect(response.queues[0].processing).toEqual(1);
|
||||
expect(response.queues[0].failed).toEqual(1);
|
||||
expect(response.queues[0].retrying).toEqual(1);
|
||||
expect(response.totalItems).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('should resend failed email through the admin email ops API', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
|
||||
const response = await request.fire({
|
||||
identity: adminIdentity,
|
||||
emailId: 'failed-email-1',
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.newQueueId).toEqual('resent-queue-id');
|
||||
expect(removedQueueItemId).toEqual('failed-email-1');
|
||||
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter after email API tests', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
107
test/test.email-ops-handlers.node.ts
Normal file
107
test/test.email-ops-handlers.node.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
|
||||
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
|
||||
|
||||
const createRouterStub = () => ({
|
||||
addTypedHandler: (_handler: unknown) => {},
|
||||
});
|
||||
|
||||
const queueItems = [
|
||||
{
|
||||
id: 'older-failed',
|
||||
status: 'failed',
|
||||
attempts: 3,
|
||||
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||
lastError: '550 mailbox unavailable',
|
||||
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||
processingResult: {
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.net'],
|
||||
cc: ['copy@example.net'],
|
||||
subject: 'Older message',
|
||||
text: 'hello',
|
||||
headers: { 'x-test': '1' },
|
||||
getMessageId: () => 'message-older',
|
||||
getAttachmentsSize: () => 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'newer-delivered',
|
||||
status: 'delivered',
|
||||
attempts: 1,
|
||||
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||
processingResult: {
|
||||
email: {
|
||||
from: 'fresh@example.com',
|
||||
to: ['new@example.net'],
|
||||
cc: [],
|
||||
subject: 'Newest message',
|
||||
},
|
||||
html: '<p>newest</p>',
|
||||
text: 'newest',
|
||||
headers: { 'x-fresh': 'true' },
|
||||
getMessageId: () => 'message-newer',
|
||||
getAttachmentsSize: () => 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
|
||||
const opsHandler = new EmailOpsHandler({
|
||||
viewRouter: createRouterStub(),
|
||||
adminRouter: createRouterStub(),
|
||||
dcRouterRef: {
|
||||
emailServer: {
|
||||
getQueueItems: () => queueItems,
|
||||
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const emails = (opsHandler as any).getAllQueueEmails();
|
||||
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||
expect(emails[0].status).toEqual('delivered');
|
||||
expect(emails[1].status).toEqual('bounced');
|
||||
expect(emails[0].messageId).toEqual('message-newer');
|
||||
|
||||
const detail = (opsHandler as any).getEmailDetail('older-failed');
|
||||
expect(detail?.toList).toEqual(['recipient@example.net']);
|
||||
expect(detail?.cc).toEqual(['copy@example.net']);
|
||||
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||
expect(detail?.headers).toEqual({ 'x-test': '1' });
|
||||
});
|
||||
|
||||
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
|
||||
const statsHandler = new StatsHandler({
|
||||
viewRouter: createRouterStub(),
|
||||
dcRouterRef: {
|
||||
emailServer: {
|
||||
getQueueStats: () => ({
|
||||
queueSize: 2,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
failed: 1,
|
||||
deferred: 1,
|
||||
delivered: 1,
|
||||
},
|
||||
}),
|
||||
getQueueItems: () => queueItems,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const queueStatus = await (statsHandler as any).getQueueStatus();
|
||||
expect(queueStatus.pending).toEqual(0);
|
||||
expect(queueStatus.active).toEqual(1);
|
||||
expect(queueStatus.failed).toEqual(1);
|
||||
expect(queueStatus.retrying).toEqual(1);
|
||||
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
274
test/test.errors.ts
Normal file
274
test/test.errors.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as errors from '../ts/errors/index.js';
|
||||
import {
|
||||
PlatformError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ResourceError,
|
||||
OperationError
|
||||
} from '../ts/errors/base.errors.js';
|
||||
import {
|
||||
ErrorSeverity,
|
||||
ErrorCategory,
|
||||
ErrorRecoverability
|
||||
} from '../ts/errors/error.codes.js';
|
||||
import {
|
||||
ErrorHandler
|
||||
} from '../ts/errors/error-handler.js';
|
||||
|
||||
// Test base error classes
|
||||
tap.test('Base error classes should set properties correctly', async () => {
|
||||
const message = 'Test error message';
|
||||
const code = 'TEST_ERROR_CODE';
|
||||
const context = {
|
||||
component: 'TestComponent',
|
||||
operation: 'testOperation',
|
||||
data: { foo: 'bar' }
|
||||
};
|
||||
|
||||
// Test PlatformError
|
||||
const platformError = new PlatformError(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
|
||||
expect(platformError.message).toEqual(message);
|
||||
expect(platformError.code).toEqual(code);
|
||||
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
expect(platformError.context?.component).toEqual(context.component);
|
||||
expect(platformError.context?.operation).toEqual(context.operation);
|
||||
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||
expect(platformError.name).toEqual('PlatformError');
|
||||
|
||||
// Test ValidationError
|
||||
const validationError = new ValidationError(message, code, context);
|
||||
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||
|
||||
// Test NetworkError
|
||||
const networkError = new NetworkError(message, code, context);
|
||||
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
|
||||
// Test ResourceError
|
||||
const resourceError = new ResourceError(message, code, context);
|
||||
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||
});
|
||||
|
||||
// Test error handler utility
|
||||
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
// Configure error handler
|
||||
ErrorHandler.configure({
|
||||
logErrors: false, // Disable for testing
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 100,
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Test converting regular Error to PlatformError
|
||||
const regularError = new Error('Something went wrong');
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
regularError,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
{ component: 'TestHandler' }
|
||||
);
|
||||
|
||||
expect(platformError).toBeInstanceOf(PlatformError);
|
||||
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(platformError.context?.component).toEqual('TestHandler');
|
||||
|
||||
// Test formatting error for API response
|
||||
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||
|
||||
// Test executing a function with error handling
|
||||
let executed = false;
|
||||
try {
|
||||
await ErrorHandler.execute(async () => {
|
||||
executed = true;
|
||||
throw new Error('Execution failed');
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
expect(executed).toEqual(true);
|
||||
|
||||
// Test executeWithRetry successful after retries
|
||||
let attempts = 0;
|
||||
const result = await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10, // Use small delay for tests
|
||||
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||
onRetry: (error, attempt, delay) => {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempt).toBeGreaterThan(0);
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test executeWithRetry that fails after max attempts
|
||||
attempts = 0;
|
||||
try {
|
||||
await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Persistent failure');
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempts).toEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
// Test retry utilities
|
||||
tap.test('Error retry utilities should work correctly', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary error');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelay: 20,
|
||||
backoffFactor: 1.5,
|
||||
retryableErrors: [/Temporary/]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
}
|
||||
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test retry with non-retryable error
|
||||
attempts = 0;
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Critical error');
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function that will reject first n times, then resolve
|
||||
interface FlakyFunction {
|
||||
(failTimes: number, result?: any): Promise<any>;
|
||||
counter: number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const flaky: FlakyFunction = Object.assign(
|
||||
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||
if (flaky.counter < failTimes) {
|
||||
flaky.counter++;
|
||||
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
counter: 0,
|
||||
reset: () => { flaky.counter = 0; }
|
||||
}
|
||||
);
|
||||
|
||||
// Test error wrapping and retry combination
|
||||
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||
// Reset counter for the test
|
||||
flaky.reset();
|
||||
|
||||
// Create a wrapped version of the flaky function
|
||||
const wrapped = errors.withErrorHandling(
|
||||
() => flaky(2, 'wrapped success'),
|
||||
'TEST_WRAPPED_ERROR',
|
||||
{ component: 'TestComponent' }
|
||||
);
|
||||
|
||||
// Execute with retry
|
||||
const result = await errors.retry(
|
||||
wrapped,
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/]
|
||||
}
|
||||
);
|
||||
expect(result).toEqual('wrapped success');
|
||||
expect(flaky.counter).toEqual(2);
|
||||
|
||||
// Reset and test failure case
|
||||
flaky.reset();
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
() => flaky(5, 'never reached'),
|
||||
{
|
||||
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||
}
|
||||
);
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
304
test/test.http3-augmentation.ts
Normal file
304
test/test.http3-augmentation.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
routeQualifiesForHttp3,
|
||||
augmentRouteWithHttp3,
|
||||
augmentRoutesWithHttp3,
|
||||
type IHttp3Config,
|
||||
} from '../ts/http3/index.js';
|
||||
import type * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Helper to create a basic HTTPS forward route on port 443
|
||||
function makeRoute(
|
||||
overrides: Partial<plugins.smartproxy.IRouteConfig> = {},
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
return {
|
||||
match: { ports: 443, ...overrides.match },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
...overrides.action,
|
||||
},
|
||||
name: overrides.name ?? 'test-https-route',
|
||||
...Object.fromEntries(
|
||||
Object.entries(overrides).filter(([k]) => !['match', 'action', 'name'].includes(k)),
|
||||
),
|
||||
} as plugins.smartproxy.IRouteConfig;
|
||||
}
|
||||
|
||||
const defaultConfig: IHttp3Config = { enabled: true };
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Qualification tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('should augment qualifying HTTPS route on port 443', async () => {
|
||||
const route = makeRoute();
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp).toBeTruthy();
|
||||
expect(result.action.udp!.quic).toBeTruthy();
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||
});
|
||||
|
||||
tap.test('should NOT augment route on non-443 port', async () => {
|
||||
const route = makeRoute({ match: { ports: 8080 } });
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
expect(result.action.udp).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should NOT augment socket-handler type route', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
socketHandler: (() => {}) as any,
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should NOT augment route without TLS', async () => {
|
||||
const route: plugins.smartproxy.IRouteConfig = {
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
},
|
||||
name: 'no-tls-route',
|
||||
};
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should NOT augment email routes', async () => {
|
||||
const emailNames = ['smtp-route', 'submission-route', 'smtps-route', 'email-port-2525-route'];
|
||||
for (const name of emailNames) {
|
||||
const route = makeRoute({ name });
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should respect per-route opt-out (options.http3 = false)', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
options: { http3: false },
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
expect(result.action.udp).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should respect per-route opt-in when global is disabled', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
options: { http3: true },
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should NOT double-augment routes with transport: all', async () => {
|
||||
const route = makeRoute({
|
||||
match: { ports: 443, transport: 'all' as any },
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
// Should be the exact same object (no augmentation)
|
||||
expect(result).toEqual(route);
|
||||
});
|
||||
|
||||
tap.test('should NOT double-augment routes with existing udp.quic', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
udp: { quic: { enableHttp3: true } },
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result).toEqual(route);
|
||||
});
|
||||
|
||||
tap.test('should augment route with port range including 443', async () => {
|
||||
const route = makeRoute({
|
||||
match: { ports: [{ from: 400, to: 500 }] },
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should augment route with port array including 443', async () => {
|
||||
const route = makeRoute({
|
||||
match: { ports: [80, 443] },
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should NOT augment route with port range NOT including 443', async () => {
|
||||
const route = makeRoute({
|
||||
match: { ports: [{ from: 8000, to: 9000 }] },
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should augment TLS passthrough routes', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should augment terminate-and-reencrypt routes', async () => {
|
||||
const route = makeRoute({
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 8080 }],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
|
||||
},
|
||||
});
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Configuration tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('should apply default QUIC settings when none provided', async () => {
|
||||
const route = makeRoute();
|
||||
const result = augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(86400);
|
||||
// Undefined means SmartProxy will use its own defaults
|
||||
expect(result.action.udp!.quic!.maxIdleTimeout).toBeUndefined();
|
||||
expect(result.action.udp!.quic!.altSvcPort).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should apply custom QUIC settings', async () => {
|
||||
const route = makeRoute();
|
||||
const config: IHttp3Config = {
|
||||
enabled: true,
|
||||
quicSettings: {
|
||||
maxIdleTimeout: 60000,
|
||||
maxConcurrentBidiStreams: 200,
|
||||
maxConcurrentUniStreams: 50,
|
||||
initialCongestionWindow: 65536,
|
||||
},
|
||||
altSvc: {
|
||||
port: 8443,
|
||||
maxAge: 3600,
|
||||
},
|
||||
udpSettings: {
|
||||
sessionTimeout: 120000,
|
||||
maxSessionsPerIP: 500,
|
||||
maxDatagramSize: 32768,
|
||||
},
|
||||
};
|
||||
const result = augmentRouteWithHttp3(route, config);
|
||||
|
||||
expect(result.action.udp!.quic!.maxIdleTimeout).toEqual(60000);
|
||||
expect(result.action.udp!.quic!.maxConcurrentBidiStreams).toEqual(200);
|
||||
expect(result.action.udp!.quic!.maxConcurrentUniStreams).toEqual(50);
|
||||
expect(result.action.udp!.quic!.initialCongestionWindow).toEqual(65536);
|
||||
expect(result.action.udp!.quic!.altSvcPort).toEqual(8443);
|
||||
expect(result.action.udp!.quic!.altSvcMaxAge).toEqual(3600);
|
||||
expect(result.action.udp!.sessionTimeout).toEqual(120000);
|
||||
expect(result.action.udp!.maxSessionsPerIP).toEqual(500);
|
||||
expect(result.action.udp!.maxDatagramSize).toEqual(32768);
|
||||
});
|
||||
|
||||
tap.test('should not mutate the original route', async () => {
|
||||
const route = makeRoute();
|
||||
const originalTransport = route.match.transport;
|
||||
const originalUdp = route.action.udp;
|
||||
|
||||
augmentRouteWithHttp3(route, defaultConfig);
|
||||
|
||||
expect(route.match.transport).toEqual(originalTransport);
|
||||
expect(route.action.udp).toEqual(originalUdp);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Batch augmentation
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('should augment multiple routes in a batch', async () => {
|
||||
const routes = [
|
||||
makeRoute({ name: 'web-app' }),
|
||||
makeRoute({ name: 'smtp-route', match: { ports: 25 } }),
|
||||
makeRoute({ name: 'api-gateway' }),
|
||||
makeRoute({
|
||||
name: 'dns-query',
|
||||
action: { type: 'socket-handler' as any, socketHandler: (() => {}) as any },
|
||||
}),
|
||||
];
|
||||
|
||||
const results = augmentRoutesWithHttp3(routes, defaultConfig);
|
||||
|
||||
// web-app and api-gateway should be augmented
|
||||
expect(results[0].match.transport).toEqual('all');
|
||||
expect(results[2].match.transport).toEqual('all');
|
||||
|
||||
// smtp and dns should NOT be augmented
|
||||
expect(results[1].match.transport).toBeUndefined();
|
||||
expect(results[3].match.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Default enabled behavior
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
tap.test('should treat undefined enabled as true (default on)', async () => {
|
||||
const route = makeRoute();
|
||||
const result = augmentRouteWithHttp3(route, {}); // no enabled field at all
|
||||
|
||||
expect(result.match.transport).toEqual('all');
|
||||
expect(result.action.udp!.quic!.enableHttp3).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should disable when enabled is explicitly false', async () => {
|
||||
const route = makeRoute();
|
||||
const result = augmentRouteWithHttp3(route, { enabled: false });
|
||||
|
||||
expect(result.match.transport).toBeUndefined();
|
||||
expect(result.action.udp).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
179
test/test.ipreputationchecker.ts
Normal file
179
test/test.ipreputationchecker.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Mock for dns lookup
|
||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||
|
||||
// Setup mock DNS resolver with proper typing
|
||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||
return mockDnsResolveImpl(hostname);
|
||||
};
|
||||
|
||||
// Test instantiation
|
||||
tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
expect(checker).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test singleton pattern
|
||||
tap.test('IPReputationChecker - should use singleton pattern', async () => {
|
||||
const checker1 = IPReputationChecker.getInstance();
|
||||
const checker2 = IPReputationChecker.getInstance();
|
||||
|
||||
// Both instances should be the same object
|
||||
expect(checker1 === checker2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test IP validation
|
||||
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
// Valid IP should work
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Invalid IP should fail with error
|
||||
const invalidResult = await checker.checkReputation('invalid.ip');
|
||||
expect(invalidResult.error).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test DNSBL lookups
|
||||
tap.test('IPReputationChecker - should check IP against DNSBL', async () => {
|
||||
try {
|
||||
// Setup mock implementation for DNSBL
|
||||
mockDnsResolveImpl = async (hostname: string) => {
|
||||
// Listed in DNSBL if IP contains 2
|
||||
if (hostname.includes('2.1.168.192') && hostname.includes('zen.spamhaus.org')) {
|
||||
return ['127.0.0.2'];
|
||||
}
|
||||
throw { code: 'ENOTFOUND' };
|
||||
};
|
||||
|
||||
// Create a new instance with specific settings for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 1 // Small cache for testing
|
||||
});
|
||||
|
||||
// Clean IP should have good score
|
||||
const cleanResult = await testInstance.checkReputation('192.168.1.1');
|
||||
expect(cleanResult.isSpam).toEqual(false);
|
||||
expect(cleanResult.score).toEqual(100);
|
||||
|
||||
// Blacklisted IP should have reduced score
|
||||
const blacklistedResult = await testInstance.checkReputation('192.168.1.2');
|
||||
expect(blacklistedResult.isSpam).toEqual(true);
|
||||
expect(blacklistedResult.score < 100).toEqual(true); // Less than 100
|
||||
expect(blacklistedResult.blacklists).toBeTruthy();
|
||||
expect((blacklistedResult.blacklists || []).length > 0).toEqual(true);
|
||||
} catch (err) {
|
||||
console.error('Test error:', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Test caching behavior
|
||||
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
||||
// Create a fresh instance for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 10 // Small cache for testing
|
||||
});
|
||||
|
||||
// Check that first look performs a lookup and second uses cache
|
||||
const ip = '192.168.1.10';
|
||||
|
||||
// First check should add to cache
|
||||
const result1 = await testInstance.checkReputation(ip);
|
||||
expect(result1).toBeTruthy();
|
||||
|
||||
// Manually verify it's in cache - access private member for testing
|
||||
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
||||
expect(hasInCache).toEqual(true);
|
||||
|
||||
// Call again, should use cache
|
||||
const result2 = await testInstance.checkReputation(ip);
|
||||
expect(result2).toBeTruthy();
|
||||
|
||||
// Results should be identical
|
||||
expect(result1.score).toEqual(result2.score);
|
||||
});
|
||||
|
||||
// Test risk level classification
|
||||
tap.test('IPReputationChecker - should classify risk levels correctly', async () => {
|
||||
expect(IPReputationChecker.getRiskLevel(10)).toEqual('high');
|
||||
expect(IPReputationChecker.getRiskLevel(30)).toEqual('medium');
|
||||
expect(IPReputationChecker.getRiskLevel(60)).toEqual('low');
|
||||
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
||||
});
|
||||
|
||||
// Test IP type detection
|
||||
tap.test('IPReputationChecker - should detect special IP types', async () => {
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: true,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 5 // Small cache for testing
|
||||
});
|
||||
|
||||
// Test Tor exit node detection
|
||||
const torResult = await testInstance.checkReputation('171.25.1.1');
|
||||
expect(torResult.isTor).toEqual(true);
|
||||
expect(torResult.score < 90).toEqual(true);
|
||||
|
||||
// Test VPN detection
|
||||
const vpnResult = await testInstance.checkReputation('185.156.1.1');
|
||||
expect(vpnResult.isVPN).toEqual(true);
|
||||
expect(vpnResult.score < 90).toEqual(true);
|
||||
|
||||
// Test proxy detection
|
||||
const proxyResult = await testInstance.checkReputation('34.92.1.1');
|
||||
expect(proxyResult.isProxy).toEqual(true);
|
||||
expect(proxyResult.score < 90).toEqual(true);
|
||||
});
|
||||
|
||||
// Test error handling
|
||||
tap.test('IPReputationChecker - should handle DNS lookup errors gracefully', async () => {
|
||||
// Setup mock implementation to simulate error
|
||||
mockDnsResolveImpl = async () => {
|
||||
throw new Error('DNS server error');
|
||||
};
|
||||
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 300 // Force new instance
|
||||
});
|
||||
|
||||
// Should return a result despite errors
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toEqual(100); // No blacklist hits found due to error
|
||||
expect(result.isSpam).toEqual(false);
|
||||
});
|
||||
|
||||
// Restore original implementation at the end
|
||||
tap.test('Cleanup - restore mocks', async () => {
|
||||
plugins.dns.promises.resolve = originalDnsResolve;
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
132
test/test.jwt-auth.ts
Normal file
132
test/test.jwt-auth.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3102,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: {
|
||||
...identity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
// The response should contain the same identity data as the JWT
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
const response = await logoutRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('success');
|
||||
expect(response.success).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
let errorOccurred = false;
|
||||
try {
|
||||
await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
} catch (error) {
|
||||
errorOccurred = true;
|
||||
// TypedResponseError is thrown
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(errorOccurred).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
120
test/test.metricsmanager.route-keys.node.ts
Normal file
120
test/test.metricsmanager.route-keys.node.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||
|
||||
const emptyProtocolDistribution = {
|
||||
h1Active: 0,
|
||||
h1Total: 0,
|
||||
h2Active: 0,
|
||||
h2Total: 0,
|
||||
h3Active: 0,
|
||||
h3Total: 0,
|
||||
wsActive: 0,
|
||||
wsTotal: 0,
|
||||
otherActive: 0,
|
||||
otherTotal: 0,
|
||||
};
|
||||
|
||||
function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
requestsTotal?: number;
|
||||
}) {
|
||||
return {
|
||||
connections: {
|
||||
active: () => 0,
|
||||
total: () => 0,
|
||||
byRoute: () => args.connectionsByRoute,
|
||||
byIP: () => new Map<string, number>(),
|
||||
topIPs: () => [],
|
||||
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||
topDomainRequests: () => [],
|
||||
frontendProtocols: () => emptyProtocolDistribution,
|
||||
backendProtocols: () => emptyProtocolDistribution,
|
||||
},
|
||||
throughput: {
|
||||
instant: () => ({ in: 0, out: 0 }),
|
||||
recent: () => ({ in: 0, out: 0 }),
|
||||
average: () => ({ in: 0, out: 0 }),
|
||||
custom: () => ({ in: 0, out: 0 }),
|
||||
history: () => [],
|
||||
byRoute: () => args.throughputByRoute,
|
||||
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||
},
|
||||
requests: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
bytesOut: () => 0,
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 4],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1200, out: 2400 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 3],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
requestsTotal: 4,
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
routeManager: {
|
||||
getRoutes: () => [
|
||||
{
|
||||
id: 'route-id-only',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['alpha.example.com', 'beta.example.com'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||
|
||||
expect(alpha).toBeDefined();
|
||||
expect(beta).toBeDefined();
|
||||
|
||||
expect(alpha!.requestCount).toEqual(3);
|
||||
expect(alpha!.routeCount).toEqual(1);
|
||||
expect(alpha!.activeConnections).toEqual(3);
|
||||
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||
|
||||
expect(beta!.requestCount).toEqual(1);
|
||||
expect(beta!.routeCount).toEqual(1);
|
||||
expect(beta!.activeConnections).toEqual(1);
|
||||
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
124
test/test.opsserver-api.ts
Normal file
124
test/test.opsserver-api.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3101,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
identity: adminIdentity,
|
||||
detailed: false,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
expect(response.health.services).toHaveProperty('OpsServer');
|
||||
});
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
identity: adminIdentity,
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
expect(response.stats).toHaveProperty('cpuUsage');
|
||||
expect(response.stats).toHaveProperty('memoryUsage');
|
||||
});
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
identity: adminIdentity,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should reject unauthenticated requests', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
try {
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
128
test/test.protected-endpoint.ts
Normal file
128
test/test.protected-endpoint.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3103,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
console.log('Admin identity verified successfully');
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request without identity');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({
|
||||
identity: {
|
||||
...adminIdentity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
},
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request with invalid JWT');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject protected endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
try {
|
||||
// No identity provided — should be rejected
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('system');
|
||||
expect(response.config).toHaveProperty('smartProxy');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
console.log('Authenticated access to config successful');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
371
test/test.reference-resolver.ts
Normal file
371
test/test.reference-resolver.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
|
||||
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||
(resolver as any).targets.set(target.id, target);
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||
return {
|
||||
id: 'profile-1',
|
||||
name: 'STANDARD',
|
||||
description: 'Test profile',
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||
return {
|
||||
id: 'target-1',
|
||||
name: 'INFRA',
|
||||
description: 'Test target',
|
||||
host: '192.168.5.247',
|
||||
port: 443,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||
return {
|
||||
name: 'test-route',
|
||||
match: { ports: 443, domains: 'test.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||
...overrides,
|
||||
} as IRouteConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resolution tests
|
||||
// ============================================================================
|
||||
|
||||
let resolver: ReferenceResolver;
|
||||
|
||||
tap.test('should create ReferenceResolver instance', async () => {
|
||||
resolver = new ReferenceResolver();
|
||||
expect(resolver).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listProfiles()).toBeArray();
|
||||
expect(resolver.listProfiles().length).toEqual(0);
|
||||
expect(resolver.listTargets()).toBeArray();
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
});
|
||||
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should merge inline route security with profile security', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// IP lists are unioned
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
|
||||
|
||||
// Inline maxConnections overrides profile
|
||||
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
|
||||
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
const baseProfile = makeProfile({
|
||||
id: 'base-profile',
|
||||
name: 'BASE',
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8'],
|
||||
maxConnections: 500,
|
||||
},
|
||||
});
|
||||
injectProfile(resolver, baseProfile);
|
||||
|
||||
const extendedProfile = makeProfile({
|
||||
id: 'extended-profile',
|
||||
name: 'EXTENDED',
|
||||
security: {
|
||||
ipAllowList: ['160.79.104.0/21'],
|
||||
},
|
||||
extendsProfiles: ['base-profile'],
|
||||
});
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Should have IPs from both base and extended profiles
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
const profileA = makeProfile({
|
||||
id: 'circular-a',
|
||||
name: 'A',
|
||||
security: { ipAllowList: ['1.1.1.1'] },
|
||||
extendsProfiles: ['circular-b'],
|
||||
});
|
||||
const profileB = makeProfile({
|
||||
id: 'circular-b',
|
||||
name: 'B',
|
||||
security: { ipAllowList: ['2.2.2.2'] },
|
||||
extendsProfiles: ['circular-a'],
|
||||
});
|
||||
injectProfile(resolver, profileA);
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
// ---- Network target resolution ----
|
||||
|
||||
tap.test('should resolve network target onto a route', async () => {
|
||||
const target = makeTarget();
|
||||
injectTarget(resolver, target);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.action.targets).toBeTruthy();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle missing target gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route targets should be unchanged (still the placeholder)
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Combined resolution ----
|
||||
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Security from profile
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
|
||||
// Target from network target
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
tap.test('should skip resolution when no metadata refs', async () => {
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['1.2.3.4'] },
|
||||
});
|
||||
const metadata: IRouteMetadata = {};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be completely unchanged
|
||||
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
});
|
||||
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const first = resolver.resolveRoute(route, metadata);
|
||||
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||
|
||||
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||
});
|
||||
|
||||
// ---- Lookup helpers ----
|
||||
|
||||
tap.test('should find routes by profile ref (sync)', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-a', {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
route: makeRoute({ name: 'route-b' }),
|
||||
enabled: true,
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
storedRoutes.set('route-c', {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(2);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
|
||||
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||
expect(targetRefs.length).toEqual(2);
|
||||
expect(targetRefs).toContain('route-b');
|
||||
expect(targetRefs).toContain('route-c');
|
||||
});
|
||||
|
||||
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-x', {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-x');
|
||||
expect(usage[0].routeName).toEqual('my-route');
|
||||
});
|
||||
|
||||
tap.test('should get target usage for a specific target ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-y', {
|
||||
id: 'route-y',
|
||||
route: makeRoute({ name: 'other-route' }),
|
||||
enabled: true,
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-y');
|
||||
expect(usage[0].routeName).toEqual('other-route');
|
||||
});
|
||||
|
||||
// ---- Profile/target getters ----
|
||||
|
||||
tap.test('should get profile by name', async () => {
|
||||
const profile = resolver.getProfileByName('STANDARD');
|
||||
expect(profile).toBeTruthy();
|
||||
expect(profile!.id).toEqual('profile-1');
|
||||
});
|
||||
|
||||
tap.test('should get target by name', async () => {
|
||||
const target = resolver.getTargetByName('INFRA');
|
||||
expect(target).toBeTruthy();
|
||||
expect(target!.id).toEqual('target-1');
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||
expect(profile).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent target name', async () => {
|
||||
const target = resolver.getTargetByName('NONEXISTENT');
|
||||
expect(target).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
31
test/test.smartmta-storage-manager.node.ts
Normal file
31
test/test.smartmta-storage-manager.node.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SmartMtaStorageManager } from '../ts/email/index.js';
|
||||
|
||||
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
|
||||
|
||||
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
const storageManager = new SmartMtaStorageManager(tempDir);
|
||||
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
|
||||
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
|
||||
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
|
||||
|
||||
const keys = await storageManager.list('/email/dkim/example.com/');
|
||||
expect(keys).toEqual([
|
||||
'/email/dkim/example.com/default/metadata',
|
||||
'/email/dkim/example.com/default/public.key',
|
||||
]);
|
||||
|
||||
await storageManager.delete('/email/dkim/example.com/default/metadata');
|
||||
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
208
test/test.source-profiles-api.ts
Normal file
208
test/test.source-profiles-api.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const TEST_PORT = 3200;
|
||||
const TEST_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
// ============================================================================
|
||||
// Setup — db disabled, handlers return graceful fallbacks
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
opsServerPort: TEST_PORT,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
TEST_URL,
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Source Profile endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty profiles list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.profiles).toBeArray();
|
||||
expect(response.profiles.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should return null for single profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
|
||||
TEST_URL,
|
||||
'getSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.profile).toEqual(null);
|
||||
});
|
||||
|
||||
tap.test('should return failure for create profile when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
|
||||
TEST_URL,
|
||||
'createSourceProfile'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
name: 'TEST',
|
||||
security: { ipAllowList: ['*'] },
|
||||
});
|
||||
|
||||
expect(response.success).toBeFalse();
|
||||
expect(response.message).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return empty profile usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
|
||||
TEST_URL,
|
||||
'getSourceProfileUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.routes).toBeArray();
|
||||
expect(response.routes.length).toEqual(0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Network Target endpoints (graceful fallbacks when resolver unavailable)
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should return empty targets list when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
TEST_URL,
|
||||
'getNetworkTargets'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response.targets).toBeArray();
|
||||
expect(response.targets.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should return null for single target when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTarget>(
|
||||
TEST_URL,
|
||||
'getNetworkTarget'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.target).toEqual(null);
|
||||
});
|
||||
|
||||
tap.test('should return failure for create target when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_CreateNetworkTarget>(
|
||||
TEST_URL,
|
||||
'createNetworkTarget'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
name: 'TEST',
|
||||
host: '127.0.0.1',
|
||||
port: 443,
|
||||
});
|
||||
|
||||
expect(response.success).toBeFalse();
|
||||
expect(response.message).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should return empty target usage when resolver not initialized', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargetUsage>(
|
||||
TEST_URL,
|
||||
'getNetworkTargetUsage'
|
||||
);
|
||||
|
||||
const response = await req.fire({
|
||||
identity: adminIdentity,
|
||||
id: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(response.routes).toBeArray();
|
||||
expect(response.routes.length).toEqual(0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Auth rejection
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should reject unauthenticated profile requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
|
||||
TEST_URL,
|
||||
'getSourceProfiles'
|
||||
);
|
||||
|
||||
try {
|
||||
await req.fire({} as any);
|
||||
expect(true).toBeFalse();
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject unauthenticated target requests', async () => {
|
||||
const req = new TypedRequest<interfaces.requests.IReq_GetNetworkTargets>(
|
||||
TEST_URL,
|
||||
'getNetworkTargets'
|
||||
);
|
||||
|
||||
try {
|
||||
await req.fire({} as any);
|
||||
expect(true).toBeFalse();
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
69
test_watch/devserver.ts
Normal file
69
test_watch/devserver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Server public IP (used for VPN AllowedIPs)
|
||||
publicIp: '203.0.113.1',
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-traffic',
|
||||
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
},
|
||||
{
|
||||
name: 'api-gateway',
|
||||
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||
},
|
||||
{
|
||||
name: 'tls-passthrough',
|
||||
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 4443 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vpn-internal-app',
|
||||
match: { ports: [18080], domains: ['internal.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
|
||||
vpnOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'vpn-eng-dashboard',
|
||||
match: { ports: [18080], domains: ['eng.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
|
||||
vpnOnly: true,
|
||||
},
|
||||
] as any[],
|
||||
},
|
||||
// VPN with pre-defined clients
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.dev.local',
|
||||
clients: [
|
||||
{ clientId: 'dev-laptop', description: 'Developer laptop' },
|
||||
{ clientId: 'ci-runner', description: 'CI/CD pipeline' },
|
||||
{ clientId: 'admin-desktop', description: 'Admin workstation' },
|
||||
],
|
||||
},
|
||||
dbConfig: { enabled: true },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
await devRouter.start();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await devRouter.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '1.0.2',
|
||||
description: 'contains the platformservice container with mail, sms, letter, ai services.'
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.19.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
1
ts/acme/index.ts
Normal file
1
ts/acme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './manager.acme-config.js';
|
||||
182
ts/acme/manager.acme-config.ts
Normal file
182
ts/acme/manager.acme-config.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { AcmeConfigDoc } from '../db/documents/index.js';
|
||||
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||
|
||||
/**
|
||||
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
|
||||
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
|
||||
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
|
||||
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
|
||||
*
|
||||
* Reload semantics: updates take effect on the next dcrouter restart because
|
||||
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
|
||||
* applies immediately to the next renewal check. See
|
||||
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
|
||||
*/
|
||||
export class AcmeConfigManager {
|
||||
private cached: IAcmeConfig | null = null;
|
||||
|
||||
constructor(private options: IDcRouterOptions) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'AcmeConfigManager: starting');
|
||||
let doc = await AcmeConfigDoc.load();
|
||||
|
||||
if (!doc) {
|
||||
// First-boot path: seed from legacy constructor fields if present.
|
||||
const seed = this.deriveSeedFromOptions();
|
||||
if (seed) {
|
||||
doc = await this.createSeedDoc(seed);
|
||||
logger.log(
|
||||
'info',
|
||||
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
'info',
|
||||
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
|
||||
);
|
||||
}
|
||||
} else if (this.deriveSeedFromOptions()) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
|
||||
);
|
||||
}
|
||||
|
||||
this.cached = doc ? this.toPlain(doc) : null;
|
||||
if (this.cached) {
|
||||
logger.log(
|
||||
'info',
|
||||
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current ACME config, or null if not configured.
|
||||
* In-memory — does not hit the DB.
|
||||
*/
|
||||
public getConfig(): IAcmeConfig | null {
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
|
||||
* decide whether to instantiate SmartAcme.
|
||||
*/
|
||||
public hasEnabledConfig(): boolean {
|
||||
return this.cached !== null && this.cached.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the ACME config. All fields are optional; missing fields are
|
||||
* preserved from the existing row (or defaulted if there is no row yet).
|
||||
*/
|
||||
public async updateConfig(
|
||||
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
|
||||
updatedBy: string,
|
||||
): Promise<IAcmeConfig> {
|
||||
let doc = await AcmeConfigDoc.load();
|
||||
const now = Date.now();
|
||||
|
||||
if (!doc) {
|
||||
doc = new AcmeConfigDoc();
|
||||
doc.configId = 'acme-config';
|
||||
doc.accountEmail = args.accountEmail ?? '';
|
||||
doc.enabled = args.enabled ?? true;
|
||||
doc.useProduction = args.useProduction ?? true;
|
||||
doc.autoRenew = args.autoRenew ?? true;
|
||||
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
|
||||
} else {
|
||||
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
|
||||
if (args.enabled !== undefined) doc.enabled = args.enabled;
|
||||
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
|
||||
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
|
||||
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
|
||||
}
|
||||
|
||||
doc.updatedAt = now;
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
|
||||
this.cached = this.toPlain(doc);
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Internal helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build a seed object from the legacy constructor fields. Returns null
|
||||
* if the user has not provided any of them.
|
||||
*
|
||||
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
|
||||
* (full form). `smartProxyConfig.acme` wins when both are present.
|
||||
*/
|
||||
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
|
||||
const acme = this.options.smartProxyConfig?.acme;
|
||||
const tls = this.options.tls;
|
||||
|
||||
// Prefer the explicit smartProxyConfig.acme block if present.
|
||||
if (acme?.accountEmail) {
|
||||
return {
|
||||
accountEmail: acme.accountEmail,
|
||||
enabled: acme.enabled !== false,
|
||||
useProduction: acme.useProduction !== false,
|
||||
autoRenew: acme.autoRenew !== false,
|
||||
renewThresholdDays: acme.renewThresholdDays ?? 30,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the short tls.contactEmail form.
|
||||
if (tls?.contactEmail) {
|
||||
return {
|
||||
accountEmail: tls.contactEmail,
|
||||
enabled: true,
|
||||
useProduction: true,
|
||||
autoRenew: true,
|
||||
renewThresholdDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createSeedDoc(
|
||||
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
|
||||
): Promise<AcmeConfigDoc> {
|
||||
const doc = new AcmeConfigDoc();
|
||||
doc.configId = 'acme-config';
|
||||
doc.accountEmail = seed.accountEmail;
|
||||
doc.enabled = seed.enabled;
|
||||
doc.useProduction = seed.useProduction;
|
||||
doc.autoRenew = seed.autoRenew;
|
||||
doc.renewThresholdDays = seed.renewThresholdDays;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
return doc;
|
||||
}
|
||||
|
||||
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
|
||||
return {
|
||||
accountEmail: doc.accountEmail,
|
||||
enabled: doc.enabled,
|
||||
useProduction: doc.useProduction,
|
||||
autoRenew: doc.autoRenew,
|
||||
renewThresholdDays: doc.renewThresholdDays,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
165
ts/classes.cert-provision-scheduler.ts
Normal file
165
ts/classes.cert-provision-scheduler.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { logger } from './logger.js';
|
||||
import { CertBackoffDoc } from './db/index.js';
|
||||
|
||||
interface IBackoffEntry {
|
||||
failures: number;
|
||||
lastFailure: string; // ISO string
|
||||
retryAfter: string; // ISO string
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages certificate provisioning scheduling with:
|
||||
* - Per-domain exponential backoff persisted via CertBackoffDoc
|
||||
*
|
||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||
* concurrency, per-domain dedup, and rate limiting internally.
|
||||
*/
|
||||
export class CertProvisionScheduler {
|
||||
private maxBackoffHours: number;
|
||||
|
||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||
private backoffCache = new Map<string, IBackoffEntry>();
|
||||
|
||||
constructor(
|
||||
options?: { maxBackoffHours?: number }
|
||||
) {
|
||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitized domain key for storage lookups
|
||||
*/
|
||||
private sanitizeDomain(domain: string): string {
|
||||
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backoff entry from database (with in-memory cache)
|
||||
*/
|
||||
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||
const cached = this.backoffCache.get(domain);
|
||||
if (cached) return cached;
|
||||
|
||||
const sanitized = this.sanitizeDomain(domain);
|
||||
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||
if (doc) {
|
||||
const entry: IBackoffEntry = {
|
||||
failures: doc.failures,
|
||||
lastFailure: doc.lastFailure,
|
||||
retryAfter: doc.retryAfter,
|
||||
lastError: doc.lastError,
|
||||
};
|
||||
this.backoffCache.set(domain, entry);
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backoff entry to both cache and database
|
||||
*/
|
||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||
this.backoffCache.set(domain, entry);
|
||||
const sanitized = this.sanitizeDomain(domain);
|
||||
let doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||
if (!doc) {
|
||||
doc = new CertBackoffDoc();
|
||||
doc.domain = sanitized;
|
||||
}
|
||||
doc.failures = entry.failures;
|
||||
doc.lastFailure = entry.lastFailure;
|
||||
doc.retryAfter = entry.retryAfter;
|
||||
doc.lastError = entry.lastError || '';
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is currently in backoff.
|
||||
* Expired entries are pruned from the cache to prevent unbounded growth.
|
||||
*/
|
||||
async isInBackoff(domain: string): Promise<boolean> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return false;
|
||||
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
if (retryAfter.getTime() > Date.now()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backoff has expired — prune the stale entry
|
||||
this.backoffCache.delete(domain);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a provisioning failure for a domain.
|
||||
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
|
||||
*/
|
||||
async recordFailure(domain: string, error?: string): Promise<void> {
|
||||
const existing = await this.loadBackoff(domain);
|
||||
const failures = (existing?.failures ?? 0) + 1;
|
||||
|
||||
// Exponential backoff: failures^2 hours, capped
|
||||
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
|
||||
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||
|
||||
const entry: IBackoffEntry = {
|
||||
failures,
|
||||
lastFailure: new Date().toISOString(),
|
||||
retryAfter: retryAfter.toISOString(),
|
||||
lastError: error,
|
||||
};
|
||||
|
||||
await this.saveBackoff(domain, entry);
|
||||
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear backoff for a domain (on success or manual override)
|
||||
*/
|
||||
async clearBackoff(domain: string): Promise<void> {
|
||||
this.backoffCache.delete(domain);
|
||||
try {
|
||||
const sanitized = this.sanitizeDomain(domain);
|
||||
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
} catch {
|
||||
// Ignore delete errors (doc may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-memory backoff cache entries
|
||||
*/
|
||||
public clear(): void {
|
||||
this.backoffCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backoff info for UI display
|
||||
*/
|
||||
async getBackoffInfo(domain: string): Promise<{
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
} | null> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return null;
|
||||
|
||||
// Only return if still in backoff — prune expired entries
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
if (retryAfter.getTime() <= Date.now()) {
|
||||
this.backoffCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
failures: entry.failures,
|
||||
retryAfter: entry.retryAfter,
|
||||
lastError: entry.lastError,
|
||||
};
|
||||
}
|
||||
}
|
||||
2452
ts/classes.dcrouter.ts
Normal file
2452
ts/classes.dcrouter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
import * as plugins from './platformservice.plugins.js';
|
||||
import * as paths from './platformservice.paths.js';
|
||||
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
||||
|
||||
export class SzPlatformService {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||
public platformserviceDb: PlatformServiceDb;
|
||||
|
||||
public typedserver: plugins.typedserver.TypedServer;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public async start() {
|
||||
this.platformserviceDb = new PlatformServiceDb(this);
|
||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||
this.typedserver = new plugins.typedserver.TypedServer({
|
||||
cors: true,
|
||||
});
|
||||
await this.typedserver.start();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as plugins from './platformservice.plugins.js';
|
||||
import { SzPlatformService } from './classes.platformservice.js';
|
||||
|
||||
|
||||
|
||||
export class PlatformServiceDb {
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
public platformserviceRef: SzPlatformService;
|
||||
|
||||
constructor(platformserviceRefArg: SzPlatformService) {
|
||||
this.platformserviceRef = platformserviceRefArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
||||
58
ts/classes.storage-cert-manager.ts
Normal file
58
ts/classes.storage-cert-manager.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { AcmeCertDoc } from './db/index.js';
|
||||
|
||||
/**
|
||||
* ICertManager implementation backed by smartdata document classes.
|
||||
* Persists SmartAcme certificates via AcmeCertDoc so they
|
||||
* survive process restarts without re-hitting ACME.
|
||||
*/
|
||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||
constructor() {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||
if (!doc) return null;
|
||||
return new plugins.smartacme.Cert({
|
||||
id: doc.id,
|
||||
domainName: doc.domainName,
|
||||
created: doc.created,
|
||||
privateKey: doc.privateKey,
|
||||
publicKey: doc.publicKey,
|
||||
csr: doc.csr,
|
||||
validUntil: doc.validUntil,
|
||||
});
|
||||
}
|
||||
|
||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||
if (!doc) {
|
||||
doc = new AcmeCertDoc();
|
||||
doc.id = cert.id;
|
||||
doc.domainName = cert.domainName;
|
||||
}
|
||||
doc.created = cert.created;
|
||||
doc.privateKey = cert.privateKey;
|
||||
doc.publicKey = cert.publicKey;
|
||||
doc.csr = cert.csr;
|
||||
doc.validUntil = cert.validUntil;
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
async deleteCertificate(domainName: string): Promise<void> {
|
||||
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const docs = await AcmeCertDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
await doc.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
204
ts/config/classes.api-token-manager.ts
Normal file
204
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { ApiTokenDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const TOKEN_PREFIX_STR = 'dcr_';
|
||||
|
||||
export class ApiTokenManager {
|
||||
private tokens = new Map<string, IStoredApiToken>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadTokens();
|
||||
if (this.tokens.size > 0) {
|
||||
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a new API token. Returns the raw token value (shown once).
|
||||
*/
|
||||
public async createToken(
|
||||
name: string,
|
||||
scopes: TApiTokenScope[],
|
||||
expiresInDays: number | null,
|
||||
createdBy: string,
|
||||
): Promise<{ id: string; rawToken: string }> {
|
||||
const id = plugins.uuid.v4();
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
const now = Date.now();
|
||||
const stored: IStoredApiToken = {
|
||||
id,
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
createdAt: now,
|
||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||
lastUsedAt: null,
|
||||
createdBy,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.tokens.set(id, stored);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||
* Also updates lastUsedAt.
|
||||
*/
|
||||
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||
|
||||
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
for (const stored of this.tokens.values()) {
|
||||
if (stored.tokenHash === hash) {
|
||||
if (!stored.enabled) return null;
|
||||
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||
|
||||
// Update lastUsedAt (fire and forget)
|
||||
stored.lastUsedAt = Date.now();
|
||||
this.persistToken(stored).catch(() => {});
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has a specific scope.
|
||||
*/
|
||||
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||
return token.scopes.includes(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens (safe info only, no hashes).
|
||||
*/
|
||||
public listTokens(): IApiTokenInfo[] {
|
||||
const result: IApiTokenInfo[] = [];
|
||||
for (const stored of this.tokens.values()) {
|
||||
result.push({
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
scopes: stored.scopes,
|
||||
createdAt: stored.createdAt,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastUsedAt: stored.lastUsedAt,
|
||||
enabled: stored.enabled,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token.
|
||||
*/
|
||||
public async revokeToken(id: string): Promise<boolean> {
|
||||
if (!this.tokens.has(id)) return false;
|
||||
const token = this.tokens.get(id)!;
|
||||
this.tokens.delete(id);
|
||||
const doc = await ApiTokenDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll (regenerate) a token's secret while keeping its identity.
|
||||
* Returns the new raw token value (shown once).
|
||||
*/
|
||||
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
|
||||
const stored = this.tokens.get(id);
|
||||
if (!stored) return null;
|
||||
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a token.
|
||||
*/
|
||||
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||
const stored = this.tokens.get(id);
|
||||
if (!stored) return false;
|
||||
stored.enabled = enabled;
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private
|
||||
// =========================================================================
|
||||
|
||||
private async loadTokens(): Promise<void> {
|
||||
const docs = await ApiTokenDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.tokens.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
tokenHash: doc.tokenHash,
|
||||
scopes: doc.scopes,
|
||||
createdAt: doc.createdAt,
|
||||
expiresAt: doc.expiresAt,
|
||||
lastUsedAt: doc.lastUsedAt,
|
||||
createdBy: doc.createdBy,
|
||||
enabled: doc.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||
const existing = await ApiTokenDoc.findById(stored.id);
|
||||
if (existing) {
|
||||
existing.name = stored.name;
|
||||
existing.tokenHash = stored.tokenHash;
|
||||
existing.scopes = stored.scopes;
|
||||
existing.createdAt = stored.createdAt;
|
||||
existing.expiresAt = stored.expiresAt;
|
||||
existing.lastUsedAt = stored.lastUsedAt;
|
||||
existing.createdBy = stored.createdBy;
|
||||
existing.enabled = stored.enabled;
|
||||
await existing.save();
|
||||
} else {
|
||||
const doc = new ApiTokenDoc();
|
||||
doc.id = stored.id;
|
||||
doc.name = stored.name;
|
||||
doc.tokenHash = stored.tokenHash;
|
||||
doc.scopes = stored.scopes;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.expiresAt = stored.expiresAt;
|
||||
doc.lastUsedAt = stored.lastUsedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.enabled = stored.enabled;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ts/config/classes.db-seeder.ts
Normal file
95
ts/config/classes.db-seeder.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { logger } from '../logger.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
export interface ISeedData {
|
||||
profiles?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
security: IRouteSecurity;
|
||||
extendsProfiles?: string[];
|
||||
}>;
|
||||
targets?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
host: string | string[];
|
||||
port: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class DbSeeder {
|
||||
constructor(private referenceResolver: ReferenceResolver) {}
|
||||
|
||||
/**
|
||||
* Check if DB is empty and seed if configured.
|
||||
* Called once during ConfigManagers service startup, after initialize().
|
||||
*/
|
||||
public async seedIfEmpty(
|
||||
seedOnEmpty?: boolean,
|
||||
seedData?: ISeedData,
|
||||
): Promise<void> {
|
||||
if (!seedOnEmpty) return;
|
||||
|
||||
const existingProfiles = this.referenceResolver.listProfiles();
|
||||
const existingTargets = this.referenceResolver.listTargets();
|
||||
|
||||
if (existingProfiles.length > 0 || existingTargets.length > 0) {
|
||||
logger.log('info', 'DB already contains profiles/targets, skipping seed');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Seeding database with initial profiles and targets...');
|
||||
|
||||
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
|
||||
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
|
||||
|
||||
for (const p of profilesToSeed) {
|
||||
await this.referenceResolver.createProfile({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
security: p.security,
|
||||
extendsProfiles: p.extendsProfiles,
|
||||
createdBy: 'system-seed',
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of targetsToSeed) {
|
||||
await this.referenceResolver.createTarget({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
host: t.host,
|
||||
port: t.port,
|
||||
createdBy: 'system-seed',
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||
{
|
||||
name: 'PUBLIC',
|
||||
description: 'Allow all traffic — no IP restrictions',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'STANDARD',
|
||||
description: 'Standard internal access with common private subnets',
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
|
||||
{
|
||||
name: 'LOCALHOST',
|
||||
description: 'Local machine on port 443',
|
||||
host: '127.0.0.1',
|
||||
port: 443,
|
||||
},
|
||||
];
|
||||
577
ts/config/classes.reference-resolver.ts
Normal file
577
ts/config/classes.reference-resolver.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
|
||||
export class ReferenceResolver {
|
||||
private profiles = new Map<string, ISourceProfile>();
|
||||
private targets = new Map<string, INetworkTarget>();
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadProfiles();
|
||||
await this.loadTargets();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createProfile(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
security: IRouteSecurity;
|
||||
extendsProfiles?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const profile: ISourceProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
security: data.security,
|
||||
extendsProfiles: data.extendsProfiles,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created source profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Source profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.security !== undefined) profile.security = patch.security;
|
||||
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
|
||||
|
||||
// Find routes referencing this profile
|
||||
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||
return { affectedRouteIds };
|
||||
}
|
||||
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Source profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check usage
|
||||
const affectedIds = storedRoutes
|
||||
? this.findRoutesByProfileRefSync(id, storedRoutes)
|
||||
: await this.findRoutesByProfileRef(id);
|
||||
|
||||
if (affectedIds.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await SourceProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
// If force-deleting with referencing routes, clear refs but keep resolved values
|
||||
if (affectedIds.length > 0) {
|
||||
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ISourceProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
public getProfileByName(name: string): ISourceProfile | undefined {
|
||||
for (const profile of this.profiles.values()) {
|
||||
if (profile.name === name) return profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listProfiles(): ISourceProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||
for (const profile of this.profiles.values()) {
|
||||
usage.set(profile.id, []);
|
||||
}
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
const ref = stored.metadata?.sourceProfileRef;
|
||||
if (ref && usage.has(ref)) {
|
||||
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Target CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createTarget(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
host: string | string[];
|
||||
port: number;
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const target: INetworkTarget = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.targets.set(id, target);
|
||||
await this.persistTarget(target);
|
||||
logger.log('info', `Created network target '${target.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateTarget(
|
||||
id: string,
|
||||
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
throw new Error(`Network target '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) target.name = patch.name;
|
||||
if (patch.description !== undefined) target.description = patch.description;
|
||||
if (patch.host !== undefined) target.host = patch.host;
|
||||
if (patch.port !== undefined) target.port = patch.port;
|
||||
target.updatedAt = Date.now();
|
||||
|
||||
await this.persistTarget(target);
|
||||
logger.log('info', `Updated network target '${target.name}' (${id})`);
|
||||
|
||||
const affectedRouteIds = await this.findRoutesByTargetRef(id);
|
||||
return { affectedRouteIds };
|
||||
}
|
||||
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
return { success: false, message: `Network target '${id}' not found` };
|
||||
}
|
||||
|
||||
const affectedIds = storedRoutes
|
||||
? this.findRoutesByTargetRefSync(id, storedRoutes)
|
||||
: await this.findRoutesByTargetRef(id);
|
||||
|
||||
if (affectedIds.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
const doc = await NetworkTargetDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.targets.delete(id);
|
||||
|
||||
if (affectedIds.length > 0) {
|
||||
await this.clearTargetRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted network target '${target.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getTarget(id: string): INetworkTarget | undefined {
|
||||
return this.targets.get(id);
|
||||
}
|
||||
|
||||
public getTargetByName(name: string): INetworkTarget | undefined {
|
||||
for (const target of this.targets.values()) {
|
||||
if (target.name === name) return target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listTargets(): INetworkTarget[] {
|
||||
return [...this.targets.values()];
|
||||
}
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Resolution
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
metadata?: IRouteMetadata,
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
// Merge: profile provides base, route's inline values override
|
||||
route = {
|
||||
...route,
|
||||
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||
};
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||
if (target) {
|
||||
const hosts = Array.isArray(target.host) ? target.host : [target.host];
|
||||
route = {
|
||||
...route,
|
||||
action: {
|
||||
...route.action,
|
||||
targets: hosts.map((h) => ({
|
||||
host: h,
|
||||
port: target.port,
|
||||
})),
|
||||
},
|
||||
};
|
||||
resolvedMetadata.networkTargetName = target.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
return { route, metadata: resolvedMetadata };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reference lookup helpers
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveSourceProfile(
|
||||
profileId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0,
|
||||
): IRouteSecurity | null {
|
||||
if (depth > MAX_INHERITANCE_DEPTH) {
|
||||
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visited.has(profileId)) {
|
||||
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) return null;
|
||||
|
||||
visited.add(profileId);
|
||||
|
||||
// Start with an empty base
|
||||
let baseSecurity: IRouteSecurity = {};
|
||||
|
||||
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||
if (profile.extendsProfiles?.length) {
|
||||
for (const parentId of profile.extendsProfiles) {
|
||||
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
|
||||
if (parentSecurity) {
|
||||
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply this profile's security on top
|
||||
return this.mergeSecurityFields(baseSecurity, profile.security);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two IRouteSecurity objects.
|
||||
* `override` values take precedence over `base` values.
|
||||
* For ipAllowList/ipBlockList: union arrays and deduplicate.
|
||||
* For scalar/object fields: override wins if present.
|
||||
*/
|
||||
private mergeSecurityFields(
|
||||
base: IRouteSecurity | undefined,
|
||||
override: IRouteSecurity | undefined,
|
||||
): IRouteSecurity {
|
||||
if (!base && !override) return {};
|
||||
if (!base) return { ...override };
|
||||
if (!override) return { ...base };
|
||||
|
||||
const merged: IRouteSecurity = { ...base };
|
||||
|
||||
// IP lists: union
|
||||
if (override.ipAllowList || base.ipAllowList) {
|
||||
merged.ipAllowList = [...new Set([
|
||||
...(base.ipAllowList || []),
|
||||
...(override.ipAllowList || []),
|
||||
])];
|
||||
}
|
||||
|
||||
if (override.ipBlockList || base.ipBlockList) {
|
||||
merged.ipBlockList = [...new Set([
|
||||
...(base.ipBlockList || []),
|
||||
...(override.ipBlockList || []),
|
||||
])];
|
||||
}
|
||||
|
||||
// Scalar/object fields: override wins
|
||||
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
|
||||
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
|
||||
if (override.authentication !== undefined) merged.authentication = override.authentication;
|
||||
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
|
||||
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await SourceProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
security: doc.security,
|
||||
extendsProfiles: doc.extendsProfiles,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTargets(): Promise<void> {
|
||||
const docs = await NetworkTargetDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.targets.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
host: doc.host,
|
||||
port: doc.port,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.targets.size > 0) {
|
||||
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ISourceProfile): Promise<void> {
|
||||
const existingDoc = await SourceProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
existingDoc.security = profile.security;
|
||||
existingDoc.extendsProfiles = profile.extendsProfiles;
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new SourceProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
doc.security = profile.security;
|
||||
doc.extendsProfiles = profile.extendsProfiles;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async persistTarget(target: INetworkTarget): Promise<void> {
|
||||
const existingDoc = await NetworkTargetDoc.findById(target.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = target.name;
|
||||
existingDoc.description = target.description;
|
||||
existingDoc.host = target.host;
|
||||
existingDoc.port = target.port;
|
||||
existingDoc.updatedAt = target.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new NetworkTargetDoc();
|
||||
doc.id = target.id;
|
||||
doc.name = target.name;
|
||||
doc.description = target.description;
|
||||
doc.host = target.host;
|
||||
doc.port = target.port;
|
||||
doc.createdAt = target.createdAt;
|
||||
doc.updatedAt = target.updatedAt;
|
||||
doc.createdBy = target.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: ref cleanup on force-delete
|
||||
// =========================================================================
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
sourceProfileRef: undefined,
|
||||
sourceProfileName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
networkTargetRef: undefined,
|
||||
networkTargetName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
531
ts/config/classes.route-config-manager.ts
Normal file
531
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||
|
||||
export interface IRouteMutationResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||
*/
|
||||
class RouteUpdateMutex {
|
||||
private locked = false;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.locked = false;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
this.locked = true;
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private routes = new Map<string, IRoute>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||
) {}
|
||||
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
public getRoutes(): Map<string, IRoute> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
public getRoute(id: string): IRoute | undefined {
|
||||
return this.routes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
*/
|
||||
public async initialize(
|
||||
configRoutes: IDcRouterRouteConfig[] = [],
|
||||
emailRoutes: IDcRouterRouteConfig[] = [],
|
||||
dnsRoutes: IDcRouterRouteConfig[] = [],
|
||||
): Promise<void> {
|
||||
await this.loadRoutes();
|
||||
await this.seedRoutes(configRoutes, 'config');
|
||||
await this.seedRoutes(emailRoutes, 'email');
|
||||
await this.seedRoutes(dnsRoutes, 'dns');
|
||||
this.computeWarnings();
|
||||
this.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route listing
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
for (const route of this.routes.values()) {
|
||||
merged.push({
|
||||
route: route.route,
|
||||
id: route.id,
|
||||
enabled: route.enabled,
|
||||
origin: route.origin,
|
||||
systemKey: route.systemKey,
|
||||
createdAt: route.createdAt,
|
||||
updatedAt: route.updatedAt,
|
||||
metadata: route.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `route-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Resolve references if metadata has refs and resolver is available
|
||||
let resolvedMetadata = metadata;
|
||||
if (metadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(route, metadata);
|
||||
route = resolved.route;
|
||||
resolvedMetadata = resolved.metadata;
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<IRouteMutationResult> {
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
&& patch.metadata === undefined;
|
||||
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System routes are managed by the system and can only be toggled',
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.route) {
|
||||
const mergedAction = patch.route.action
|
||||
? { ...stored.route.action, ...patch.route.action }
|
||||
: stored.route.action;
|
||||
// Handle explicit null to remove nested action properties (e.g., tls: null)
|
||||
if (patch.route.action) {
|
||||
for (const [key, val] of Object.entries(patch.route.action)) {
|
||||
if (val === null) {
|
||||
delete (mergedAction as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.metadata !== undefined) {
|
||||
stored.metadata = { ...stored.metadata, ...patch.metadata };
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
if (stored.metadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
stored.route = resolved.route;
|
||||
stored.metadata = resolved.metadata;
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
if (stored.origin !== 'api') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System routes are managed by the system and cannot be deleted',
|
||||
};
|
||||
}
|
||||
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
await this.applyRoutes();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||
*/
|
||||
private async seedRoutes(
|
||||
seedRoutes: IDcRouterRouteConfig[],
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
): Promise<void> {
|
||||
const seedSystemKeys = new Set<string>();
|
||||
const seedNames = new Set<string>();
|
||||
let seeded = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const route of seedRoutes) {
|
||||
const name = route.name || '';
|
||||
if (name) {
|
||||
seedNames.add(name);
|
||||
}
|
||||
const systemKey = this.buildSystemRouteKey(origin, route);
|
||||
if (systemKey) {
|
||||
seedSystemKeys.add(systemKey);
|
||||
}
|
||||
|
||||
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
|
||||
|
||||
if (existingId) {
|
||||
// Update route config but preserve enabled state
|
||||
const existing = this.routes.get(existingId)!;
|
||||
existing.route = route;
|
||||
existing.systemKey = systemKey;
|
||||
existing.updatedAt = Date.now();
|
||||
await this.persistRoute(existing);
|
||||
updated++;
|
||||
} else {
|
||||
// Insert new seed route
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const newRoute: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
origin,
|
||||
systemKey,
|
||||
};
|
||||
this.routes.set(id, newRoute);
|
||||
await this.persistRoute(newRoute);
|
||||
seeded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stale routes: same origin but name not in current seed set
|
||||
const staleIds: string[] = [];
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin !== origin) continue;
|
||||
|
||||
const routeName = r.route.name || '';
|
||||
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
|
||||
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
|
||||
if (!matchesSeedSystemKey && !matchesSeedName) {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of staleIds) {
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
}
|
||||
|
||||
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
|
||||
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private buildSystemRouteKey(
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const name = route.name?.trim();
|
||||
if (!name) return undefined;
|
||||
return `${origin}:${name}`;
|
||||
}
|
||||
|
||||
private findExistingSeedRouteId(
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
route: IDcRouterRouteConfig,
|
||||
systemKey?: string,
|
||||
): string | undefined {
|
||||
const routeName = route.name || '';
|
||||
|
||||
for (const [id, storedRoute] of this.routes) {
|
||||
if (storedRoute.origin !== origin) continue;
|
||||
|
||||
if (systemKey && storedRoute.systemKey === systemKey) {
|
||||
return id;
|
||||
}
|
||||
|
||||
if (storedRoute.route.name === routeName) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async loadRoutes(): Promise<void> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
|
||||
for (const doc of docs) {
|
||||
if (!doc.id) continue;
|
||||
|
||||
const storedRoute: IRoute = {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
origin: doc.origin || 'api',
|
||||
systemKey: doc.systemKey,
|
||||
metadata: doc.metadata,
|
||||
};
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
}
|
||||
if (this.routes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRoute(stored: IRoute): Promise<void> {
|
||||
const existingDoc = await RouteDoc.findById(stored.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.route = stored.route;
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.origin = stored.origin;
|
||||
existingDoc.systemKey = stored.systemKey;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new RouteDoc();
|
||||
doc.id = stored.id;
|
||||
doc.route = stored.route;
|
||||
doc.enabled = stored.enabled;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.updatedAt = stored.updatedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.origin = stored.origin;
|
||||
doc.systemKey = stored.systemKey;
|
||||
doc.metadata = stored.metadata;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
|
||||
for (const route of this.routes.values()) {
|
||||
if (!route.enabled) {
|
||||
const name = route.route.name || route.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-route',
|
||||
routeName: name,
|
||||
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logWarnings(): void {
|
||||
for (const w of this.warnings) {
|
||||
logger.log('warn', w.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Re-resolve routes after profile/target changes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Re-resolve specific routes by ID (after a profile or target is updated).
|
||||
* Persists each route and calls applyRoutes() once at the end.
|
||||
*/
|
||||
public async reResolveRoutes(routeIds: string[]): Promise<void> {
|
||||
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||
|
||||
for (const routeId of routeIds) {
|
||||
const stored = this.routes.get(routeId);
|
||||
if (!stored?.metadata) continue;
|
||||
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
stored.route = resolved.route;
|
||||
stored.metadata = resolved.metadata;
|
||||
stored.updatedAt = Date.now();
|
||||
await this.persistRoute(stored);
|
||||
}
|
||||
|
||||
await this.applyRoutes();
|
||||
logger.log('info', `Re-resolved ${routeIds.length} route(s) after profile/target change`);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Apply routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
await this.routeUpdateMutex.runExclusive(async () => {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
});
|
||||
}
|
||||
|
||||
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
let preparedRoute = route;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
|
||||
if (http3Config?.enabled !== false) {
|
||||
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||
}
|
||||
|
||||
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||
}
|
||||
|
||||
private injectVpnSecurity(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
if (!vpnCallback) return route;
|
||||
|
||||
const dcRoute = route as IDcRouterRouteConfig;
|
||||
if (!dcRoute.vpnOnly) return route;
|
||||
|
||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||
const existingEntries = route.security?.ipAllowList || [];
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
538
ts/config/classes.target-profile-manager.ts
Normal file
538
ts/config/classes.target-profile-manager.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
/**
|
||||
* Manages TargetProfiles (target-side: what can be accessed).
|
||||
* TargetProfiles define what resources a VPN client can reach:
|
||||
* domains, specific IP:port targets, and/or direct route references.
|
||||
*/
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadProfiles();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createProfile(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
domains?: string[];
|
||||
targets?: ITargetProfileTarget[];
|
||||
routeRefs?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
// Enforce unique profile names
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.name === data.name) {
|
||||
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created target profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<void> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Target profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||
for (const existing of this.profiles.values()) {
|
||||
if (existing.id !== id && existing.name === patch.name) {
|
||||
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force?: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Target profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check if any VPN clients reference this profile
|
||||
const clients = await VpnClientDoc.findAll();
|
||||
const referencingClients = clients.filter(
|
||||
(c) => c.targetProfileIds?.includes(id),
|
||||
);
|
||||
|
||||
if (referencingClients.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await TargetProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
if (referencingClients.length > 0) {
|
||||
// Remove profile ref from clients
|
||||
for (const client of referencingClients) {
|
||||
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
|
||||
client.updatedAt = Date.now();
|
||||
await client.save();
|
||||
}
|
||||
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ITargetProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored route references to route IDs when they can be resolved
|
||||
* uniquely against the current route registry.
|
||||
*/
|
||||
public async normalizeAllRouteRefs(): Promise<void> {
|
||||
const allRoutes = this.getAllRoutes?.();
|
||||
if (!allRoutes?.size) return;
|
||||
|
||||
for (const profile of this.profiles.values()) {
|
||||
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||
profile.routeRefs,
|
||||
allRoutes,
|
||||
'bestEffort',
|
||||
);
|
||||
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||
|
||||
profile.routeRefs = normalizedRouteRefs;
|
||||
profile.updatedAt = Date.now();
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
public listProfiles(): ITargetProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which VPN clients reference a target profile.
|
||||
*/
|
||||
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
|
||||
const clients = await VpnClientDoc.findAll();
|
||||
return clients
|
||||
.filter((c) => c.targetProfileIds?.includes(profileId))
|
||||
.map((c) => ({ clientId: c.clientId, description: c.description }));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Direct target IPs (bypass SmartProxy)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* For a set of target profile IDs, collect all explicit target IPs.
|
||||
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
|
||||
* connect to them directly through the tunnel.
|
||||
*/
|
||||
public getDirectTargetIps(targetProfileIds: string[]): string[] {
|
||||
const ips = new Set<string>();
|
||||
for (const profileId of targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile?.targets?.length) continue;
|
||||
for (const t of profile.targets) {
|
||||
ips.add(t.ip);
|
||||
}
|
||||
}
|
||||
return [...ips];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Core matching: route → client IPs
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
|
||||
* matches the route. Returns IP allow entries for injection into ipAllowList.
|
||||
*
|
||||
* Entries are domain-scoped when a profile matches via specific domains that are
|
||||
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
|
||||
* or when profile domains exactly equal the route's domains.
|
||||
*/
|
||||
public getMatchingClientIps(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
if (!client.targetProfileIds?.length) continue;
|
||||
|
||||
// Collect scoped domains from all matching profiles for this client
|
||||
let fullAccess = false;
|
||||
const scopedDomains = new Set<string>();
|
||||
|
||||
for (const profileId of client.targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
}
|
||||
if (matchResult !== 'none') {
|
||||
for (const d of matchResult.domains) scopedDomains.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAccess) {
|
||||
entries.push(client.assignedIp);
|
||||
} else if (scopedDomains.size > 0) {
|
||||
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given client (by its targetProfileIds), compute the set of
|
||||
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
|
||||
*/
|
||||
public getClientAccessSpec(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: Map<string, IRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
// Direct domain entries
|
||||
if (profile.domains?.length) {
|
||||
for (const d of profile.domains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct target IP entries
|
||||
if (profile.targets?.length) {
|
||||
for (const t of profile.targets) {
|
||||
targetIps.add(t.ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domains: [...domains],
|
||||
targetIps: [...targetIps],
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: matching logic
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if a route matches a profile (boolean convenience wrapper).
|
||||
*/
|
||||
private routeMatchesProfile(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const result = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
return result !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
|
||||
* or 'none' (no match).
|
||||
*
|
||||
* - routeRefs / target matches → 'full' (explicit reference = full access)
|
||||
* - domain match where profile domains are a subset of route wildcard → 'scoped'
|
||||
* - domain match where domains are identical or profile is a wildcard → 'full'
|
||||
*/
|
||||
private routeMatchesProfileDetailed(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Domain match
|
||||
if (profile.domains?.length && routeDomains.length) {
|
||||
const matchedProfileDomains: string[] = [];
|
||||
|
||||
for (const profileDomain of profile.domains) {
|
||||
for (const routeDomain of routeDomains) {
|
||||
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
|
||||
this.domainMatchesPattern(profileDomain, routeDomain)) {
|
||||
matchedProfileDomains.push(profileDomain);
|
||||
break; // This profileDomain matched, move to the next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedProfileDomains.length > 0) {
|
||||
// Check if profile domains cover the route entirely (same wildcards = full access)
|
||||
const isFullCoverage = routeDomains.every((rd) =>
|
||||
matchedProfileDomains.some((pd) =>
|
||||
rd === pd || this.domainMatchesPattern(rd, pd),
|
||||
),
|
||||
);
|
||||
if (isFullCoverage) return 'full';
|
||||
|
||||
// Profile domains are a subset → scoped access to those specific domains
|
||||
return { type: 'scoped', domains: matchedProfileDomains };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Target match (host + port) → full access (precise by nature)
|
||||
if (profile.targets?.length) {
|
||||
const routeTargets = (route.action as any)?.targets;
|
||||
if (Array.isArray(routeTargets)) {
|
||||
for (const profileTarget of profile.targets) {
|
||||
for (const routeTarget of routeTargets) {
|
||||
const routeHost = routeTarget.host;
|
||||
const routePort = routeTarget.port;
|
||||
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain matches a pattern.
|
||||
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
|
||||
* - 'example.com' matches only 'example.com'
|
||||
*/
|
||||
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
||||
if (pattern === domain) return true;
|
||||
if (pattern.startsWith('*.')) {
|
||||
const suffix = pattern.slice(1); // '.example.com'
|
||||
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||
}
|
||||
|
||||
private normalizeRouteRefsAgainstRoutes(
|
||||
routeRefs: string[] | undefined,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
mode: 'strict' | 'bestEffort',
|
||||
): string[] | undefined {
|
||||
if (!routeRefs?.length) return undefined;
|
||||
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
const normalizedRefs = new Set<string>();
|
||||
|
||||
for (const routeRef of routeRefs) {
|
||||
if (allRoutes.has(routeRef)) {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||
if (matchingRouteIds.length === 1) {
|
||||
normalizedRefs.add(matchingRouteIds[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === 'bestEffort') {
|
||||
normalizedRefs.add(routeRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchingRouteIds.length > 1) {
|
||||
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||
}
|
||||
throw new Error(`Route reference '${routeRef}' not found`);
|
||||
}
|
||||
|
||||
return [...normalizedRefs];
|
||||
}
|
||||
|
||||
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||
const routeNameIndex = new Map<string, string[]>();
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
const routeName = route.route.name;
|
||||
if (!routeName) continue;
|
||||
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||
matchingRouteIds.push(routeId);
|
||||
routeNameIndex.set(routeName, matchingRouteIds);
|
||||
}
|
||||
return routeNameIndex;
|
||||
}
|
||||
|
||||
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await TargetProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
domains: doc.domains,
|
||||
targets: doc.targets,
|
||||
routeRefs: doc.routeRefs,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ITargetProfile): Promise<void> {
|
||||
const existingDoc = await TargetProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
existingDoc.domains = profile.domains;
|
||||
existingDoc.targets = profile.targets;
|
||||
existingDoc.routeRefs = profile.routeRefs;
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new TargetProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
doc.domains = profile.domains;
|
||||
doc.targets = profile.targets;
|
||||
doc.routeRefs = profile.routeRefs;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ts/config/index.ts
Normal file
7
ts/config/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
266
ts/config/validator.ts
Normal file
266
ts/config/validator.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { ValidationError } from '../errors/base.errors.js';
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface IValidationResult {
|
||||
/**
|
||||
* Whether the validation passed
|
||||
*/
|
||||
valid: boolean;
|
||||
|
||||
/**
|
||||
* Validation errors if any
|
||||
*/
|
||||
errors?: string[];
|
||||
|
||||
/**
|
||||
* Validated configuration (may include defaults)
|
||||
*/
|
||||
config?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schema types
|
||||
*/
|
||||
export type ValidationSchema = Record<string, {
|
||||
/**
|
||||
* Type of the value
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
|
||||
/**
|
||||
* Whether the field is required
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Default value if not specified
|
||||
*/
|
||||
default?: any;
|
||||
|
||||
/**
|
||||
* Minimum value (for numbers)
|
||||
*/
|
||||
min?: number;
|
||||
|
||||
/**
|
||||
* Maximum value (for numbers)
|
||||
*/
|
||||
max?: number;
|
||||
|
||||
/**
|
||||
* Minimum length (for strings or arrays)
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/**
|
||||
* Maximum length (for strings or arrays)
|
||||
*/
|
||||
maxLength?: number;
|
||||
|
||||
/**
|
||||
* Pattern to match (for strings)
|
||||
*/
|
||||
pattern?: RegExp;
|
||||
|
||||
/**
|
||||
* Allowed values (for strings, numbers)
|
||||
*/
|
||||
enum?: any[];
|
||||
|
||||
/**
|
||||
* Nested schema (for objects)
|
||||
*/
|
||||
schema?: ValidationSchema;
|
||||
|
||||
/**
|
||||
* Item schema (for arrays)
|
||||
*/
|
||||
items?: {
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
schema?: ValidationSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom validation function
|
||||
*/
|
||||
validate?: (value: any) => boolean | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Configuration validator
|
||||
* Validates configuration objects against schemas and provides default values
|
||||
*/
|
||||
export class ConfigValidator {
|
||||
|
||||
/**
|
||||
* Validate a configuration object against a schema
|
||||
*
|
||||
* @param config Configuration object to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validation result
|
||||
*/
|
||||
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
|
||||
const errors: string[] = [];
|
||||
const validatedConfig = { ...config };
|
||||
|
||||
// Validate each field against the schema
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
const value = config[key];
|
||||
|
||||
// Check if required
|
||||
if (rules.required && (value === undefined || value === null)) {
|
||||
errors.push(`${key} is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not present and not required, apply default if available
|
||||
if ((value === undefined || value === null)) {
|
||||
if (rules.default !== undefined) {
|
||||
validatedConfig[key] = rules.default;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (value !== undefined && value !== null) {
|
||||
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||
if (valueType !== rules.type) {
|
||||
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validations
|
||||
switch (rules.type) {
|
||||
case 'number':
|
||||
if (rules.min !== undefined && value < rules.min) {
|
||||
errors.push(`${key} must be at least ${rules.min}`);
|
||||
}
|
||||
if (rules.max !== undefined && value > rules.max) {
|
||||
errors.push(`${key} must be at most ${rules.max}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must be at most ${rules.maxLength} characters`);
|
||||
}
|
||||
if (rules.pattern && !rules.pattern.test(value)) {
|
||||
errors.push(`${key} must match pattern ${rules.pattern}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must have at least ${rules.minLength} items`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must have at most ${rules.maxLength} items`);
|
||||
}
|
||||
if (rules.items && value.length > 0) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
|
||||
if (itemType !== rules.items.type) {
|
||||
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
|
||||
} else if (rules.items.schema && itemType === 'object') {
|
||||
const itemResult = this.validate(value[i], rules.items.schema);
|
||||
if (!itemResult.valid) {
|
||||
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (rules.schema) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if (rules.enum && !rules.enum.includes(value)) {
|
||||
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.validate) {
|
||||
const result = rules.validate(value);
|
||||
if (result !== true) {
|
||||
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
config: validatedConfig
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply defaults to a configuration object based on a schema
|
||||
*
|
||||
* @param config Configuration object to apply defaults to
|
||||
* @param schema Validation schema with defaults
|
||||
* @returns Configuration with defaults applied
|
||||
*/
|
||||
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = { ...config };
|
||||
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
if (result[key] === undefined && rules.default !== undefined) {
|
||||
result[key] = rules.default;
|
||||
}
|
||||
|
||||
// Apply defaults to nested objects
|
||||
if (result[key] && rules.type === 'object' && rules.schema) {
|
||||
result[key] = this.applyDefaults(result[key], rules.schema);
|
||||
}
|
||||
|
||||
// Apply defaults to array items
|
||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a validation error if the configuration is invalid
|
||||
*
|
||||
* @param config Configuration to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validated configuration with defaults
|
||||
* @throws ValidationError if validation fails
|
||||
*/
|
||||
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = this.validate(config, schema);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
}
|
||||
|
||||
return result.config;
|
||||
}
|
||||
}
|
||||
166
ts/db/classes.cache.cleaner.ts
Normal file
166
ts/db/classes.cache.cleaner.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { DcRouterDb } from './classes.dcrouter-db.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||
|
||||
/**
|
||||
* Configuration for the cache cleaner
|
||||
*/
|
||||
export interface ICacheCleanerOptions {
|
||||
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||
intervalMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheCleaner - Periodically removes expired documents from the cache
|
||||
*
|
||||
* Runs on a configurable interval (default: hourly) and queries each
|
||||
* collection for documents where expiresAt < now(), then deletes them.
|
||||
*/
|
||||
export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private dcRouterDb: DcRouterDb;
|
||||
|
||||
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||
this.dcRouterDb = dcRouterDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic cleanup process
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.log('warn', 'CacheCleaner already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic cleanup process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.log('info', 'CacheCleaner stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.dcRouterDb.isReady()) {
|
||||
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results: { collection: string; deleted: number }[] = [];
|
||||
|
||||
try {
|
||||
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
|
||||
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||
|
||||
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
|
||||
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||
|
||||
// Log results
|
||||
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||
if (totalDeleted > 0 || this.options.verbose) {
|
||||
const summary = results
|
||||
.filter((r) => r.deleted > 0)
|
||||
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||
.join(', ');
|
||||
logger.log(
|
||||
'info',
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired documents from a specific collection using smartdata API
|
||||
*/
|
||||
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
|
||||
documentClass: { getInstances: (filter: any) => Promise<T[]> },
|
||||
now: Date
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Find all expired documents
|
||||
const expiredDocs = await documentClass.getInstances({
|
||||
expiresAt: { $lt: now },
|
||||
});
|
||||
|
||||
// Delete each expired document
|
||||
let deletedCount = 0;
|
||||
for (const doc of expiredDocs) {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError: unknown) {
|
||||
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cleaner is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cleanup interval in milliseconds
|
||||
*/
|
||||
public getIntervalMs(): number {
|
||||
return this.options.intervalMs;
|
||||
}
|
||||
}
|
||||
111
ts/db/classes.cached.document.ts
Normal file
111
ts/db/classes.cached.document.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Base class for all cached documents with TTL support
|
||||
*
|
||||
* Extends smartdata's SmartDataDbDoc to add:
|
||||
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||
* - TTL/expiration support (expiresAt)
|
||||
* - Helper methods for TTL management
|
||||
*
|
||||
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
|
||||
* since decorators on abstract classes don't propagate correctly.
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt!: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Set the TTL (time to live) for this document
|
||||
* @param ttlMs Time to live in milliseconds
|
||||
*/
|
||||
public setTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using days
|
||||
* @param days Number of days until expiration
|
||||
*/
|
||||
public setTTLDays(days: number): void {
|
||||
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using hours
|
||||
* @param hours Number of hours until expiration
|
||||
*/
|
||||
public setTTLHours(hours: number): void {
|
||||
this.setTTL(hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this document has expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (!this.expiresAt) {
|
||||
return false; // No expiration set
|
||||
}
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastAccessedAt timestamp
|
||||
*/
|
||||
public touch(): void {
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining TTL in milliseconds
|
||||
* Returns 0 if expired, -1 if no expiration set
|
||||
*/
|
||||
public getRemainingTTL(): number {
|
||||
if (!this.expiresAt) {
|
||||
return -1;
|
||||
}
|
||||
const remaining = this.expiresAt.getTime() - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL by the specified milliseconds from now
|
||||
* @param ttlMs Additional time to live in milliseconds
|
||||
*/
|
||||
public extendTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document to never expire (100 years in the future)
|
||||
*/
|
||||
public setNeverExpires(): void {
|
||||
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL constants in milliseconds
|
||||
*/
|
||||
export const TTL = {
|
||||
HOURS_1: 1 * 60 * 60 * 1000,
|
||||
HOURS_24: 24 * 60 * 60 * 1000,
|
||||
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
179
ts/db/classes.dcrouter-db.ts
Normal file
179
ts/db/classes.dcrouter-db.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { defaultTsmDbPath } from '../paths.js';
|
||||
|
||||
/**
|
||||
* Configuration options for the unified DCRouter database
|
||||
*/
|
||||
export interface IDcRouterDbConfig {
|
||||
/** External MongoDB connection URL. If absent, uses embedded LocalSmartDb. */
|
||||
mongoDbUrl?: string;
|
||||
/** Storage path for embedded LocalSmartDb data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DcRouterDb - Unified database layer for DCRouter
|
||||
*
|
||||
* Replaces both StorageManager (flat-file key-value) and CacheDb (embedded MongoDB).
|
||||
* All data is stored as smartdata document classes in a single database.
|
||||
*
|
||||
* Two modes:
|
||||
* - **Embedded** (default): Spawns a LocalSmartDb (Rust-based MongoDB-compatible engine)
|
||||
* - **External**: Connects to a provided MongoDB URL
|
||||
*/
|
||||
export class DcRouterDb {
|
||||
private static instance: DcRouterDb | null = null;
|
||||
|
||||
private localSmartDb: plugins.smartdb.LocalSmartDb | null = null;
|
||||
private smartdataDb!: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<IDcRouterDbConfig>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: IDcRouterDbConfig = {}) {
|
||||
this.options = {
|
||||
mongoDbUrl: options.mongoDbUrl || '',
|
||||
storagePath: options.storagePath || defaultTsmDbPath,
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: IDcRouterDbConfig): DcRouterDb {
|
||||
if (!DcRouterDb.instance) {
|
||||
DcRouterDb.instance = new DcRouterDb(options);
|
||||
}
|
||||
return DcRouterDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
DcRouterDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the database
|
||||
* - If mongoDbUrl is provided, connects directly to external MongoDB
|
||||
* - Otherwise, starts an embedded LocalSmartDb instance
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'DcRouterDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let connectionUri: string;
|
||||
|
||||
if (this.options.mongoDbUrl) {
|
||||
// External MongoDB mode
|
||||
connectionUri = this.options.mongoDbUrl;
|
||||
logger.log('info', `DcRouterDb connecting to external MongoDB`);
|
||||
} else {
|
||||
// Embedded LocalSmartDb mode
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
this.localSmartDb = new plugins.smartdb.LocalSmartDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
|
||||
const connectionInfo = await this.localSmartDb.start();
|
||||
connectionUri = connectionInfo.connectionUri;
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalSmartDb started with URI: ${connectionUri}`);
|
||||
}
|
||||
|
||||
logger.log('info', `DcRouterDb started embedded instance at ${this.options.storagePath}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata ORM
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: connectionUri,
|
||||
mongoDbName: this.options.dbName,
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `DcRouterDb ready (db: ${this.options.dbName})`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Failed to start DcRouterDb: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop embedded LocalSmartDb if running
|
||||
if (this.localSmartDb) {
|
||||
await this.localSmartDb.stop();
|
||||
this.localSmartDb = null;
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'DcRouterDb stopped');
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error stopping DcRouterDb: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance for @Collection decorators
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('DcRouterDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether running in embedded mode (LocalSmartDb) vs external MongoDB
|
||||
*/
|
||||
public isEmbedded(): boolean {
|
||||
return !this.options.mongoDbUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path (only relevant for embedded mode)
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
106
ts/db/documents/classes.accounting-session.doc.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AccountingSessionDoc extends plugins.smartdata.SmartDataDbDoc<AccountingSessionDoc, AccountingSessionDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public sessionId!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public username!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public macAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nasIpAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nasPort!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nasPortType!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nasIdentifier!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public vlanId!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public framedIpAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public calledStationId!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public callingStationId!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public startTime!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public endTime!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastUpdateTime!: number;
|
||||
|
||||
@plugins.smartdata.index()
|
||||
@plugins.smartdata.svDb()
|
||||
public status!: 'active' | 'stopped' | 'terminated';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public terminateCause!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public inputOctets!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public outputOctets!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public inputPackets!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public outputPackets!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public sessionTime!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public serviceType!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findBySessionId(sessionId: string): Promise<AccountingSessionDoc | null> {
|
||||
return await AccountingSessionDoc.getInstance({ sessionId });
|
||||
}
|
||||
|
||||
public static async findActive(): Promise<AccountingSessionDoc[]> {
|
||||
return await AccountingSessionDoc.getInstances({ status: 'active' });
|
||||
}
|
||||
|
||||
public static async findByUsername(username: string): Promise<AccountingSessionDoc[]> {
|
||||
return await AccountingSessionDoc.getInstances({ username });
|
||||
}
|
||||
|
||||
public static async findByNas(nasIpAddress: string): Promise<AccountingSessionDoc[]> {
|
||||
return await AccountingSessionDoc.getInstances({ nasIpAddress });
|
||||
}
|
||||
|
||||
public static async findByVlan(vlanId: number): Promise<AccountingSessionDoc[]> {
|
||||
return await AccountingSessionDoc.getInstances({ vlanId });
|
||||
}
|
||||
|
||||
public static async findStoppedBefore(cutoffTime: number): Promise<AccountingSessionDoc[]> {
|
||||
return await AccountingSessionDoc.getInstances({
|
||||
status: { $in: ['stopped', 'terminated'] } as any,
|
||||
endTime: { $lt: cutoffTime, $gt: 0 } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
41
ts/db/documents/classes.acme-cert.doc.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AcmeCertDoc extends plugins.smartdata.SmartDataDbDoc<AcmeCertDoc, AcmeCertDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domainName!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public created!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public privateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public csr!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public validUntil!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByDomain(domainName: string): Promise<AcmeCertDoc | null> {
|
||||
return await AcmeCertDoc.getInstance({ domainName });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<AcmeCertDoc[]> {
|
||||
return await AcmeCertDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* Singleton ACME configuration document. One row per dcrouter instance,
|
||||
* keyed on the fixed `configId = 'acme-config'` following the
|
||||
* `VpnServerKeysDoc` pattern.
|
||||
*
|
||||
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||
* constructor fields. Managed via the OpsServer UI at
|
||||
* **Domains > Certificates > Settings**.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'acme-config';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accountEmail: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useProduction: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public autoRenew: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public renewThresholdDays: number = 30;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<AcmeConfigDoc | null> {
|
||||
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
||||
}
|
||||
}
|
||||
56
ts/db/documents/classes.api-token.doc.ts
Normal file
56
ts/db/documents/classes.api-token.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, ApiTokenDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tokenHash!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public scopes!: TApiTokenScope[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt!: number | null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastUsedAt!: number | null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<ApiTokenDoc | null> {
|
||||
return await ApiTokenDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByTokenHash(tokenHash: string): Promise<ApiTokenDoc | null> {
|
||||
return await ApiTokenDoc.getInstance({ tokenHash });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<ApiTokenDoc[]> {
|
||||
return await ApiTokenDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<ApiTokenDoc[]> {
|
||||
return await ApiTokenDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
240
ts/db/documents/classes.cached.email.ts
Normal file
240
ts/db/documents/classes.cached.email.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
*
|
||||
* Used for persistent email queue storage, tracking delivery status,
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId!: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from!: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to!: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc!: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc!: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject!: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent!: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status!: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public attempts: number = 0;
|
||||
|
||||
/**
|
||||
* Maximum number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public maxAttempts: number = 3;
|
||||
|
||||
/**
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt!: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError!: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt!: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain!: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 0;
|
||||
|
||||
/**
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData!: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public dkimSigned: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.status = 'pending';
|
||||
this.to = [];
|
||||
this.cc = [];
|
||||
this.bcc = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CachedEmail with a unique ID
|
||||
*/
|
||||
public static createNew(): CachedEmail {
|
||||
const email = new CachedEmail();
|
||||
email.id = plugins.uuid.v4();
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an email by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||
return await CachedEmail.getInstance({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails with a specific status
|
||||
*/
|
||||
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||
*/
|
||||
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||
const now = new Date();
|
||||
return await CachedEmail.getInstances({
|
||||
status: 'pending',
|
||||
nextAttempt: { $lte: now },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find emails by sender domain
|
||||
*/
|
||||
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
senderDomain: domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as delivered
|
||||
*/
|
||||
public markDelivered(): void {
|
||||
this.status = 'delivered';
|
||||
this.deliveredAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as failed with error
|
||||
*/
|
||||
public markFailed(error: string): void {
|
||||
this.status = 'failed';
|
||||
this.lastError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment attempt counter and schedule next attempt
|
||||
*/
|
||||
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||
this.attempts++;
|
||||
this.status = 'deferred';
|
||||
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||
|
||||
// If max attempts reached, mark as failed
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
this.status = 'failed';
|
||||
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sender domain from email address
|
||||
*/
|
||||
public updateSenderDomain(): void {
|
||||
if (this.from) {
|
||||
const match = this.from.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.senderDomain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
247
ts/db/documents/classes.cached.ip.reputation.ts
Normal file
247
ts/db/documents/classes.cached.ip.reputation.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
*/
|
||||
export interface IIPReputationData {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
isProxy: boolean;
|
||||
isTor: boolean;
|
||||
isVPN: boolean;
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
blacklists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CachedIPReputation - Stores IP reputation lookup results
|
||||
*
|
||||
* Caches the results of IP reputation checks to avoid repeated
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress!: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score!: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN!: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country!: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn!: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org!: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists!: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public checkCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of connections from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of emails received from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public emailCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of spam emails from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public spamCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||
this.blacklists = [];
|
||||
this.score = 50; // Default neutral score
|
||||
this.isSpam = false;
|
||||
this.isProxy = false;
|
||||
this.isTor = false;
|
||||
this.isVPN = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from reputation data
|
||||
*/
|
||||
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||
const cached = new CachedIPReputation();
|
||||
cached.ipAddress = ipAddress;
|
||||
cached.score = data.score;
|
||||
cached.isSpam = data.isSpam;
|
||||
cached.isProxy = data.isProxy;
|
||||
cached.isTor = data.isTor;
|
||||
cached.isVPN = data.isVPN;
|
||||
cached.country = data.country || '';
|
||||
cached.asn = data.asn || '';
|
||||
cached.org = data.org || '';
|
||||
cached.blacklists = data.blacklists || [];
|
||||
cached.checkCount = 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to reputation data object
|
||||
*/
|
||||
public toReputationData(): IIPReputationData {
|
||||
this.touch();
|
||||
return {
|
||||
score: this.score,
|
||||
isSpam: this.isSpam,
|
||||
isProxy: this.isProxy,
|
||||
isTor: this.isTor,
|
||||
isVPN: this.isVPN,
|
||||
country: this.country,
|
||||
asn: this.asn,
|
||||
org: this.org,
|
||||
blacklists: this.blacklists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by IP address
|
||||
*/
|
||||
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||
return await CachedIPReputation.getInstance({
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all IPs flagged as spam
|
||||
*/
|
||||
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
isSpam: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find IPs with score below threshold
|
||||
*/
|
||||
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
score: { $lt: threshold },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from this IP
|
||||
*/
|
||||
public recordConnection(): void {
|
||||
this.connectionCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an email from this IP
|
||||
*/
|
||||
public recordEmail(isSpam: boolean = false): void {
|
||||
this.emailCount++;
|
||||
if (isSpam) {
|
||||
this.spamCount++;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reputation data
|
||||
*/
|
||||
public updateReputation(data: IIPReputationData): void {
|
||||
this.score = data.score;
|
||||
this.isSpam = data.isSpam;
|
||||
this.isProxy = data.isProxy;
|
||||
this.isTor = data.isTor;
|
||||
this.isVPN = data.isVPN;
|
||||
this.country = data.country || this.country;
|
||||
this.asn = data.asn || this.asn;
|
||||
this.org = data.org || this.org;
|
||||
this.blacklists = data.blacklists || this.blacklists;
|
||||
this.checkCount++;
|
||||
this.touch();
|
||||
// Refresh TTL on update
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this IP should be blocked
|
||||
*/
|
||||
public shouldBlock(): boolean {
|
||||
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||
}
|
||||
}
|
||||
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
35
ts/db/documents/classes.cert-backoff.doc.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domain!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public failures!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastFailure!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public retryAfter!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
|
||||
return await CertBackoffDoc.getInstance({ domain });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<CertBackoffDoc[]> {
|
||||
return await CertBackoffDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderStatus,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsProviderType;
|
||||
|
||||
/**
|
||||
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public credentials!: TDnsProviderCredentials;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TDnsProviderStatus = 'untested';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||
return await DnsProviderDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({ type });
|
||||
}
|
||||
}
|
||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domainId!: string;
|
||||
|
||||
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsRecordType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ttl: number = 300;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public proxied?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDnsRecordSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerRecordId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||
return await DnsRecordDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({ domainId });
|
||||
}
|
||||
}
|
||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
/** FQDN — kept lowercased on save. */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDomainSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public authoritative: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nameservers?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalZoneId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSyncedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({ providerId });
|
||||
}
|
||||
}
|
||||
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
IEmailDomainDkim,
|
||||
IEmailDomainRateLimits,
|
||||
IEmailDomainDnsStatus,
|
||||
} from '../../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public linkedDomainId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public subdomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dkim!: IEmailDomainDkim;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rateLimits?: IEmailDomainRateLimits;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dnsStatus!: IEmailDomainDnsStatus;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||
return await EmailDomainDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
48
ts/db/documents/classes.network-target.doc.ts
Normal file
48
ts/db/documents/classes.network-target.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public host!: string | string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public port!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
|
||||
return await NetworkTargetDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||
return await NetworkTargetDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||
return await NetworkTargetDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
38
ts/db/documents/classes.proxy-cert.doc.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domain!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public privateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ca!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public validUntil!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public validFrom!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
|
||||
return await ProxyCertDoc.getInstance({ domain });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<ProxyCertDoc[]> {
|
||||
return await ProxyCertDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
54
ts/db/documents/classes.remote-ingress-edge.doc.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public secret!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public listenPorts!: number[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public listenPortsUdp!: number[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public autoDerivePorts!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tags!: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
|
||||
return await RemoteIngressEdgeDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
|
||||
return await RemoteIngressEdgeDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
|
||||
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
61
ts/db/documents/classes.route.doc.ts
Normal file
61
ts/db/documents/classes.route.doc.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public route!: IDcRouterRouteConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public systemKey?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ 'route.name': name });
|
||||
}
|
||||
|
||||
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({ origin });
|
||||
}
|
||||
|
||||
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ systemKey });
|
||||
}
|
||||
}
|
||||
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
45
ts/db/documents/classes.source-profile.doc.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public security!: IRouteSecurity;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public extendsProfiles?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
48
ts/db/documents/classes.target-profile.doc.ts
Normal file
48
ts/db/documents/classes.target-profile.doc.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domains?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public targets?: ITargetProfileTarget[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public routeRefs?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||
return await TargetProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
32
ts/db/documents/classes.vlan-mappings.doc.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
export interface IMacVlanMapping {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'vlan-mappings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public mappings!: IMacVlanMapping[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.mappings = [];
|
||||
}
|
||||
|
||||
public static async load(): Promise<VlanMappingsDoc | null> {
|
||||
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
|
||||
}
|
||||
}
|
||||
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
70
ts/db/documents/classes.vpn-client.doc.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public clientId!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public targetProfileIds?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public assignedIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPrivateKey?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationAllowList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationBlockList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useHostIp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useDhcp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public staticIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceVlan?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public vlanId?: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
31
ts/db/documents/classes.vpn-server-keys.doc.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'vpn-server-keys';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePrivateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPrivateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPublicKey!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<VpnServerKeysDoc | null> {
|
||||
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
|
||||
}
|
||||
}
|
||||
37
ts/db/documents/index.ts
Normal file
37
ts/db/documents/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Cached/TTL document classes
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
export * from './classes.network-target.doc.js';
|
||||
|
||||
// VPN document classes
|
||||
export * from './classes.vpn-server-keys.doc.js';
|
||||
export * from './classes.vpn-client.doc.js';
|
||||
|
||||
// Certificate document classes
|
||||
export * from './classes.acme-cert.doc.js';
|
||||
export * from './classes.proxy-cert.doc.js';
|
||||
export * from './classes.cert-backoff.doc.js';
|
||||
|
||||
// Remote ingress document classes
|
||||
export * from './classes.remote-ingress-edge.doc.js';
|
||||
|
||||
// RADIUS document classes
|
||||
export * from './classes.vlan-mappings.doc.js';
|
||||
export * from './classes.accounting-session.doc.js';
|
||||
|
||||
// DNS / Domain management document classes
|
||||
export * from './classes.dns-provider.doc.js';
|
||||
export * from './classes.domain.doc.js';
|
||||
export * from './classes.dns-record.doc.js';
|
||||
|
||||
// ACME configuration (singleton)
|
||||
export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
11
ts/db/index.ts
Normal file
11
ts/db/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Unified database manager
|
||||
export * from './classes.dcrouter-db.js';
|
||||
|
||||
// TTL base class and constants
|
||||
export * from './classes.cached.document.js';
|
||||
|
||||
// Cache cleaner
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
export * from './documents/index.js';
|
||||
2
ts/dns/index.ts
Normal file
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './manager.dns.js';
|
||||
export * from './providers/index.js';
|
||||
1064
ts/dns/manager.dns.ts
Normal file
1064
ts/dns/manager.dns.ts
Normal file
File diff suppressed because it is too large
Load Diff
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type {
|
||||
IDnsProviderClient,
|
||||
IConnectionTestResult,
|
||||
IProviderRecord,
|
||||
IProviderRecordInput,
|
||||
} from './interfaces.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
/**
|
||||
* Cloudflare implementation of IDnsProviderClient.
|
||||
*
|
||||
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||
* can have multiple records of the same name+type).
|
||||
*/
|
||||
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||
|
||||
constructor(apiToken: string) {
|
||||
if (!apiToken) {
|
||||
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||
}
|
||||
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
try {
|
||||
// Listing zones is the lightest-weight call that proves the token works.
|
||||
await this.cfAccount.zoneManager.listZones();
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||
const zones = await this.cfAccount.zoneManager.listZones();
|
||||
return zones.map((zone) => ({
|
||||
name: zone.name,
|
||||
externalId: zone.id,
|
||||
nameservers: zone.name_servers ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||
return records
|
||||
.filter((r) => this.isSupportedType(r.type))
|
||||
.map((r) => ({
|
||||
providerRecordId: r.id,
|
||||
name: r.name,
|
||||
type: r.type as TDnsRecordType,
|
||||
value: r.content,
|
||||
ttl: r.ttl,
|
||||
proxied: r.proxied,
|
||||
}));
|
||||
}
|
||||
|
||||
public async createRecord(
|
||||
domain: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1, // 1 = automatic
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||
return {
|
||||
providerRecordId: created.id,
|
||||
name: created.name,
|
||||
type: created.type as TDnsRecordType,
|
||||
value: created.content,
|
||||
ttl: created.ttl,
|
||||
proxied: created.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1,
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||
providerRecordId,
|
||||
apiRecord,
|
||||
);
|
||||
return {
|
||||
providerRecordId: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.type as TDnsRecordType,
|
||||
value: updated.content,
|
||||
ttl: updated.ttl,
|
||||
proxied: updated.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||
zone_id: zoneId,
|
||||
});
|
||||
}
|
||||
|
||||
private isSupportedType(type: string): boolean {
|
||||
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||
}
|
||||
}
|
||||
59
ts/dns/providers/factory.ts
Normal file
59
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { IDnsProviderClient } from './interfaces.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||
|
||||
/**
|
||||
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||
*
|
||||
* @throws if the provider type is not supported.
|
||||
*
|
||||
* ## Adding a new provider (e.g. Route53)
|
||||
*
|
||||
* 1. **Type union** — extend `TDnsProviderType` in
|
||||
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
|
||||
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
|
||||
* the `TDnsProviderCredentials` discriminated union.
|
||||
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
|
||||
* the OpsServer UI picks up the new type and renders the right credential
|
||||
* form fields automatically.
|
||||
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
|
||||
* implementing `IDnsProviderClient`.
|
||||
* 5. **Factory case** — add a new `case 'route53':` below. The
|
||||
* `_exhaustive: never` line will fail to compile until you do.
|
||||
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
|
||||
*/
|
||||
export function createDnsProvider(
|
||||
type: TDnsProviderType,
|
||||
credentials: TDnsProviderCredentials,
|
||||
): IDnsProviderClient {
|
||||
switch (type) {
|
||||
case 'cloudflare': {
|
||||
if (credentials.type !== 'cloudflare') {
|
||||
throw new Error(
|
||||
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||
);
|
||||
}
|
||||
return new CloudflareDnsProvider(credentials.apiToken);
|
||||
}
|
||||
case 'dcrouter': {
|
||||
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
|
||||
// itself serves the records via the embedded smartdns.DnsServer. This
|
||||
// case exists only to satisfy the exhaustive switch; it should never
|
||||
// actually run because the handler layer rejects any CRUD that would
|
||||
// result in a DnsProviderDoc with type: 'dcrouter'.
|
||||
throw new Error(
|
||||
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
|
||||
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||
const _exhaustive: never = type;
|
||||
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './cloudflare.provider.js';
|
||||
export * from './factory.js';
|
||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
/**
|
||||
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||
* is the provider's internal identifier, used for subsequent updates and
|
||||
* deletes (since providers can have multiple records of the same name+type).
|
||||
*/
|
||||
export interface IProviderRecord {
|
||||
providerRecordId: string;
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for creating / updating a DNS record at a provider.
|
||||
*/
|
||||
export interface IProviderRecordInput {
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a connection test against a provider's API.
|
||||
*/
|
||||
export interface IConnectionTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable DNS provider client interface. One implementation per provider type
|
||||
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||
* instantiated by `createDnsProvider()` in factory.ts.
|
||||
*
|
||||
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||
*/
|
||||
export interface IDnsProviderClient {
|
||||
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
|
||||
/** List all DNS zones visible to this provider account. */
|
||||
listDomains(): Promise<IProviderDomainListing[]>;
|
||||
|
||||
/** List all DNS records for a zone (FQDN). */
|
||||
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||
|
||||
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||
|
||||
/** Update an existing record by provider id; returns the updated record. */
|
||||
updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord>;
|
||||
|
||||
/** Delete a record by provider id. */
|
||||
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||
}
|
||||
406
ts/email/classes.email-domain.manager.ts
Normal file
406
ts/email/classes.email-domain.manager.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
import { logger } from '../logger.js';
|
||||
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
||||
import type { DnsManager } from '../dns/manager.dns.js';
|
||||
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
||||
import { buildEmailDnsRecords } from './email-dns-records.js';
|
||||
|
||||
/**
|
||||
* EmailDomainManager — orchestrates email domain setup.
|
||||
*
|
||||
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
|
||||
* (record creation for dcrouter-hosted and provider-managed zones) to provide
|
||||
* a single entry point for setting up an email domain from A to Z.
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
private get dnsManager(): DnsManager | undefined {
|
||||
return this.dcRouter.dnsManager;
|
||||
}
|
||||
|
||||
private get dkimCreator(): any | undefined {
|
||||
return this.dcRouter.emailServer?.dkimCreator;
|
||||
}
|
||||
|
||||
private get emailHostname(): string {
|
||||
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public async getAll(): Promise<IEmailDomain[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
return docs.map((d) => this.docToInterface(d));
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
return doc ? this.docToInterface(doc) : null;
|
||||
}
|
||||
|
||||
public async createEmailDomain(opts: {
|
||||
linkedDomainId: string;
|
||||
subdomain?: string;
|
||||
dkimSelector?: string;
|
||||
dkimKeySize?: number;
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
}): Promise<IEmailDomain> {
|
||||
// Resolve the linked DNS domain
|
||||
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
||||
if (!domainDoc) {
|
||||
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||
}
|
||||
const baseDomain = domainDoc.name;
|
||||
const subdomain = opts.subdomain?.trim() || undefined;
|
||||
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||
|
||||
// Check for duplicates
|
||||
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||
throw new Error(`Email domain already configured for ${domainName}`);
|
||||
}
|
||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||
if (existing) {
|
||||
throw new Error(`Email domain already exists for ${domainName}`);
|
||||
}
|
||||
|
||||
const selector = opts.dkimSelector || 'default';
|
||||
const keySize = opts.dkimKeySize || 2048;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Generate DKIM keys
|
||||
let publicKey: string | undefined;
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||
// Extract public key from the DNS record value
|
||||
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||
publicKey = match ? match[1] : undefined;
|
||||
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the document
|
||||
const doc = new EmailDomainDoc();
|
||||
doc.id = plugins.smartunique.shortId();
|
||||
doc.domain = domainName.toLowerCase();
|
||||
doc.linkedDomainId = opts.linkedDomainId;
|
||||
doc.subdomain = subdomain;
|
||||
doc.dkim = {
|
||||
selector,
|
||||
keySize,
|
||||
publicKey,
|
||||
rotateKeys: opts.rotateKeys ?? false,
|
||||
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
|
||||
};
|
||||
doc.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
|
||||
logger.log('info', `Email domain created: ${domainName}`);
|
||||
return this.docToInterface(doc);
|
||||
}
|
||||
|
||||
public async updateEmailDomain(
|
||||
id: string,
|
||||
changes: {
|
||||
rotateKeys?: boolean;
|
||||
rotationIntervalDays?: number;
|
||||
rateLimits?: IEmailDomain['rateLimits'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
||||
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
||||
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
}
|
||||
|
||||
public async deleteEmailDomain(id: string): Promise<void> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
await doc.delete();
|
||||
await this.syncManagedDomainsToRuntime();
|
||||
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS record computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the 4 required DNS records for an email domain.
|
||||
*/
|
||||
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const hostname = this.emailHostname;
|
||||
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
||||
|
||||
if (this.dkimCreator) {
|
||||
try {
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||
dkimValue = dnsRecord.value;
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return buildEmailDnsRecords({
|
||||
domain,
|
||||
hostname,
|
||||
selector,
|
||||
dkimValue,
|
||||
statuses: doc.dnsStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Auto-create missing DNS records via the linked domain's DNS path.
|
||||
*/
|
||||
public async provisionDnsRecords(id: string): Promise<number> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
if (!this.dnsManager) throw new Error('DnsManager not available');
|
||||
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
const domainId = doc.linkedDomainId;
|
||||
|
||||
// Get existing DNS records for the linked domain
|
||||
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
|
||||
let provisioned = 0;
|
||||
|
||||
for (const required of requiredRecords) {
|
||||
// Check if a matching record already exists
|
||||
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.dnsManager.createRecord({
|
||||
domainId,
|
||||
name: required.name,
|
||||
type: required.type as any,
|
||||
value: required.value,
|
||||
ttl: 3600,
|
||||
createdBy: 'email-domain-manager',
|
||||
});
|
||||
provisioned++;
|
||||
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
|
||||
} catch (err: unknown) {
|
||||
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after provisioning
|
||||
await this.validateDns(id);
|
||||
|
||||
return provisioned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DNS validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate DNS records via live lookups.
|
||||
*/
|
||||
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
|
||||
const doc = await EmailDomainDoc.findById(id);
|
||||
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||
|
||||
const domain = doc.domain;
|
||||
const selector = doc.dkim.selector;
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
|
||||
// MX check
|
||||
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||
|
||||
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
||||
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
||||
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
||||
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
||||
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
||||
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return record.value.trim() === required.value.trim();
|
||||
}
|
||||
|
||||
private async checkMx(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
domain: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
if (!records || records.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
expectedValue?: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
if (flat.length === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
if (!expectedValue) {
|
||||
return 'valid';
|
||||
}
|
||||
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||
return found ? 'valid' : 'invalid';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
|
||||
return {
|
||||
id: doc.id,
|
||||
domain: doc.domain,
|
||||
linkedDomainId: doc.linkedDomainId,
|
||||
subdomain: doc.subdomain,
|
||||
dkim: doc.dkim,
|
||||
rateLimits: doc.rateLimits,
|
||||
dnsStatus: doc.dnsStatus,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private isDomainAlreadyConfigured(domainName: string): boolean {
|
||||
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
||||
return configuredDomains.includes(domainName.toLowerCase());
|
||||
}
|
||||
|
||||
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||
const docs = await EmailDomainDoc.findAll();
|
||||
const managedConfigs: IEmailDomainConfig[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
||||
if (!linkedDomain) {
|
||||
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
managedConfigs.push({
|
||||
domain: doc.domain,
|
||||
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
||||
dkim: {
|
||||
selector: doc.dkim.selector,
|
||||
keySize: doc.dkim.keySize,
|
||||
rotateKeys: doc.dkim.rotateKeys,
|
||||
rotationInterval: doc.dkim.rotationIntervalDays,
|
||||
},
|
||||
rateLimits: doc.rateLimits,
|
||||
});
|
||||
}
|
||||
|
||||
return managedConfigs;
|
||||
}
|
||||
|
||||
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||
if (!this.dcRouter.options?.emailConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||
for (const domainConfig of this.baseEmailDomains) {
|
||||
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||
const key = managedConfig.domain.toLowerCase();
|
||||
if (mergedDomains.has(key)) {
|
||||
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||
continue;
|
||||
}
|
||||
mergedDomains.set(key, managedConfig);
|
||||
}
|
||||
|
||||
const domains = Array.from(mergedDomains.values());
|
||||
this.dcRouter.options.emailConfig.domains = domains;
|
||||
if (this.dcRouter.emailServer) {
|
||||
this.dcRouter.emailServer.updateOptions({ domains });
|
||||
}
|
||||
}
|
||||
}
|
||||
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||
|
||||
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||
private readonly resolvedRootDir: string;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||
}
|
||||
|
||||
private normalizeKey(key: string): string {
|
||||
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
private resolvePathForKey(key: string): string {
|
||||
const normalizedKey = this.normalizeKey(key);
|
||||
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||
if (
|
||||
resolvedPath !== this.resolvedRootDir
|
||||
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||
) {
|
||||
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
private toStorageKey(filePath: string): string {
|
||||
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
try {
|
||||
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
const filePath = this.resolvePathForKey(key);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||
}
|
||||
|
||||
public async list(prefix: string): Promise<string[]> {
|
||||
const prefixPath = this.resolvePathForKey(prefix);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||
if (stat.isFile()) {
|
||||
return [this.toStorageKey(prefixPath)];
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
const walk = async (currentPath: string): Promise<void> => {
|
||||
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(this.toStorageKey(entryPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(prefixPath);
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
public async delete(key: string): Promise<void> {
|
||||
const targetPath = this.resolvePathForKey(key);
|
||||
try {
|
||||
const stat = await plugins.fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
await plugins.fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let currentDir = plugins.path.dirname(targetPath);
|
||||
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
await plugins.fs.promises.rmdir(currentDir);
|
||||
currentDir = plugins.path.dirname(currentDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
ts/email/email-dns-records.ts
Normal file
53
ts/email/email-dns-records.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
IEmailDnsRecord,
|
||||
TDnsRecordStatus,
|
||||
} from '../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
|
||||
|
||||
export interface IBuildEmailDnsRecordsOptions {
|
||||
domain: string;
|
||||
hostname: string;
|
||||
selector?: string;
|
||||
dkimValue?: string;
|
||||
mxPriority?: number;
|
||||
dmarcPolicy?: string;
|
||||
dmarcRua?: string;
|
||||
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
|
||||
}
|
||||
|
||||
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
|
||||
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
|
||||
const selector = options.selector || 'default';
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
type: 'MX',
|
||||
name: options.domain,
|
||||
value: `${options.mxPriority ?? 10} ${options.hostname}`,
|
||||
status: statusFor('mx'),
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: options.domain,
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: statusFor('spf'),
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${options.domain}`,
|
||||
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
|
||||
status: statusFor('dmarc'),
|
||||
},
|
||||
];
|
||||
|
||||
if (options.dkimValue) {
|
||||
records.splice(2, 0, {
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${options.domain}`,
|
||||
value: options.dkimValue,
|
||||
status: statusFor('dkim'),
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
import { request } from 'http';
|
||||
import { logger } from './email.logging.js';
|
||||
|
||||
export class ApiManager {
|
||||
public emailRef: Email;
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.emailRef.mainTypedRouter.addTypedRouter(this.typedRouter);
|
||||
this.typedRouter.addTypedHandler<plugins.lointEmail.IRequestSendEmail>(
|
||||
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
|
||||
const mailToSend = new plugins.smartmail.Smartmail({
|
||||
body: requestData.body,
|
||||
from: requestData.from,
|
||||
subject: requestData.title,
|
||||
});
|
||||
|
||||
if (requestData.attachments) {
|
||||
for (const attachment of requestData.attachments) {
|
||||
mailToSend.addAttachment(
|
||||
await plugins.smartfile.Smartfile.fromString(
|
||||
attachment.name,
|
||||
attachment.binaryAttachmentString,
|
||||
'binary'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
|
||||
logger.log(
|
||||
'info',
|
||||
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
||||
{
|
||||
eventType: 'sentEmail',
|
||||
email: {
|
||||
to: requestData.to,
|
||||
subject: mailToSend.getSubject(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
responseId: 'abc', // TODO: generate proper response id
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
|
||||
export class MailgunConnector {
|
||||
public emailRef: Email;
|
||||
public mailgunAccount: plugins.mailgun.MailgunAccount;
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
|
||||
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
|
||||
region: 'eu',
|
||||
});
|
||||
this.mailgunAccount.addSmtpCredentials(
|
||||
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
|
||||
);
|
||||
}
|
||||
|
||||
public async sendEmail(
|
||||
smartMailArg: plugins.smartmail.Smartmail<any>,
|
||||
toArg: string,
|
||||
dataArg: any = {}
|
||||
) {
|
||||
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
|
||||
}
|
||||
|
||||
public async receiveEmail(messageUrl: string) {
|
||||
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import * as paths from './email.paths.js';
|
||||
import { MailgunConnector } from './email.classes.connector.mailgun.js';
|
||||
import { RuleManager } from './email.classes.rulemanager.js';
|
||||
import { ApiManager } from './email.classes.apimanager.js';
|
||||
import { logger } from './email.logging.js';
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
|
||||
export class Email {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
|
||||
// typedrouter
|
||||
public mainTypedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// connectors
|
||||
public mailgunConnector: MailgunConnector;
|
||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
|
||||
// server
|
||||
public apiManager = new ApiManager(this);
|
||||
public ruleManager: RuleManager;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.mailgunConnector = new MailgunConnector(this);
|
||||
this.ruleManager = new RuleManager(this);
|
||||
this.platformServiceRef.typedserver.server.addRoute(
|
||||
'/mailgun-notify',
|
||||
new plugins.loleServiceserver.Handler('POST', async (req, res) => {
|
||||
console.log('Got a mailgun email notification');
|
||||
res.status(200);
|
||||
res.end();
|
||||
this.ruleManager.handleNotification(req.body);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.ruleManager.init();
|
||||
logger.log('success', `Started email service`);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
import { logger } from './email.logging.js';
|
||||
|
||||
export class RuleManager {
|
||||
public emailRef: Email;
|
||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
||||
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
|
||||
>();
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
this.emailRef = emailRefArg;
|
||||
}
|
||||
|
||||
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
|
||||
console.log(notification['message-url']);
|
||||
|
||||
// basic checks here
|
||||
// none for now
|
||||
|
||||
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
|
||||
notification['message-url']
|
||||
);
|
||||
console.log('=======================');
|
||||
console.log('Received a mail:');
|
||||
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
|
||||
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
|
||||
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
|
||||
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
|
||||
{
|
||||
eventType: 'receivedEmail',
|
||||
email: {
|
||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
||||
}
|
||||
|
||||
public async init() {
|
||||
// lets forward stuff
|
||||
await this.createForwards();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates the default forwards
|
||||
*/
|
||||
public async createForwards() {
|
||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
|
||||
{
|
||||
originalToAddress: ['bot@mail.nevermind.group'],
|
||||
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
|
||||
},
|
||||
{
|
||||
originalToAddress: ['legal@mail.lossless.com'],
|
||||
forwardedToAddress: ['phil@lossless.com'],
|
||||
},
|
||||
{
|
||||
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
|
||||
forwardedToAddress: ['phil@lossless.com'],
|
||||
},
|
||||
];
|
||||
console.log(`${forwards.length} forward rules configured:`);
|
||||
for (const forward of forwards) {
|
||||
console.log(forward);
|
||||
}
|
||||
|
||||
for (const forward of forwards) {
|
||||
this.smartruleInstance.createRule(
|
||||
10,
|
||||
async (smartmailArg) => {
|
||||
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
|
||||
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
|
||||
}, false);
|
||||
if (matched) {
|
||||
console.log('Forward rule matched');
|
||||
console.log(forward);
|
||||
return 'apply-continue';
|
||||
} else {
|
||||
return 'continue';
|
||||
}
|
||||
},
|
||||
async (smartmailArg: plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>) => {
|
||||
forward.forwardedToAddress.map(async (toArg) => {
|
||||
const forwardedSmartMail = new plugins.smartmail.Smartmail({
|
||||
body:
|
||||
`
|
||||
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
|
||||
<div><b>Original Sender:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.From}</div>
|
||||
<div><b>Original Recipient:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.To}</div>
|
||||
<div><b>Forwarded to:</b></div>
|
||||
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
|
||||
return `${pVal ? pVal + ', ' : ''}${cVal}`;
|
||||
}, null)}</div>
|
||||
<div><b>Subject:</b></div>
|
||||
<div>${smartmailArg.getSubject()}</div>
|
||||
<div><b>The original body can be found below.</b></div>
|
||||
</div>
|
||||
` + smartmailArg.getBody(),
|
||||
from: 'forwarder@mail.lossless.one',
|
||||
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
|
||||
});
|
||||
for (const attachment of smartmailArg.attachments) {
|
||||
forwardedSmartMail.addAttachment(attachment);
|
||||
}
|
||||
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
|
||||
console.log(`forwarded mail to ${toArg}`);
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${
|
||||
smartmailArg.options.creationObjectRef.From
|
||||
} to phil@lossless.com with subject '${smartmailArg.getSubject()}'`,
|
||||
{
|
||||
eventType: 'forwardedEmail',
|
||||
email: {
|
||||
from: smartmailArg.options.creationObjectRef.From,
|
||||
to: smartmailArg.options.creationObjectRef.To,
|
||||
forwardedTo: toArg,
|
||||
subject: smartmailArg.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
|
||||
export class TemplateManager {
|
||||
public smartmailDefault = new plugins.smartmail.Smartmail({
|
||||
body: `
|
||||
|
||||
`,
|
||||
from: `noreply@mail.lossless.com`,
|
||||
subject: `{{subject}}`,
|
||||
});
|
||||
|
||||
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import * as paths from './email.paths.js';
|
||||
|
||||
const projectInfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
|
||||
|
||||
export const logger = plugins.loleLog.createLoleLogger({
|
||||
companyUnit: 'lossless.cloud',
|
||||
containerName: 'email',
|
||||
containerVersion: projectInfoNpm.version,
|
||||
sentryAppName: 'email',
|
||||
sentryDsn: 'https://7037e86f36134ced85ae56a57daa1e5e@o169278.ingest.sentry.io/5280282',
|
||||
zone: 'servezone',
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
@@ -1,43 +0,0 @@
|
||||
// native scope
|
||||
import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// @losslessone_private scope
|
||||
import * as loleLog from '@losslessone_private/lole-log';
|
||||
import * as loleServiceserver from '@losslessone_private/lole-serviceserver';
|
||||
|
||||
import * as lointEmail from '@losslessone_private/loint-email';
|
||||
|
||||
export { loleLog, loleServiceserver, lointEmail };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedrequest from '@apiglobal/typedrequest';
|
||||
|
||||
export { typedrequest };
|
||||
|
||||
// @mojoio scope
|
||||
import * as mailgun from '@mojoio/mailgun';
|
||||
|
||||
export { mailgun };
|
||||
|
||||
// @pushrocks scope
|
||||
import * as projectinfo from '@pushrocks/projectinfo';
|
||||
import * as qenv from '@pushrocks/qenv';
|
||||
import * as smartfile from '@pushrocks/smartfile';
|
||||
import * as smartmail from '@pushrocks/smartmail';
|
||||
import * as smartpath from '@pushrocks/smartpath';
|
||||
import * as smartrequest from '@pushrocks/smartrequest';
|
||||
import * as smartrule from '@pushrocks/smartrule';
|
||||
import * as smartvalidator from '@pushrocks/smartvalidator';
|
||||
|
||||
export {
|
||||
projectinfo,
|
||||
qenv,
|
||||
smartfile,
|
||||
smartmail,
|
||||
smartpath,
|
||||
smartrequest,
|
||||
smartrule,
|
||||
smartvalidator,
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
import { Email } from './email.classes.email.js';
|
||||
|
||||
export { Email };
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
export * from './email-dns-records.js';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user