Compare commits
956 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ceb46b509 | |||
| 0aa1cde5eb | |||
| 584782dcb7 | |||
| 810ecf46f8 | |||
| 6d5d23a691 | |||
| c6617c79f5 | |||
| 135432260d | |||
| b55d2ac61d | |||
| c88e8e1758 | |||
| 6ee716e4ef | |||
| 1d4ed9af2c | |||
| d2331fdcbe | |||
| 0e7765c740 | |||
| 1a381df937 | |||
| 38e2f3cee1 | |||
| 4a47460bf1 | |||
| 3679cba3a4 | |||
| 3dc0371f7e | |||
| b212662764 | |||
| 776c65a18c | |||
| 5f6ec63770 | |||
| 1b4cc0567f | |||
| 22de50b544 | |||
| 2e3bead40c | |||
| 85065b05c8 | |||
| 7f7a26fb38 | |||
| a089b681c4 | |||
| 3e71301bf5 | |||
| 58cc8c0753 | |||
| e279814803 | |||
| 6bee2eb172 | |||
| db8ea99e88 | |||
| 98ccf82af0 | |||
| 0f99525612 | |||
| 8e707d9c4d | |||
| 418c825b01 | |||
| 75f29af27f | |||
| 4467fe629a | |||
| 1912feffe5 | |||
| 9077b3dad6 | |||
| d09ac51c5b | |||
| 9d7975721d | |||
| 667d62b456 | |||
| 90b1ca8de3 | |||
| 17d824d718 | |||
| 06a8636aee | |||
| 4bf08c1fc3 | |||
| 7e721c54d0 | |||
| e6aa5a1dd2 | |||
| bbe18e1413 | |||
| e2a10bdc3c | |||
| 42a5f6df7b | |||
| c61d832b43 | |||
| 872a822ed7 | |||
| 34bfd1528b | |||
| be38808795 | |||
| b9ae4ac344 | |||
| 37adcc9ddc | |||
| ac118397f9 | |||
| 8188b4712c | |||
| 27d077feed | |||
| 98913c1977 | |||
| ca5c57a329 | |||
| 707fbc2413 | |||
| a0c9d40e87 | |||
| 2a73973eda | |||
| f0069f87e2 | |||
| 77c1738390 | |||
| 53d7c5350e | |||
| 7986d01245 | |||
| 0b01a4c26b | |||
| 407c8eef8a | |||
| aa0ef2f033 | |||
| 7819f09625 | |||
| 3f8c0c4219 | |||
| 70fcd46d52 | |||
| 47a1f5d7db | |||
| 67b9fb536c | |||
| 8dd0c3def9 | |||
| d73b250382 | |||
| 1c1d55ab8a | |||
| 2596303c06 | |||
| f78bddaede | |||
| a2887d6266 | |||
| 97505935bb | |||
| 7e3b89d9b4 | |||
| 7bb6559748 | |||
| 5fbe2eb80b | |||
| a22cc1c0eb | |||
| 4ea339b85a | |||
| df9cc3e49b | |||
| 7f3ab2499d | |||
| 89ab918826 | |||
| e5c3578163 | |||
| 1567606c49 | |||
| af31982d58 | |||
| a322308623 | |||
| ec5374900c | |||
| 49ce265d7e | |||
| 63729697c5 | |||
| ce93b726ef | |||
| 1c3aa89f8d | |||
| b3751abd17 | |||
| 97017ede98 | |||
| 4b928b038e | |||
| a466b88408 | |||
| e26ea9e114 | |||
| c5ca95b6f5 | |||
| 1f25ca4095 | |||
| 2891e5d3ee | |||
| 152110c877 | |||
| d780e02928 | |||
| 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 |
@@ -1 +1,7 @@
|
||||
node_modules/
|
||||
.nogit/
|
||||
.git/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
test/
|
||||
test_watch/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Docker (tags)
|
||||
name: Docker (non-tag pushes)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,44 +6,12 @@ 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}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
|
||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
@@ -54,18 +22,14 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
pnpm install -g @git.zone/tsdocker@latest
|
||||
pnpm install
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci npm prepare
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci command npm run build
|
||||
- name: Build image
|
||||
run: tsdocker build
|
||||
|
||||
- name: Test image
|
||||
run: tsdocker test
|
||||
|
||||
@@ -6,75 +6,15 @@ 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}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
|
||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci command npm run build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
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-dbase:szci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -82,25 +22,20 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @git.zone/tsdocker@latest
|
||||
pnpm install
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci docker login
|
||||
npmci docker build
|
||||
npmci docker test
|
||||
# npmci docker push gitea.lossless.digital
|
||||
npmci docker push dockerregistry.lossless.digital
|
||||
- name: Login to registries
|
||||
run: tsdocker login
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
- name: List images
|
||||
run: tsdocker list
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build images
|
||||
run: tsdocker build
|
||||
|
||||
- name: Trigger
|
||||
run: npmci trigger
|
||||
- name: Test images
|
||||
run: tsdocker test
|
||||
|
||||
- name: Push to code.foss.global
|
||||
run: tsdocker push code.foss.global
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: code.foss.global/host.today/ht-docker-node:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Configure pnpm registry
|
||||
run: pnpm config set registry https://verdaccio.lossless.digital/
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Verify package.json version matches tag
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
|
||||
TAG_VERSION="${{ steps.version.outputs.version_number }}"
|
||||
echo "package.json version: $PACKAGE_VERSION"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test package
|
||||
run: pnpm test
|
||||
|
||||
- name: Build binary artifacts
|
||||
run: pnpm run build:binary
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd dist/binaries
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
cd ../..
|
||||
|
||||
- name: Pack npm artifact
|
||||
run: |
|
||||
mkdir -p dist/package
|
||||
pnpm pack --pack-destination dist/package
|
||||
ls -lh dist/package
|
||||
|
||||
- name: Extract changelog for this version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
if [ -f changelog.md ]; then
|
||||
awk "/## $VERSION/,/## /" changelog.md | sed '$d' > /tmp/release_notes.md || true
|
||||
fi
|
||||
if [ ! -s /tmp/release_notes.md ]; then
|
||||
cat > /tmp/release_notes.md << EOF
|
||||
## DcRouter $VERSION
|
||||
|
||||
NodeNext package build plus self-extracting Linux binaries.
|
||||
|
||||
### Artifacts
|
||||
|
||||
- npm package tarball
|
||||
- dcrouter-linux-x64
|
||||
- dcrouter-linux-arm64
|
||||
- SHA256SUMS.txt
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Delete existing release if it exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXISTING_RELEASE_ID=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/tags/$VERSION" \
|
||||
| jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_RELEASE_ID" ]; then
|
||||
curl -X DELETE -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$EXISTING_RELEASE_ID"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
RELEASE_ID=$(curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$VERSION\",
|
||||
\"name\": \"DcRouter $VERSION\",
|
||||
\"body\": $(jq -Rs . /tmp/release_notes.md),
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
for artifact in dist/package/* dist/binaries/*; do
|
||||
[ -f "$artifact" ] || continue
|
||||
filename=$(basename "$artifact")
|
||||
curl -X POST -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$artifact" \
|
||||
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$RELEASE_ID/assets?name=$filename"
|
||||
done
|
||||
|
||||
- name: Release Summary
|
||||
run: |
|
||||
echo "Release ${{ steps.version.outputs.version }} complete"
|
||||
ls -lh dist/package
|
||||
ls -lh dist/binaries
|
||||
+3
-1
@@ -19,4 +19,6 @@ dist_*/
|
||||
|
||||
# custom
|
||||
**/.claude/settings.local.json
|
||||
data/
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"@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/tsdeno": {
|
||||
"compileTargets": [
|
||||
{
|
||||
"name": "dcrouter-linux-x64",
|
||||
"entryPoint": "binary/dcrouter.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "x86_64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true,
|
||||
"selfExtracting": true
|
||||
},
|
||||
{
|
||||
"name": "dcrouter-linux-arm64",
|
||||
"entryPoint": "binary/dcrouter.ts",
|
||||
"outDir": "dist/binaries",
|
||||
"target": "aarch64-unknown-linux-gnu",
|
||||
"permissions": ["--allow-all"],
|
||||
"noCheck": true,
|
||||
"selfExtracting": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"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": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": [
|
||||
"code.foss.global"
|
||||
],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/dcrouter"
|
||||
},
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"@ship.zone/szci": {}
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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.
|
||||
+26
-34
@@ -1,46 +1,38 @@
|
||||
# gitzone dockerfile_service
|
||||
## STAGE 1 // BUILD
|
||||
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
|
||||
COPY ./ /app
|
||||
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||
|
||||
WORKDIR /app
|
||||
ARG NPMCI_TOKEN_NPM2
|
||||
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
|
||||
RUN npmci npm prepare
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm config set registry https://verdaccio.lossless.digital/
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN rm -rf node_modules && pnpm install
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . ./
|
||||
RUN pnpm run build
|
||||
|
||||
# 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
|
||||
RUN pnpm prune --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++
|
||||
|
||||
## 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
|
||||
COPY --from=build /app /app
|
||||
|
||||
## 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
|
||||
ENV DCROUTER_MODE=OCI_CONTAINER
|
||||
ENV NODE_ENV=production
|
||||
ENV DCROUTER_HEAP_SIZE=512
|
||||
ENV UV_THREADPOOL_SIZE=16
|
||||
|
||||
### Healthchecks
|
||||
RUN pnpm install -g @servezone/healthy
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
|
||||
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"
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["npm", "start"]
|
||||
# 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"]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
process.env.CLI_CALL = 'true';
|
||||
|
||||
const cliTool = await import('../dist_ts/index.js');
|
||||
await cliTool.runCli();
|
||||
+2908
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.CLI_CALL = 'true';
|
||||
import * as cliTool from './ts/index.js';
|
||||
cliTool.runCli();
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.43.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
"dist_serve"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.7",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.4.0/server",
|
||||
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
|
||||
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.2",
|
||||
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
|
||||
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
|
||||
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
|
||||
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
|
||||
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.4",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.3.0",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.2.1",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.5",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.5.1",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
}
|
||||
+121
@@ -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>
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DcRouter Installer Script
|
||||
# Installs the self-extracting Linux binary by default, or builds the NodeNext
|
||||
# source package when --source is specified.
|
||||
#
|
||||
# Usage:
|
||||
# Binary install:
|
||||
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
|
||||
#
|
||||
# Source install:
|
||||
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
|
||||
#
|
||||
# Options:
|
||||
# -h, --help Show this help message
|
||||
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
|
||||
# --install-dir DIR Installation directory (default: /opt/dcrouter)
|
||||
# --binary Install release binary (default)
|
||||
# --source Clone the tag and build the NodeNext package locally
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHOW_HELP=0
|
||||
SPECIFIED_VERSION=""
|
||||
INSTALL_DIR="/opt/dcrouter"
|
||||
INSTALL_MODE="binary"
|
||||
GITEA_BASE_URL="https://code.foss.global"
|
||||
GITEA_REPO="serve.zone/dcrouter"
|
||||
SERVICE_NAME="dcrouter"
|
||||
BIN_DIR="/usr/local/bin"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
SHOW_HELP=1
|
||||
shift
|
||||
;;
|
||||
--version)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --version requires a value"
|
||||
exit 1
|
||||
fi
|
||||
SPECIFIED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --install-dir requires a value"
|
||||
exit 1
|
||||
fi
|
||||
INSTALL_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
INSTALL_MODE="binary"
|
||||
shift
|
||||
;;
|
||||
--source)
|
||||
INSTALL_MODE="source"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $SHOW_HELP -eq 1 ]]; then
|
||||
echo "DcRouter Installer Script"
|
||||
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
|
||||
echo ""
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
|
||||
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
|
||||
echo " --binary Install release binary (default)"
|
||||
echo " --source Clone the tag and build the NodeNext package locally"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
|
||||
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$INSTALL_DIR" in
|
||||
""|"/")
|
||||
echo "Error: unsafe install directory: $INSTALL_DIR"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Error: required command not found: $1"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_pnpm() {
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
if command -v corepack >/dev/null 2>&1; then
|
||||
corepack enable
|
||||
fi
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
make_executable_if_present() {
|
||||
if [[ -f "$1" ]]; then
|
||||
chmod 0755 "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
echo "Fetching latest release version from Gitea..." >&2
|
||||
|
||||
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
|
||||
local response
|
||||
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
|
||||
echo "Error: Failed to fetch latest release information from Gitea API" >&2
|
||||
echo "URL: $api_url" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local version
|
||||
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "Error: Could not determine latest version from API response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
detect_binary_name() {
|
||||
local os
|
||||
local arch
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$os" != "Linux" ]]; then
|
||||
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
echo "dcrouter-linux-x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "dcrouter-linux-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "================================================"
|
||||
echo " DcRouter Installation Script"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
require_command curl
|
||||
require_command sed
|
||||
|
||||
if [[ -n "$SPECIFIED_VERSION" ]]; then
|
||||
VERSION="$SPECIFIED_VERSION"
|
||||
echo "Installing specified version: $VERSION"
|
||||
else
|
||||
VERSION=$(get_latest_version)
|
||||
echo "Installing latest version: $VERSION"
|
||||
fi
|
||||
echo "Install mode: $INSTALL_MODE"
|
||||
echo ""
|
||||
|
||||
SOURCE_REF="$VERSION"
|
||||
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
SOURCE_DIR="$TEMP_DIR/source"
|
||||
BACKUP_DIR=""
|
||||
SERVICE_WAS_RUNNING=0
|
||||
SERVICE_STOPPED=0
|
||||
SYSTEMD_AVAILABLE=0
|
||||
|
||||
cleanup_temp() {
|
||||
rm -rf "$TEMP_DIR"
|
||||
}
|
||||
trap cleanup_temp EXIT
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
SYSTEMD_AVAILABLE=1
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
SERVICE_WAS_RUNNING=1
|
||||
fi
|
||||
fi
|
||||
|
||||
restore_previous_installation() {
|
||||
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
|
||||
echo "Restoring previous installation from $BACKUP_DIR..."
|
||||
rm -rf "$INSTALL_DIR" || true
|
||||
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
|
||||
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
|
||||
mkdir -p "$BIN_DIR" || true
|
||||
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
|
||||
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
|
||||
mkdir -p "$BIN_DIR" || true
|
||||
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
restart_previous_service_on_error() {
|
||||
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
|
||||
echo "Installation failed after stopping DcRouter; restarting previous service..."
|
||||
systemctl start "$SERVICE_NAME" || true
|
||||
fi
|
||||
}
|
||||
|
||||
handle_install_error() {
|
||||
trap - ERR
|
||||
restore_previous_installation
|
||||
restart_previous_service_on_error
|
||||
}
|
||||
trap handle_install_error ERR
|
||||
|
||||
stop_service_if_running() {
|
||||
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
echo "Stopping DcRouter service..."
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=1
|
||||
fi
|
||||
}
|
||||
|
||||
move_previous_installation() {
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
if [[ -d "$INSTALL_DIR" ]]; then
|
||||
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
|
||||
echo "Moving previous installation to $BACKUP_DIR"
|
||||
mv "$INSTALL_DIR" "$BACKUP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
install_source_build() {
|
||||
require_command git
|
||||
require_command node
|
||||
ensure_pnpm
|
||||
|
||||
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
|
||||
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
|
||||
|
||||
echo "Building DcRouter..."
|
||||
pnpm --dir "$SOURCE_DIR" run build
|
||||
|
||||
echo "Validating built CLI..."
|
||||
node "$SOURCE_DIR/cli.js" --version >/dev/null
|
||||
|
||||
stop_service_if_running
|
||||
move_previous_installation
|
||||
|
||||
echo "Installing source build to $INSTALL_DIR"
|
||||
mv "$SOURCE_DIR" "$INSTALL_DIR"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.js"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
|
||||
make_executable_if_present "$INSTALL_DIR/cli.child.js"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
|
||||
}
|
||||
|
||||
install_release_binary() {
|
||||
local binary_name
|
||||
local download_url
|
||||
local temp_file
|
||||
|
||||
binary_name=$(detect_binary_name)
|
||||
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
|
||||
temp_file="$TEMP_DIR/$binary_name"
|
||||
|
||||
echo "Downloading DcRouter binary: $download_url"
|
||||
curl -fSL "$download_url" -o "$temp_file"
|
||||
chmod 0755 "$temp_file"
|
||||
|
||||
echo "Validating downloaded binary..."
|
||||
"$temp_file" --version >/dev/null
|
||||
|
||||
stop_service_if_running
|
||||
move_previous_installation
|
||||
|
||||
echo "Installing binary to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
|
||||
}
|
||||
|
||||
if [[ "$INSTALL_MODE" == "source" ]]; then
|
||||
install_source_build
|
||||
else
|
||||
install_release_binary
|
||||
fi
|
||||
|
||||
echo "Symlink created: $BIN_DIR/dcrouter"
|
||||
|
||||
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
|
||||
echo "Error: Installed DcRouter CLI failed validation"
|
||||
restore_previous_installation
|
||||
restart_previous_service_on_error
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
fi
|
||||
|
||||
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
|
||||
echo "Restarting DcRouter service..."
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=0
|
||||
echo "Service restarted successfully."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
|
||||
echo "================================================"
|
||||
echo " DcRouter Installation Complete!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Installation details:"
|
||||
echo " Install directory: $INSTALL_DIR"
|
||||
echo " Symlink location: $BIN_DIR/dcrouter"
|
||||
echo " Version: $VERSION"
|
||||
echo " Mode: $INSTALL_MODE"
|
||||
echo ""
|
||||
echo "Get started:"
|
||||
echo ""
|
||||
echo " dcrouter --version"
|
||||
echo " dcrouter --help"
|
||||
echo ""
|
||||
@@ -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,46 +0,0 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "platformservice",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"npmPackagename": "@serve.zone/platformservice",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone",
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"platform service",
|
||||
"mailgun integration",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||
}
|
||||
}
|
||||
+94
-53
@@ -1,56 +1,82 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"private": true,
|
||||
"version": "2.4.2",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.43.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"dcrouter": "./cli.js"
|
||||
},
|
||||
"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=250 ./cli.js)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
||||
"localPublish": ""
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && pnpm run bundle)",
|
||||
"build:binary": "(pnpm run build && tsdeno compile)",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release:docker": "tsdocker push --verbose",
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tstest": "^1.0.88",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.15.14"
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdeno": "^1.5.0",
|
||||
"@git.zone/tsdocker": "^2.4.2",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.74",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@apiclient.xyz/cloudflare": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartacme": "^7.3.3",
|
||||
"@push.rocks/smartdata": "^5.15.1",
|
||||
"@push.rocks/smartdns": "^6.2.2",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@push.rocks/smartlog": "^3.0.3",
|
||||
"@push.rocks/smartmail": "^2.1.0",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartproxy": "^10.2.0",
|
||||
"@push.rocks/smartrequest": "^2.1.0",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@api.global/typedrequest": "^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.7",
|
||||
"@api.global/typedsocket": "^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.83.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.4.0",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartdb": "^2.10.2",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.2",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.4.1",
|
||||
"@push.rocks/smartmta": "^5.3.3",
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.12.4",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.0",
|
||||
"@serve.zone/interfaces": "^5.0.4",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"ip": "^2.0.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mailauth": "^4.8.4",
|
||||
"mailparser": "^3.6.9",
|
||||
"uuid": "^11.1.0"
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.7",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/remoteingress": "^4.22.5",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
@@ -60,8 +86,7 @@
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"platform service",
|
||||
"mailgun integration",
|
||||
"mail router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
@@ -72,14 +97,30 @@
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
"DNS management",
|
||||
"RADIUS",
|
||||
"AAA",
|
||||
"network authentication",
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"binary/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_apiclient/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"dist_ts_apiclient/**/*",
|
||||
"cli.js",
|
||||
"cli.ts.js",
|
||||
"cli.child.js",
|
||||
"cli.child.ts",
|
||||
"deno.json",
|
||||
"tsconfig.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+5310
-6213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
puppeteer: true
|
||||
+773
@@ -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
|
||||
@@ -1,200 +1,415 @@
|
||||
# @serve.zone/platformservice
|
||||
# @serve.zone/dcrouter
|
||||
|
||||
contains the platformservice container with mail, sms, letter, ai services.
|
||||
`dcrouter` is the serve.zone datacenter gateway runtime: a TypeScript control plane that brings HTTP/HTTPS/TCP routing, email ingress, authoritative DNS, RADIUS, VPN access control, remote ingress tunnels, certificate operations, metrics, and an Ops dashboard into one process.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
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 It Exists
|
||||
|
||||
Modern infrastructure often has too many tiny edge tools: a proxy here, a DNS daemon there, a separate cert worker, another dashboard, and a tunnel process bolted on later. `dcrouter` is designed as a cohesive gateway layer for operators who want one audited place to define public routes, domains, edge tunnels, access policy, and operational state.
|
||||
|
||||
Highlights:
|
||||
|
||||
- 🌐 SmartProxy-backed HTTP, HTTPS, TCP, TLS/SNI, and optional HTTP/3 route handling
|
||||
- 📬 SmartMTA-backed SMTP ingress and email-domain operations
|
||||
- 🧭 SmartDNS-backed authoritative DNS plus generated DNS-over-HTTPS routes
|
||||
- 🔐 ACME, certificate state, API tokens, users, source profiles, target profiles, and security policies
|
||||
- 🛡️ RADIUS, VLAN assignment, VPN-protected routes, and remote ingress firewall snapshots
|
||||
- 🖥️ Browser Ops dashboard and TypedRequest API served by the built-in OpsServer
|
||||
|
||||
## Runtime Areas
|
||||
|
||||
| Area | What dcrouter manages |
|
||||
| --- | --- |
|
||||
| Proxying | SmartProxy routes for HTTP, HTTPS, TCP, SNI, TLS termination, passthrough, and backend forwarding |
|
||||
| Route ownership | Constructor routes, generated email/DNS routes, and API-created routes with explicit origins |
|
||||
| DNS | Authoritative scopes, generated NS records, static DNS records, provider-backed domains, and DoH endpoints |
|
||||
| Email | UnifiedEmailServer startup, email-domain management, route-backed delivery actions, received mail operations |
|
||||
| Certificates | ACME config, stored certificate metadata, provisioning backoff, and certificate status reporting |
|
||||
| Edge access | Remote ingress hub, edge registrations, derived edge ports, pushed firewall rules, VPN-only route access |
|
||||
| Network auth | RADIUS clients, MAC Authentication Bypass, VLAN mapping, and accounting sessions |
|
||||
| Operations | Dashboard views, TypedRequest handlers, metrics, logs, health, API tokens, users, and configuration views |
|
||||
|
||||
## Install
|
||||
|
||||
To install `@serve.zone/platformservice`, run the following command:
|
||||
Install the CLI/runtime on a Linux gateway host with the released self-extracting binary:
|
||||
|
||||
```sh
|
||||
npm install @serve.zone/platformservice --save
|
||||
```bash
|
||||
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Make sure you have Node.js and npm installed on your system to use this package.
|
||||
The installer downloads `dcrouter-linux-x64` or `dcrouter-linux-arm64` from the latest Gitea release, installs it under `/opt/dcrouter`, and links `/usr/local/bin/dcrouter`. Use `--version vX.Y.Z` to pin a release, `--install-dir /path` to change the target directory, or `--source` to clone the tag and build the NodeNext package locally.
|
||||
|
||||
## Usage
|
||||
|
||||
This document provides extensive usage scenarios for the `@serve.zone/platformservice`, a comprehensive ESM module written in TypeScript offering a wide range of services such as mail, SMS, letter, and artificial intelligence (AI) functionalities. This service is an exemplar of a modular design, allowing users to leverage various communication methods and AI services efficiently. Key features provided by this platform include sending and receiving emails, managing SMS services, letter dispatching, and utilizing AI for diverse purposes.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before diving into the examples, ensure you have the platform service installed and configured correctly. The package leverages environment variables for configuration, so you must set up the necessary variables, including service endpoints, authentication tokens, and database connections.
|
||||
|
||||
### Initialization
|
||||
|
||||
First, initialize the platform service, ensuring all dependencies are correctly loaded and configured:
|
||||
|
||||
```ts
|
||||
import { SzPlatformService } from '@serve.zone/platformservice';
|
||||
|
||||
async function initService() {
|
||||
const platformService = new SzPlatformService();
|
||||
await platformService.start();
|
||||
console.log('Platform service initialized successfully.');
|
||||
}
|
||||
|
||||
initService();
|
||||
```bash
|
||||
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
|
||||
```
|
||||
|
||||
### Sending Emails
|
||||
Use the package as a TypeScript library:
|
||||
|
||||
One of the primary services offered is email management. Here's how to send an email using the platform service:
|
||||
|
||||
```ts
|
||||
import { EmailService, IEmailOptions } from '@serve.zone/platformservice';
|
||||
|
||||
async function sendEmail() {
|
||||
const emailOptions: IEmailOptions = {
|
||||
from: 'no-reply@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Email',
|
||||
body: '<h1>This is a test email</h1>',
|
||||
};
|
||||
|
||||
const emailService = new EmailService('MAILGUN_API_KEY'); // Replace with your real API key
|
||||
await emailService.sendEmail(emailOptions);
|
||||
|
||||
console.log('Email sent successfully.');
|
||||
}
|
||||
|
||||
sendEmail();
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
### Managing SMS
|
||||
## Quick Start
|
||||
|
||||
Similar to email, the platform also facilitates SMS sending:
|
||||
This starts the gateway on unprivileged ports and stores data under the default `~/.serve.zone/dcrouter` base directory.
|
||||
|
||||
```ts
|
||||
import { SmsService, ISmsConstructorOptions } from '@serve.zone/platformservice';
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
async function sendSms() {
|
||||
const smsOptions: ISmsConstructorOptions = {
|
||||
apiGatewayApiToken: 'SMS_API_TOKEN', // Replace with your real token
|
||||
};
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'local-app',
|
||||
match: {
|
||||
domains: ['localhost'],
|
||||
ports: [18080],
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 3001 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
dbConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
const smsService = new SmsService(smsOptions);
|
||||
await smsService.sendSms(1234567890, 'SENDER_NAME', 'This is a test SMS.');
|
||||
|
||||
console.log('SMS sent successfully.');
|
||||
}
|
||||
|
||||
sendSms();
|
||||
await router.start();
|
||||
```
|
||||
|
||||
### Dispatching Letters
|
||||
After startup:
|
||||
|
||||
For physical mail correspondence, the platform provides a letter service:
|
||||
- open the dashboard at `http://localhost:3000`
|
||||
- complete the first-admin bootstrap flow if no persisted admin account exists yet
|
||||
- send proxied traffic to `http://localhost:18080`
|
||||
- stop gracefully with `await router.stop()`
|
||||
|
||||
```ts
|
||||
import { LetterService, ILetterConstructorOptions } from '@serve.zone/platformservice';
|
||||
## Initial Admin Bootstrap
|
||||
|
||||
async function sendLetter() {
|
||||
const letterOptions: ILetterConstructorOptions = {
|
||||
letterxpressUser: 'USER',
|
||||
letterxpressToken: 'TOKEN',
|
||||
};
|
||||
When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
|
||||
|
||||
const letterService = new LetterService(letterOptions);
|
||||
await letterService.sendLetter('This is a test letter body.', {address: 'Recipient Address', name: 'Recipient Name'});
|
||||
|
||||
console.log('Letter dispatched successfully.');
|
||||
}
|
||||
Bootstrap behavior:
|
||||
|
||||
sendLetter();
|
||||
- `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
|
||||
- The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
|
||||
- `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
|
||||
- Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
|
||||
- After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
`DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `baseDir` | Root directory for dcrouter runtime data. Defaults to `~/.serve.zone/dcrouter`. |
|
||||
| `smartProxyConfig` | Main SmartProxy route configuration for HTTP/HTTPS/TCP/SNI traffic. |
|
||||
| `emailConfig` | UnifiedEmailServer configuration: hostname, ports, domains, and mail routes. |
|
||||
| `emailPortConfig` | External-to-internal email port mapping and received-email storage path. |
|
||||
| `tls` | Legacy/static TLS and ACME contact settings used to seed certificate config. |
|
||||
| `dnsNsDomains` | Nameserver hostnames used for generated NS records and DoH routes. |
|
||||
| `dnsScopes` | Authoritative domains served by the embedded DNS server. |
|
||||
| `dnsRecords` | Constructor-defined DNS records. |
|
||||
| `publicIp` / `proxyIps` | IPs used for generated A records and proxy-aware DNS exposure. |
|
||||
| `dbConfig` | Smartdata persistence via embedded LocalSmartDb or external MongoDB. |
|
||||
| `radiusConfig` | RADIUS authentication, accounting, and VLAN assignment. |
|
||||
| `remoteIngressConfig` | Remote ingress hub configuration for edge tunnel nodes. |
|
||||
| `vpnConfig` | VPN server/client definitions and VPN-only routing behavior. |
|
||||
| `http3` | HTTP/3 augmentation settings for qualifying HTTPS routes. |
|
||||
| `opsServerPort` | Port for the Ops dashboard and `/typedrequest` API. Defaults to `3000`. |
|
||||
|
||||
Important runtime behavior:
|
||||
|
||||
- `dbConfig.enabled` defaults to enabled. Without `mongoDbUrl`, dcrouter uses embedded LocalSmartDb.
|
||||
- If the DB is disabled, constructor-defined proxy traffic can still run, but persistent API routes, tokens, managed domains, and stored certificate state are unavailable.
|
||||
- Qualifying HTTPS forward routes on port `443` are HTTP/3-augmented unless `http3.enabled === false` or the route opts out.
|
||||
- DNS-over-HTTPS routes are generated on the first `dnsNsDomains` entry at `/dns-query` and `/resolve`.
|
||||
- Email listener ports can be remapped internally, for example public `25`, `587`, and `465` to unprivileged internal ports.
|
||||
|
||||
## Route Ownership
|
||||
|
||||
dcrouter keeps generated and operator-created routes separate so automation can reconcile safely.
|
||||
|
||||
| Origin | Source | Mutability |
|
||||
| --- | --- | --- |
|
||||
| `config` | Constructor `smartProxyConfig.routes` and seed data | Toggle only |
|
||||
| `email` | Email listener and email-domain generated routes | Toggle only |
|
||||
| `dns` | Generated DNS-over-HTTPS and DNS-related routes | Toggle only |
|
||||
| `api` | Ops UI or API client | Full CRUD |
|
||||
|
||||
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
|
||||
|
||||
## Route Source Policies
|
||||
|
||||
API-created route records pass `metadata.sourcePolicy` alongside the SmartProxy route config to express ordered source and path policy variants without duplicating whole routes by hand. A source policy contains ordered `bindings`, each pointing at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
|
||||
|
||||
Runtime behavior:
|
||||
|
||||
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
|
||||
- Bindings are evaluated in order and the first matching source profile wins.
|
||||
- A matched binding that exceeds its configured rate or connection limit is terminal and returns `429`; dcrouter does not fall through to later bindings.
|
||||
- Source-policy rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-policy binding and path-policy overrides.
|
||||
- A public fallback binding must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source policies with missing source profiles, source profiles without source matches, missing final all-source fallback, or any all-source binding that shadows later bindings; persisted invalid policies fail closed at compile time.
|
||||
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, and 512 compiled SmartProxy route-port variants per stored route.
|
||||
|
||||
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
|
||||
|
||||
```typescript
|
||||
const trustedProfileId = 'source-profile-id-trusted';
|
||||
const publicProfileId = 'source-profile-id-public';
|
||||
|
||||
const createRoutePayload = {
|
||||
route: {
|
||||
name: 'public-gitea',
|
||||
match: { domains: ['code.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.10.0.20', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: trustedProfileId,
|
||||
maxConnections: 5000,
|
||||
onExceeded: { type: '429' },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicProfileId,
|
||||
onExceeded: { type: '429' },
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'static',
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'raw',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'archive',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'expensive-html',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Mail Transfer Agent (MTA)
|
||||
## Production-Flavored Example
|
||||
|
||||
The platform includes a robust Mail Transfer Agent (MTA) for enterprise-grade email handling with complete control over the email delivery process:
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
API[API Clients] --> ApiManager
|
||||
SMTP[External SMTP Servers] <--> SMTPServer
|
||||
|
||||
subgraph "MTA Service"
|
||||
MtaService[MTA Service] --> SMTPServer[SMTP Server]
|
||||
MtaService --> EmailSendJob[Email Send Job]
|
||||
MtaService --> DnsManager[DNS Manager]
|
||||
MtaService --> DkimCreator[DKIM Creator]
|
||||
ApiManager[API Manager] --> MtaService
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
DnsManager <--> DNS[DNS Servers]
|
||||
EmailSendJob <--> MXServers[MX Servers]
|
||||
end
|
||||
const router = new DcRouter({
|
||||
baseDir: '/var/lib/dcrouter',
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.10.0.21', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'internal-admin',
|
||||
match: { domains: ['admin.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.10.0.30', port: 9000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
vpnOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [{ domain: 'example.com', dnsMode: 'internal-dns' }],
|
||||
routes: [
|
||||
{
|
||||
name: 'inbound-example',
|
||||
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',
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
vpnConfig: {
|
||||
enabled: true,
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
clients: [{ clientId: 'ops-laptop', description: 'Operations laptop' }],
|
||||
},
|
||||
opsServerPort: 3000,
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
The MTA service provides:
|
||||
- Complete SMTP server for receiving emails
|
||||
- DKIM signing and verification
|
||||
- SPF and DMARC support
|
||||
- DNS record management
|
||||
- Retry logic with queue processing
|
||||
- TLS encryption
|
||||
## VPN Target Profiles
|
||||
|
||||
Here's how to use the MTA service:
|
||||
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.
|
||||
|
||||
```ts
|
||||
import { MtaService, Email } from '@serve.zone/platformservice';
|
||||
dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route `security.ipAllowList` and `security.ipBlockList` stay the source of truth for real source-IP policy; `vpnOnly` adds the requirement for authenticated VPN metadata and a matching VPN client grant.
|
||||
|
||||
async function useMtaService() {
|
||||
// Initialize MTA service
|
||||
const mtaService = new MtaService(platformService);
|
||||
await mtaService.start();
|
||||
|
||||
// Send an email
|
||||
const email = new Email({
|
||||
from: 'sender@yourdomain.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Hello World',
|
||||
text: 'This is a test email',
|
||||
html: '<p>This is a <b>test</b> email</p>',
|
||||
attachments: [] // Optional attachments
|
||||
});
|
||||
|
||||
const emailId = await mtaService.send(email);
|
||||
console.log(`Email queued with ID: ${emailId}`);
|
||||
|
||||
// Check email status
|
||||
const status = mtaService.getEmailStatus(emailId);
|
||||
console.log(`Email status: ${status.status}`);
|
||||
|
||||
// Set up API for external access
|
||||
const apiManager = new ApiManager(mtaService);
|
||||
await apiManager.start(3000);
|
||||
console.log('MTA API running on port 3000');
|
||||
}
|
||||
|
||||
useMtaService();
|
||||
```typescript
|
||||
const targetProfile = {
|
||||
name: 'ops laptop source access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
};
|
||||
```
|
||||
|
||||
The MTA provides key advantages for applications requiring:
|
||||
- High-volume email sending
|
||||
- Compliance with email authentication standards
|
||||
- Detailed delivery tracking
|
||||
- Custom email handling logic
|
||||
- Multi-domain email management
|
||||
- Complete control over email infrastructure
|
||||
## Automation API
|
||||
|
||||
### Leveraging AI Services
|
||||
The OpsServer exposes TypedRequest handlers at `/typedrequest`. You can use raw contracts or the object-oriented API client.
|
||||
|
||||
The platform also integrates AI functionalities, allowing for innovative use cases like generating content, analyzing text, or automating responses:
|
||||
|
||||
```ts
|
||||
import { AiService } from '@serve.zone/platformservice';
|
||||
|
||||
async function useAiService() {
|
||||
const aiService = new AiService('OPENAI_API_KEY'); // Replace with your real API key
|
||||
const response = await aiService.generateText('Prompt for the AI service.');
|
||||
|
||||
console.log(`AI response: ${response}`);
|
||||
}
|
||||
|
||||
useAiService();
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter-apiclient
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
```typescript
|
||||
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
|
||||
|
||||
The `@serve.zone/platformservice` offers a robust set of features for modern application requirements, including but not limited to communication and AI services. By following the examples above, developers can integrate these services into their applications, harnessing the power of email, SMS, letters, MTA capabilities, and artificial intelligence seamlessly.
|
||||
const client = new DcRouterApiClient({
|
||||
baseUrl: 'https://dcrouter.example.com',
|
||||
});
|
||||
|
||||
await client.login('admin@example.com', 'strong-password');
|
||||
|
||||
const route = 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();
|
||||
|
||||
await route.toggle(true);
|
||||
```
|
||||
|
||||
Use `@serve.zone/dcrouter/interfaces` or `@serve.zone/dcrouter-interfaces` when you want the raw TypedRequest contracts instead of resource managers.
|
||||
|
||||
## OCI / Container Bootstrap
|
||||
|
||||
`runCli()` supports an environment-driven container mode when `DCROUTER_MODE=OCI_CONTAINER`.
|
||||
|
||||
```typescript
|
||||
import { runCli } from '@serve.zone/dcrouter';
|
||||
|
||||
await runCli();
|
||||
```
|
||||
|
||||
Supported environment overrides include:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `DCROUTER_CONFIG_PATH` | JSON file loaded as the base `IDcRouterOptions` object. |
|
||||
| `DCROUTER_BASE_DIR` | Runtime data root. |
|
||||
| `DCROUTER_TLS_EMAIL` / `DCROUTER_TLS_DOMAIN` | TLS/ACME seed settings. |
|
||||
| `DCROUTER_PUBLIC_IP` / `DCROUTER_PROXY_IPS` | Public/proxy IP exposure settings. |
|
||||
| `DCROUTER_DNS_NS_DOMAINS` / `DCROUTER_DNS_SCOPES` | DNS nameserver and authoritative scope settings. |
|
||||
| `DCROUTER_EMAIL_HOSTNAME` / `DCROUTER_EMAIL_PORTS` | Email server seed settings. |
|
||||
| `DCROUTER_CACHE_ENABLED` | Enables or disables DB-backed persistence. |
|
||||
| `DCROUTER_MAX_CONNECTIONS`, `DCROUTER_MAX_CONNECTIONS_PER_IP`, `DCROUTER_CONNECTION_RATE_LIMIT` | SmartProxy capacity and rate-limit overrides. |
|
||||
|
||||
## Docker Image
|
||||
|
||||
Release builds publish a multi-arch OCI image at `code.foss.global/serve.zone/dcrouter:latest` for `linux/amd64` and `linux/arm64`. The image sets `DCROUTER_MODE=OCI_CONTAINER` and starts `node ./cli.js`.
|
||||
|
||||
```bash
|
||||
docker run --rm --name dcrouter \
|
||||
--network host \
|
||||
-v dcrouter-data:/data \
|
||||
-e DCROUTER_BASE_DIR=/data \
|
||||
-e DCROUTER_TLS_EMAIL=ops@example.com \
|
||||
code.foss.global/serve.zone/dcrouter:latest
|
||||
```
|
||||
|
||||
Host networking is the simplest container mode for a gateway that owns HTTP/S, SMTP, DNS, RADIUS, remote ingress, and dynamic proxy ports. For narrower deployments, publish only the ports you enable in `IDcRouterOptions` or via the `DCROUTER_*` environment overrides.
|
||||
|
||||
## Published Modules
|
||||
|
||||
This repository intentionally publishes multiple module boundaries from one codebase.
|
||||
|
||||
| Module | Purpose | Docs |
|
||||
| --- | --- | --- |
|
||||
| `@serve.zone/dcrouter` | Main runtime and orchestrator | `./readme.md` |
|
||||
| `@serve.zone/dcrouter/interfaces` | Shared contracts as a subpath export | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter/apiclient` | API client as a subpath export | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-interfaces` | Standalone contracts package | `./ts_interfaces/readme.md` |
|
||||
| `@serve.zone/dcrouter-apiclient` | Standalone OO API client package | `./ts_apiclient/readme.md` |
|
||||
| `@serve.zone/dcrouter-migrations` | Standalone migration runner package | `./ts_migrations/readme.md` |
|
||||
| `@serve.zone/dcrouter-web` | Dashboard frontend module boundary | `./ts_web/readme.md` |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm test
|
||||
pnpm run watch
|
||||
```
|
||||
|
||||
Useful source entry points:
|
||||
|
||||
- `ts/index.ts` exports `DcRouter`, `runCli()`, and public module surfaces.
|
||||
- `ts/classes.dcrouter.ts` owns service startup, dependency ordering, and `IDcRouterOptions`.
|
||||
- `ts/opsserver/classes.opsserver.ts` wires the dashboard server and TypedRequest handlers.
|
||||
- `ts/remoteingress/` integrates `@serve.zone/remoteingress` with stored edge registrations.
|
||||
- `ts_migrations/index.ts` contains all DB schema migration steps.
|
||||
|
||||
## 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.
|
||||
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
# Plan for Further Enhancing the Email Stack
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The platformservice now has a robust email system with:
|
||||
- Enhanced EmailValidator with comprehensive validation (format, MX, spam detection)
|
||||
- Improved TemplateManager with typed templates and variable substitution
|
||||
- Streamlined conversion between Email and Smartmail formats
|
||||
- Strong attachment handling
|
||||
- Comprehensive testing
|
||||
|
||||
## Identified Enhancement Opportunities
|
||||
|
||||
### 1. Performance Optimization
|
||||
|
||||
- [x] Replace setTimeout-based DNS cache with proper LRU cache implementation
|
||||
- [x] Implement rate limiting for outbound emails
|
||||
- [ ] Add bulk email handling with batching capabilities
|
||||
- [ ] Optimize template rendering for high-volume scenarios
|
||||
|
||||
### 2. Security Enhancements
|
||||
|
||||
- [x] Implement DMARC policy checking and enforcement
|
||||
- [x] Add SPF validation for incoming emails
|
||||
- [x] Enhance logging for security-related events
|
||||
- [x] Add IP reputation checking for inbound emails
|
||||
- [x] Implement content scanning for potentially malicious payloads
|
||||
|
||||
### 3. Deliverability Improvements
|
||||
|
||||
- [x] Implement bounce handling and feedback loop processing
|
||||
- [x] Add automated IP warmup capabilities
|
||||
- [x] Develop sender reputation monitoring
|
||||
- [ ] Create domain rotation for high-volume sending
|
||||
|
||||
### 4. Advanced Templating
|
||||
|
||||
- [ ] Add conditional logic in email templates
|
||||
- [ ] Support localization with i18n integration
|
||||
- [ ] Implement template versioning and A/B testing capabilities
|
||||
- [ ] Add rich media handling (responsive images, video thumbnails)
|
||||
|
||||
### 5. Analytics and Monitoring
|
||||
|
||||
- [ ] Implement delivery tracking and reporting
|
||||
- [ ] Add open and click tracking
|
||||
- [ ] Create dashboards for email performance
|
||||
- [ ] Set up alerts for delivery issues
|
||||
- [ ] Add spam complaint monitoring
|
||||
|
||||
### 6. Integration Enhancements
|
||||
|
||||
- [ ] Add webhook support for email events
|
||||
- [ ] Implement integration with popular ESPs as fallback providers
|
||||
- [ ] Add support for calendar invites and structured data
|
||||
- [ ] Create API for managing suppression lists
|
||||
|
||||
### 7. Testing and QA
|
||||
|
||||
- [ ] Implement email rendering tests across email clients
|
||||
- [ ] Add load testing for high-volume scenarios
|
||||
- [ ] Create end-to-end testing of complete email journeys
|
||||
- [ ] Add spam testing and deliverability scoring
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### Completed Enhancements
|
||||
|
||||
1. **Performance Optimization**
|
||||
- Replaced setTimeout-based DNS cache with LRU cache for more efficient and reliable caching
|
||||
- Implemented advanced rate limiting with token bucket algorithm for outbound emails
|
||||
|
||||
2. **Security Enhancements**
|
||||
- Added comprehensive security logging system for email-related security events
|
||||
- Created a centralized SecurityLogger with event categorization and filtering
|
||||
- Implemented DMARC policy checking and enforcement for improved email authentication
|
||||
- Added SPF validation for incoming emails with proper record parsing and verification
|
||||
- Implemented IP reputation checking for inbound emails with DNSBL integration
|
||||
- Added detection for suspicious IPs (proxies, VPNs, Tor exit nodes)
|
||||
- Implemented configurable throttling/rejection for low-reputation IPs
|
||||
- Implemented content scanning for malicious payloads with pattern matching
|
||||
- Added detection for phishing, spam, malware indicators, executable attachments
|
||||
- Created quarantine capabilities for suspicious emails with configurable thresholds
|
||||
- Implemented macro detection in Office document attachments
|
||||
|
||||
3. **Deliverability Improvements**
|
||||
- Implemented bounce handling with detection and categorization of different bounce types
|
||||
- Created suppression list management to prevent sending to known bad addresses
|
||||
- Added exponential backoff retry strategy for soft bounces
|
||||
- Implemented automated IP warmup capabilities:
|
||||
- Created configurable warmup stages with progressive volume increases
|
||||
- Added multiple allocation policies (balanced, round robin, dedicated domain)
|
||||
- Implemented daily and hourly sending limits with tracking
|
||||
- Added persistence for warmup state between service restarts
|
||||
- Developed comprehensive sender reputation monitoring:
|
||||
- Implemented tracking of key deliverability metrics (bounces, complaints, opens, etc.)
|
||||
- Added reputation scoring with multiple weighted components
|
||||
- Created blacklist monitoring integration
|
||||
- Implemented trend analysis for early detection of reputation issues
|
||||
- Added full event tracking for sent, delivered, bounced, and complaint events
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Continue with security enhancements:
|
||||
- ✅ Added IP reputation checking for inbound emails with DNS blacklist integration and caching
|
||||
- ✅ Implemented content scanning for potentially malicious payloads with pattern matching and threat scoring
|
||||
|
||||
2. Further deliverability improvements:
|
||||
- ✅ Added automated IP warmup capabilities with configurable stages and allocation policies
|
||||
- ✅ Developed sender reputation monitoring with bounce tracking and metric calculation
|
||||
|
||||
3. Implement analytics and monitoring to gain visibility into performance
|
||||
|
||||
Each enhancement is being implemented incrementally with comprehensive testing to ensure reliability and backward compatibility, while maintaining the clean separation of concerns established in the codebase.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Improved deliverability rates (95%+ inbox placement)
|
||||
- Enhanced security with no vulnerabilities
|
||||
- Support for high volume sending (10,000+ emails per hour)
|
||||
- Rich analytics providing actionable insights
|
||||
- High template flexibility for marketing and transactional emails
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { OpsServer } from '../ts/opsserver/index.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const testPort = 3110;
|
||||
const baseUrl = `http://localhost:${testPort}/typedrequest`;
|
||||
const bootstrapPassword = 'temporary-bootstrap-password';
|
||||
const persistedPassword = 'persisted-admin-password';
|
||||
|
||||
let previousAdminPassword: string | undefined;
|
||||
let opsServer: OpsServer;
|
||||
let testDb: DcRouterDb;
|
||||
let storagePath: string;
|
||||
let dbName: string;
|
||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||
let persistedIdentity: interfaces.data.IIdentity;
|
||||
let createdUserId: string;
|
||||
|
||||
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
baseUrl,
|
||||
'getAdminBootstrapStatus',
|
||||
);
|
||||
|
||||
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
baseUrl,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
|
||||
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
|
||||
options: {
|
||||
opsServerPort: portArg,
|
||||
dbConfig: { enabled: true },
|
||||
adminAuth: {
|
||||
idpClient: {
|
||||
loginWithEmailAndPassword: async () => ({
|
||||
jwt: 'idp-jwt',
|
||||
refreshToken: 'idp-refresh-token',
|
||||
user: {
|
||||
id: 'idp-user-1',
|
||||
data: {
|
||||
name: 'Wrong IdP User',
|
||||
username: 'wrong@example.com',
|
||||
email: 'wrong@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stop: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||
dcRouterDb: dcRouterDbArg,
|
||||
});
|
||||
|
||||
const restartOpsServer = async () => {
|
||||
await opsServer.stop();
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
};
|
||||
|
||||
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
||||
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
|
||||
|
||||
storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
testDb = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName,
|
||||
});
|
||||
await testDb.start();
|
||||
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||
await opsServer.start();
|
||||
});
|
||||
|
||||
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(true);
|
||||
expect(status.hasPersistentAdmin).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(true);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||
expect(status.idpGlobalConfigured).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected bootstrap login identity');
|
||||
}
|
||||
bootstrapIdentity = response.identity;
|
||||
expect(bootstrapIdentity.role).toEqual('admin');
|
||||
});
|
||||
|
||||
tap.test('creates the initial persisted admin explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
baseUrl,
|
||||
'createInitialAdminUser',
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
identity: bootstrapIdentity,
|
||||
email: 'Admin@Example.com',
|
||||
name: 'Persisted Admin',
|
||||
password: persistedPassword,
|
||||
enableIdpGlobalAuth: true,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('admin');
|
||||
expect(response.user?.authSources).toContain('local');
|
||||
expect(response.user?.authSources).toContain('idp.global');
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin identity');
|
||||
}
|
||||
persistedIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('disables bootstrap mode after persisted admin exists', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.hasPersistentAdmin).toEqual(true);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const response = await request.fire({ identity: bootstrapIdentity });
|
||||
|
||||
expect(response.valid).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('authenticates the persisted admin locally by normalized email', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin login identity');
|
||||
}
|
||||
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
||||
});
|
||||
|
||||
tap.test('persists users across OpsServer restart', async () => {
|
||||
const oldPersistedIdentity = persistedIdentity;
|
||||
await restartOpsServer();
|
||||
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
|
||||
expect(verifyResponse.valid).toEqual(false);
|
||||
|
||||
const loginResponse = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!loginResponse.identity) {
|
||||
throw new Error('Expected persisted admin login identity after restart');
|
||||
}
|
||||
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
|
||||
persistedIdentity = loginResponse.identity;
|
||||
});
|
||||
|
||||
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: 'idp-password',
|
||||
authSource: 'idp.global',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('creates a persisted non-admin user explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateUser>(baseUrl, 'createUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
email: 'operator@example.com',
|
||||
name: 'Operator User',
|
||||
role: 'user',
|
||||
password: 'operator-password',
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('user');
|
||||
expect(response.user?.email).toEqual('operator@example.com');
|
||||
if (!response.user?.id) {
|
||||
throw new Error('Expected created user id');
|
||||
}
|
||||
createdUserId = response.user.id;
|
||||
});
|
||||
|
||||
tap.test('rejects deleting the current persisted admin user', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
id: persistedIdentity.userId,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('deletes a persisted non-current user', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_DeleteUser>(baseUrl, 'deleteUser');
|
||||
const response = await request.fire({
|
||||
identity: persistedIdentity,
|
||||
id: createdUserId,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('lists persisted users without password material', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||
const response = await request.fire({ identity: persistedIdentity });
|
||||
|
||||
expect(response.users.length).toEqual(1);
|
||||
expect(response.users[0].email).toEqual('Admin@Example.com');
|
||||
expect((response.users[0] as any).password).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
|
||||
await testDb.stop();
|
||||
|
||||
const status = await createStatusRequest().fire({});
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||
await opsServer.stop();
|
||||
await testDb.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
|
||||
if (previousAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
|
||||
const unavailablePort = 3111;
|
||||
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
|
||||
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
|
||||
DcRouterDb.resetInstance();
|
||||
|
||||
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
|
||||
try {
|
||||
await unavailableOpsServer.start();
|
||||
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
unavailableBaseUrl,
|
||||
'getAdminBootstrapStatus',
|
||||
).fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
|
||||
let rejected = false;
|
||||
try {
|
||||
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
unavailableBaseUrl,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
).fire({
|
||||
username: 'admin',
|
||||
password: 'unavailable-bootstrap-password',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
} finally {
|
||||
await unavailableOpsServer.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
if (previousUnavailableAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,75 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ApiTokenManager } from '../ts/config/classes.api-token-manager.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-api-token-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-api-token-manager-${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 });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('ApiTokenManager seeds and rotates an env admin API token', async () => {
|
||||
const previousToken = process.env.DCROUTER_ADMIN_API_TOKEN;
|
||||
const previousName = process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
|
||||
const testDb = await createTestDb();
|
||||
|
||||
try {
|
||||
const rawToken1 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||
const rawToken2 = `dcr_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken1;
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = 'Onebox Managed Admin';
|
||||
|
||||
const manager = new ApiTokenManager();
|
||||
await manager.initialize();
|
||||
|
||||
const token1 = await manager.validateToken(rawToken1);
|
||||
expect(token1?.id).toEqual('env-admin-token');
|
||||
expect(token1?.name).toEqual('Onebox Managed Admin');
|
||||
expect(token1?.policy?.role).toEqual('admin');
|
||||
expect(manager.hasScope(token1!, 'tokens:manage')).toEqual(true);
|
||||
|
||||
const listedToken = manager.listTokens().find((token) => token.id === 'env-admin-token') as any;
|
||||
expect(listedToken.tokenHash).toBeUndefined();
|
||||
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = rawToken2;
|
||||
const rotatedManager = new ApiTokenManager();
|
||||
await rotatedManager.initialize();
|
||||
|
||||
expect(await rotatedManager.validateToken(rawToken1)).toBeNull();
|
||||
const token2 = await rotatedManager.validateToken(rawToken2);
|
||||
expect(token2?.id).toEqual('env-admin-token');
|
||||
expect(token2?.policy?.role).toEqual('admin');
|
||||
} finally {
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_API_TOKEN;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN = previousToken;
|
||||
}
|
||||
if (previousName === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_API_TOKEN_NAME;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_API_TOKEN_NAME = previousName;
|
||||
}
|
||||
await testDb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -1,65 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async () => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
expect(reputationData).toBeTruthy();
|
||||
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,197 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { BounceManager, BounceType, BounceCategory } from '../ts/email/classes.bouncemanager.js';
|
||||
|
||||
/**
|
||||
* Test the BounceManager class
|
||||
*/
|
||||
tap.test('BounceManager - should be instantiable', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
expect(bounceManager).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process basic bounce categories', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Test hard bounce detection
|
||||
const hardBounce = await bounceManager.processBounce({
|
||||
recipient: 'invalid@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'user unknown',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(hardBounce.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Test soft bounce detection
|
||||
const softBounce = await bounceManager.processBounce({
|
||||
recipient: 'valid@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'server unavailable',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(softBounce.bounceCategory).toEqual(BounceCategory.SOFT);
|
||||
|
||||
// Test auto-response detection
|
||||
const autoResponse = await bounceManager.processBounce({
|
||||
recipient: 'away@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'auto-reply: out of office',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(autoResponse.bounceCategory).toEqual(BounceCategory.AUTO_RESPONSE);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should add and check suppression list entries', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Add to suppression list permanently
|
||||
bounceManager.addToSuppressionList('permanent@example.com', 'Test hard bounce', undefined);
|
||||
|
||||
// Add to suppression list temporarily (5 seconds)
|
||||
const expireTime = Date.now() + 5000;
|
||||
bounceManager.addToSuppressionList('temporary@example.com', 'Test soft bounce', expireTime);
|
||||
|
||||
// Check suppression status
|
||||
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('notsuppressed@example.com')).toEqual(false);
|
||||
|
||||
// Get suppression info
|
||||
const info = bounceManager.getSuppressionInfo('permanent@example.com');
|
||||
expect(info).toBeTruthy();
|
||||
expect(info.reason).toEqual('Test hard bounce');
|
||||
expect(info.expiresAt).toBeUndefined();
|
||||
|
||||
// Verify temporary suppression info
|
||||
const tempInfo = bounceManager.getSuppressionInfo('temporary@example.com');
|
||||
expect(tempInfo).toBeTruthy();
|
||||
expect(tempInfo.reason).toEqual('Test soft bounce');
|
||||
expect(tempInfo.expiresAt).toEqual(expireTime);
|
||||
|
||||
// Wait for expiration (6 seconds)
|
||||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||||
|
||||
// Verify permanent suppression is still active
|
||||
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||
|
||||
// Verify temporary suppression has expired
|
||||
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process SMTP failures correctly', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
const result = await bounceManager.processSmtpFailure(
|
||||
'recipient@example.com',
|
||||
'550 5.1.1 User unknown',
|
||||
{
|
||||
sender: 'sender@example.com',
|
||||
statusCode: '550'
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.bounceType).toEqual(BounceType.INVALID_RECIPIENT);
|
||||
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Check that the email was added to the suppression list
|
||||
expect(bounceManager.isEmailSuppressed('recipient@example.com')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process bounce emails correctly', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Create a mock bounce email as Smartmail
|
||||
const bounceEmail = new plugins.smartmail.Smartmail({
|
||||
from: 'mailer-daemon@example.com',
|
||||
subject: 'Mail delivery failed: returning message to sender',
|
||||
body: `
|
||||
This message was created automatically by mail delivery software.
|
||||
|
||||
A message that you sent could not be delivered to one or more of its recipients.
|
||||
The following address(es) failed:
|
||||
|
||||
recipient@example.com
|
||||
mailbox is full
|
||||
|
||||
------ This is a copy of the message, including all the headers. ------
|
||||
|
||||
Original-Recipient: rfc822;recipient@example.com
|
||||
Final-Recipient: rfc822;recipient@example.com
|
||||
Status: 5.2.2
|
||||
diagnostic-code: smtp; 552 5.2.2 Mailbox full
|
||||
`,
|
||||
creationObjectRef: {}
|
||||
});
|
||||
|
||||
const result = await bounceManager.processBounceEmail(bounceEmail);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.bounceType).toEqual(BounceType.MAILBOX_FULL);
|
||||
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
expect(result.recipient).toEqual('recipient@example.com');
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should handle retries for soft bounces', async () => {
|
||||
const bounceManager = new BounceManager({
|
||||
retryStrategy: {
|
||||
maxRetries: 2,
|
||||
initialDelay: 100, // 100ms for test
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// First attempt
|
||||
const result1 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Email should be suppressed temporarily
|
||||
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||
expect(result1.retryCount).toEqual(1);
|
||||
expect(result1.nextRetryTime).toBeGreaterThan(Date.now());
|
||||
|
||||
// Second attempt
|
||||
const result2 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com',
|
||||
retryCount: 1
|
||||
});
|
||||
|
||||
expect(result2.retryCount).toEqual(2);
|
||||
|
||||
// Third attempt (should convert to hard bounce)
|
||||
const result3 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com',
|
||||
retryCount: 2
|
||||
});
|
||||
|
||||
// Should now be a hard bounce after max retries
|
||||
expect(result3.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Email should be suppressed permanently
|
||||
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||
const info = bounceManager.getSuppressionInfo('retry@example.com');
|
||||
expect(info.expiresAt).toBeUndefined(); // Permanent
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||
import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-cert-api-token-${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 makeApiTokenManager = (scopes: TScope[]) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'certificate-test-token',
|
||||
scopes,
|
||||
createdBy: 'token-user',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
|
||||
};
|
||||
};
|
||||
|
||||
const setupHandler = (scopes: TScope[], options?: {
|
||||
routes?: any[];
|
||||
certProvisionScheduler?: any;
|
||||
certProvisionFunction?: (...args: any[]) => any;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async () => null,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
apiTokenManager: makeApiTokenManager(scopes),
|
||||
certificateStatusMap: new Map(),
|
||||
smartProxy: {
|
||||
settings: options?.certProvisionFunction ? {
|
||||
certProvisionFunction: options.certProvisionFunction,
|
||||
} : {},
|
||||
routeManager: { getRoutes: () => options?.routes ?? [] },
|
||||
getCertificateStatus: async () => null,
|
||||
},
|
||||
certProvisionScheduler: options?.certProvisionScheduler ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
new CertificateHandler(opsServerRef);
|
||||
return { typedrouter, opsServerRef };
|
||||
};
|
||||
|
||||
const fireTypedRequest = async (
|
||||
router: plugins.typedrequest.TypedRouter,
|
||||
method: string,
|
||||
request: Record<string, any>,
|
||||
) => {
|
||||
return await router.routeAndAddResponse({
|
||||
method,
|
||||
request,
|
||||
response: {},
|
||||
correlation: {
|
||||
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
phase: 'request',
|
||||
},
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
tap.test('CertificateHandler allows API-token export with certificates:read', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const certDoc = new AcmeCertDoc();
|
||||
certDoc.id = 'cert-1';
|
||||
certDoc.domainName = 'example.com';
|
||||
certDoc.created = 1;
|
||||
certDoc.validUntil = 2;
|
||||
certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----';
|
||||
certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
|
||||
certDoc.csr = '';
|
||||
await certDoc.save();
|
||||
|
||||
const { typedrouter } = setupHandler(['certificates:read']);
|
||||
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.success).toEqual(true);
|
||||
expect(result.response.cert.domainName).toEqual('example.com');
|
||||
expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY');
|
||||
expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler rejects API-token export without certificates:read', async () => {
|
||||
const { typedrouter } = setupHandler(['certificates:write']);
|
||||
const result = await fireTypedRequest(typedrouter, 'exportCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler allows API-token import with certificates:write', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const { typedrouter, opsServerRef } = setupHandler(['certificates:write']);
|
||||
const result = await fireTypedRequest(typedrouter, 'importCertificate', {
|
||||
apiToken: 'valid-token',
|
||||
cert: {
|
||||
id: 'cert-2',
|
||||
domainName: 'imported.example.com',
|
||||
created: 3,
|
||||
validUntil: 4,
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||
csr: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.success).toEqual(true);
|
||||
expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2');
|
||||
expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid');
|
||||
});
|
||||
|
||||
tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => {
|
||||
await testDbPromise;
|
||||
|
||||
const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.';
|
||||
const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const { typedrouter } = setupHandler(['certificates:read'], {
|
||||
certProvisionFunction: async () => 'http01',
|
||||
certProvisionScheduler: {
|
||||
getBackoffInfo: async (domain: string) => domain === 'stack.gallery'
|
||||
? { failures: 11, retryAfter, lastError }
|
||||
: null,
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
name: 'stack-gallery',
|
||||
match: { domains: ['stack.gallery'] },
|
||||
action: {
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.summary.failed).toEqual(1);
|
||||
expect(result.response.certificates[0].status).toEqual('failed');
|
||||
expect(result.response.certificates[0].error).toEqual(lastError);
|
||||
expect(result.response.certificates[0].backoffInfo.failures).toEqual(11);
|
||||
});
|
||||
|
||||
tap.test('cleanup test db', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await testDb.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ConfigHandler } from '../ts/opsserver/handlers/config.handler.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const fireTypedRequest = async (
|
||||
router: plugins.typedrequest.TypedRouter,
|
||||
method: string,
|
||||
request: Record<string, any>,
|
||||
) => {
|
||||
return await router.routeAndAddResponse({
|
||||
method,
|
||||
request,
|
||||
response: {},
|
||||
correlation: {
|
||||
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
phase: 'request',
|
||||
},
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const makeOpsServer = (scopes: interfaces.data.TApiTokenScope[]) => {
|
||||
const router = new plugins.typedrequest.TypedRouter();
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'config-token',
|
||||
tokenHash: 'hash',
|
||||
scopes,
|
||||
createdBy: 'token-user',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
const opsServerRef = {
|
||||
viewRouter: router,
|
||||
adminHandler: {
|
||||
validateIdentity: async () => null,
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {
|
||||
dbConfig: { enabled: false },
|
||||
},
|
||||
resolvedPaths: {
|
||||
dcrouterHomeDir: '/tmp/dcrouter-home',
|
||||
dataDir: '/tmp/dcrouter-data',
|
||||
defaultTsmDbPath: '/tmp/dcrouter-data/db',
|
||||
},
|
||||
detectedPublicIp: null,
|
||||
apiTokenManager: {
|
||||
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
|
||||
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: interfaces.data.TApiTokenScope) => storedTokenArg.scopes.includes(scopeArg),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
new ConfigHandler(opsServerRef);
|
||||
return router;
|
||||
};
|
||||
|
||||
tap.test('ConfigHandler accepts API token with config:read', async () => {
|
||||
const router = makeOpsServer(['config:read']);
|
||||
const result = await fireTypedRequest(router, 'getConfiguration', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.config.system.baseDir).toEqual('/tmp/dcrouter-home');
|
||||
});
|
||||
|
||||
tap.test('ConfigHandler rejects API token without config:read', async () => {
|
||||
const router = makeOpsServer(['logs:read']);
|
||||
const result = await fireTypedRequest(router, 'getConfiguration', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
import { Email } from '@push.rocks/smartmta';
|
||||
|
||||
// Test instantiation
|
||||
tap.test('ContentScanner - should be instantiable', async () => {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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 * as net from 'node:net';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
// 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,
|
||||
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();
|
||||
@@ -1,55 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
|
||||
// Import the components we want to test
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
// Ensure test directories exist
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Test SenderReputationMonitor functionality
|
||||
tap.test('SenderReputationMonitor should track sending events', async () => {
|
||||
// Initialize monitor with test domain
|
||||
const monitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Record some events
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'sent', count: 100 });
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'delivered', count: 95 });
|
||||
|
||||
// Get domain metrics
|
||||
const metrics = monitor.getReputationData('test-domain.com');
|
||||
|
||||
// Verify metrics were recorded
|
||||
if (metrics) {
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
}
|
||||
});
|
||||
|
||||
// Test IPWarmupManager functionality
|
||||
tap.test('IPWarmupManager should handle IP allocation policies', async () => {
|
||||
// Initialize warmup manager
|
||||
const manager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
manager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Verify allocation methods work
|
||||
const canSend = manager.canSendMoreToday('192.168.1.1');
|
||||
expect(typeof canSend).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,469 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
|
||||
import { DcRouterDb, DnsRecordDoc, 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 record of await DnsRecordDoc.findAll()) {
|
||||
await record.delete();
|
||||
}
|
||||
for (const route of await RouteDoc.findAll()) {
|
||||
await route.delete();
|
||||
}
|
||||
for (const domain of await DomainDoc.findAll()) {
|
||||
await domain.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const now = Date.now();
|
||||
const domain = new DomainDoc();
|
||||
domain.id = 'central-eu';
|
||||
domain.name = 'central.eu';
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.createdAt = now;
|
||||
domain.updatedAt = now;
|
||||
domain.createdBy = 'test';
|
||||
await domain.save();
|
||||
|
||||
const dnsManager = new DnsManager({});
|
||||
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
|
||||
const hostName = '_acme-challenge.blog.central.eu';
|
||||
|
||||
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
|
||||
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
|
||||
|
||||
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
|
||||
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
|
||||
'first-token',
|
||||
'second-token',
|
||||
]);
|
||||
|
||||
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
|
||||
|
||||
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
|
||||
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
|
||||
});
|
||||
|
||||
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const now = Date.now();
|
||||
const domain = new DomainDoc();
|
||||
domain.id = 'central-eu';
|
||||
domain.name = 'central.eu';
|
||||
domain.source = 'dcrouter';
|
||||
domain.authoritative = true;
|
||||
domain.createdAt = now;
|
||||
domain.updatedAt = now;
|
||||
domain.createdBy = 'test';
|
||||
await domain.save();
|
||||
|
||||
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
|
||||
const dnsManager = new DnsManager({});
|
||||
dnsManager.dnsServer = {
|
||||
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
|
||||
registeredHandlers.push(handler);
|
||||
},
|
||||
} as any;
|
||||
|
||||
await dnsManager.createRecord({
|
||||
domainId: domain.id,
|
||||
name: '_acme-challenge.central.eu',
|
||||
type: 'TXT',
|
||||
value: 'challenge-token',
|
||||
ttl: 120,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const answer = registeredHandlers[0]?.({
|
||||
name: '_aCMe-challeNge.Central.Eu',
|
||||
type: 'txt',
|
||||
});
|
||||
|
||||
expect(answer).toEqual({
|
||||
name: '_aCMe-challeNge.Central.Eu',
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: 120,
|
||||
data: 'challenge-token',
|
||||
});
|
||||
});
|
||||
|
||||
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('RouteConfigManager clears a network target ref and keeps the edited inline target port', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const resolver = new ReferenceResolver();
|
||||
(resolver as any).targets.set('target-1', {
|
||||
id: 'target-1',
|
||||
name: 'SSH TARGET',
|
||||
host: '10.0.0.5',
|
||||
port: 443,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
const routeId = await routeManager.createRoute(
|
||||
{
|
||||
name: 'ssh-route',
|
||||
match: { ports: [22] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 22 }],
|
||||
},
|
||||
} as any,
|
||||
'test-user',
|
||||
true,
|
||||
{ networkTargetRef: 'target-1' },
|
||||
);
|
||||
|
||||
expect((await RouteDoc.findById(routeId))?.route.action.targets?.[0].port).toEqual(443);
|
||||
expect((await RouteDoc.findById(routeId))?.metadata?.networkTargetRef).toEqual('target-1');
|
||||
|
||||
const updateResult = await routeManager.updateRoute(routeId, {
|
||||
route: {
|
||||
action: {
|
||||
targets: [{ host: '127.0.0.1', port: 29424 }],
|
||||
},
|
||||
} as any,
|
||||
metadata: {
|
||||
networkTargetRef: '',
|
||||
networkTargetName: '',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateResult.success).toEqual(true);
|
||||
|
||||
const storedRoute = await RouteDoc.findById(routeId);
|
||||
expect(storedRoute?.route.action.targets?.[0].host).toEqual('127.0.0.1');
|
||||
expect(storedRoute?.route.action.targets?.[0].port).toEqual(29424);
|
||||
expect(storedRoute?.metadata?.networkTargetRef).toBeUndefined();
|
||||
expect(storedRoute?.metadata?.networkTargetName).toBeUndefined();
|
||||
|
||||
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
|
||||
expect(mergedRoute?.route.action.targets?.[0].port).toEqual(29424);
|
||||
expect(mergedRoute?.metadata?.networkTargetRef).toBeUndefined();
|
||||
expect(mergedRoute?.metadata?.networkTargetName).toBeUndefined();
|
||||
|
||||
expect(appliedRoutes[appliedRoutes.length - 1][0].action.targets[0].port).toEqual(29424);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager clears remote ingress config when route patch sets it to null', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
const routeId = await routeManager.createRoute(
|
||||
{
|
||||
name: 'remote-ingress-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['edge-a', 'blue'],
|
||||
},
|
||||
} as any,
|
||||
'test-user',
|
||||
);
|
||||
|
||||
const updateResult = await routeManager.updateRoute(routeId, {
|
||||
route: {
|
||||
remoteIngress: null,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(updateResult.success).toEqual(true);
|
||||
|
||||
const storedRoute = await RouteDoc.findById(routeId);
|
||||
expect(storedRoute?.route.remoteIngress).toBeUndefined();
|
||||
|
||||
const mergedRoute = routeManager.getMergedRoutes().routes.find((route) => route.id === routeId);
|
||||
expect(mergedRoute?.route.remoteIngress).toBeUndefined();
|
||||
|
||||
expect(appliedRoutes[appliedRoutes.length - 1][0].remoteIngress).toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -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
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
opsServerPort,
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,175 @@
|
||||
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`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
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 () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
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: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
const responseIdentity = response.identity;
|
||||
expect(responseIdentity).toBeDefined();
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
|
||||
adminIdentity = responseIdentity;
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -1,209 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mta/classes.spfverifier.js';
|
||||
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mta/classes.dmarcverifier.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
|
||||
/**
|
||||
* Test email authentication systems: SPF and DMARC
|
||||
*/
|
||||
|
||||
// Setup platform service for testing
|
||||
let platformService: SzPlatformService;
|
||||
|
||||
tap.test('Setup test environment', async () => {
|
||||
platformService = new SzPlatformService();
|
||||
// Use start() instead of init() which doesn't exist
|
||||
await platformService.start();
|
||||
expect(platformService.mtaService).toBeTruthy();
|
||||
});
|
||||
|
||||
// SPF Verifier Tests
|
||||
tap.test('SPF Verifier - should parse SPF record', async () => {
|
||||
const spfVerifier = new SpfVerifier(platformService.mtaService);
|
||||
|
||||
// Test valid SPF record parsing
|
||||
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
|
||||
const parsedRecord = spfVerifier.parseSpfRecord(record);
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('spf1');
|
||||
expect(parsedRecord.mechanisms.length).toEqual(5);
|
||||
|
||||
// Check specific mechanisms
|
||||
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
|
||||
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
|
||||
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
|
||||
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
|
||||
|
||||
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
|
||||
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
|
||||
|
||||
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
|
||||
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-spf-record';
|
||||
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
// DMARC Verifier Tests
|
||||
tap.test('DMARC Verifier - should parse DMARC record', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Test valid DMARC record parsing
|
||||
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
|
||||
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('DMARC1');
|
||||
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
|
||||
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
|
||||
expect(parsedRecord.pct).toEqual(50);
|
||||
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
|
||||
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
|
||||
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-dmarc-record';
|
||||
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Test email domains with DMARC alignment
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC alignment',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test when both SPF and DKIM pass with alignment
|
||||
const dmarcResult = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'example.com', result: true }, // SPF - aligned and passed
|
||||
{ domain: 'example.com', result: true } // DKIM - aligned and passed
|
||||
);
|
||||
|
||||
expect(dmarcResult).toBeTruthy();
|
||||
expect(dmarcResult.spfPassed).toEqual(true);
|
||||
expect(dmarcResult.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult.spfDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.dkimDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.action).toEqual('pass');
|
||||
|
||||
// Test when neither SPF nor DKIM is aligned
|
||||
const dmarcResult2 = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
|
||||
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
|
||||
);
|
||||
|
||||
// We can now see the actual DMARC result and update our expectations
|
||||
|
||||
expect(dmarcResult2).toBeTruthy();
|
||||
expect(dmarcResult2.spfPassed).toEqual(true);
|
||||
expect(dmarcResult2.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
||||
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
||||
|
||||
// The test environment is returning a 'reject' policy - we can verify that
|
||||
expect(dmarcResult2.policyEvaluated).toEqual('reject');
|
||||
expect(dmarcResult2.actualPolicy).toEqual('reject');
|
||||
expect(dmarcResult2.action).toEqual('reject');
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Create test email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC policy application',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test pass action
|
||||
const passResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: true,
|
||||
dkimDomainAligned: true,
|
||||
spfPassed: true,
|
||||
dkimPassed: true,
|
||||
policyEvaluated: DmarcPolicy.NONE,
|
||||
actualPolicy: DmarcPolicy.NONE,
|
||||
appliedPercentage: 100,
|
||||
action: 'pass',
|
||||
details: 'DMARC passed'
|
||||
};
|
||||
|
||||
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
|
||||
expect(passApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(false);
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
|
||||
|
||||
// Test quarantine action
|
||||
const quarantineResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.QUARANTINE,
|
||||
actualPolicy: DmarcPolicy.QUARANTINE,
|
||||
appliedPercentage: 100,
|
||||
action: 'quarantine',
|
||||
details: 'DMARC failed, policy=quarantine'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
|
||||
expect(quarantineApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
expect(email.headers['X-Spam-Flag']).toEqual('YES');
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
|
||||
|
||||
// Test reject action
|
||||
const rejectResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.REJECT,
|
||||
actualPolicy: DmarcPolicy.REJECT,
|
||||
appliedPercentage: 100,
|
||||
action: 'reject',
|
||||
details: 'DMARC failed, policy=reject'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
|
||||
expect(rejectApplied).toEqual(false);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Cleanup test environment', async () => {
|
||||
await platformService.stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,283 @@
|
||||
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);
|
||||
if (!(error instanceof PlatformError)) {
|
||||
throw error;
|
||||
}
|
||||
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) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw 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) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw 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();
|
||||
@@ -0,0 +1,232 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
import * as http from 'node:http';
|
||||
import * as net from 'node:net';
|
||||
import {
|
||||
deriveHttpRedirectConfiguration,
|
||||
deriveHttpRedirects,
|
||||
} from '../ts/config/helpers.http-redirects.js';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestHeaders(
|
||||
port: number,
|
||||
path: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<http.IncomingMessage> {
|
||||
return await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
|
||||
request.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'app-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['edge-a'],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects.length).toEqual(1);
|
||||
expect(result.redirects[0].status).toEqual('active');
|
||||
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
|
||||
expect(result.redirects[0].remoteIngress).toEqual(true);
|
||||
expect(result.runtimeRoutes.length).toEqual(1);
|
||||
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
|
||||
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
|
||||
expect(result.runtimeRoutes[0].priority).toEqual(0);
|
||||
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
|
||||
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
|
||||
const redirects = deriveHttpRedirects([
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'first-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: 'route-2',
|
||||
name: 'second-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8081 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(redirects.length).toEqual(1);
|
||||
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'existing-http-route',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects.length).toEqual(1);
|
||||
expect(result.redirects[0].status).toEqual('covered');
|
||||
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'existing-http-health-route',
|
||||
match: { ports: 80, domains: 'app.example.com', path: '/health' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects[0].status).toEqual('skipped');
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
|
||||
const result = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'wildcard-https-route',
|
||||
match: { ports: 443, domains: '*.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'explicit-http-app-route',
|
||||
match: { ports: 80, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(result.redirects[0].status).toEqual('skipped');
|
||||
expect(result.runtimeRoutes.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
|
||||
const redirects = deriveHttpRedirects([
|
||||
{
|
||||
name: 'udp-route',
|
||||
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 443 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'header-route',
|
||||
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
name: 'socket-handler-route',
|
||||
match: { ports: 443, domains: 'handler.example.com' },
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: () => {},
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(redirects.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('generated runtime redirect preserves host and path', async () => {
|
||||
const proxyPort = await getFreePort();
|
||||
const redirectRoute = deriveHttpRedirectConfiguration([
|
||||
{
|
||||
name: 'https-route',
|
||||
match: { ports: 443, domains: 'app.example.com' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
} as any,
|
||||
]).runtimeRoutes[0] as any;
|
||||
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
|
||||
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [redirectRoute],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
|
||||
expect(response.statusCode).toEqual(301);
|
||||
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
|
||||
response.destroy();
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -1,100 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { MtaService } from '../ts/mta/classes.mta.js';
|
||||
import { EmailService } from '../ts/email/classes.emailservice.js';
|
||||
import { BounceManager } from '../ts/email/classes.bouncemanager.js';
|
||||
import DcRouter from '../ts/dcrouter/classes.dcrouter.js';
|
||||
|
||||
// Test the new integration architecture
|
||||
tap.test('should be able to create an independent MTA service', async (tools) => {
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
hostname: 'test.example.com'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify it was created properly without a platform service reference
|
||||
expect(mta).toBeTruthy();
|
||||
expect(mta.platformServiceRef).toBeUndefined();
|
||||
|
||||
// Even without a platform service, it should have its own SMTP rule engine
|
||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should be able to create an EmailService with an existing MTA', async (tools) => {
|
||||
// Create a platform service first
|
||||
const platformService = new SzPlatformService();
|
||||
|
||||
// Create a shared bounce manager
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Manually set the bounce manager for testing
|
||||
// @ts-ignore - adding property for testing
|
||||
mta.bounceManager = bounceManager;
|
||||
|
||||
// Create an email service that uses the independent MTA
|
||||
// @ts-ignore - passing a third argument to the constructor
|
||||
const emailService = new EmailService(platformService, {}, mta);
|
||||
|
||||
// Manually set the mtaService property
|
||||
emailService.mtaService = mta;
|
||||
|
||||
// Verify relationships
|
||||
expect(emailService.mtaService === mta).toBeTrue();
|
||||
expect(emailService.bounceManager).toBeTruthy();
|
||||
|
||||
// MTA should not have a direct platform service reference
|
||||
expect(mta.platformServiceRef).toBeUndefined();
|
||||
|
||||
// But it should have access to bounce manager
|
||||
// @ts-ignore - accessing property for testing
|
||||
expect(mta.bounceManager === bounceManager).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('MTA service should have SMTP rule engine', async (tools) => {
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Verify the MTA has an SMTP rule engine
|
||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('platform service should support having an MTA service', async (tools) => {
|
||||
// Create a platform service with default config
|
||||
const platformService = new SzPlatformService();
|
||||
|
||||
// Create MTA - don't await start() to avoid binding to ports
|
||||
platformService.mtaService = new MtaService(platformService, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Create email service using the platform
|
||||
platformService.emailService = new EmailService(platformService);
|
||||
|
||||
// Verify the MTA has a reference to the platform service
|
||||
expect(platformService.mtaService).toBeTruthy();
|
||||
expect(platformService.mtaService.platformServiceRef).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
// Export for tapbundle execution
|
||||
export default tap.start();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
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';
|
||||
|
||||
@@ -6,8 +6,8 @@ import * as plugins from '../ts/plugins.js';
|
||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||
|
||||
// Setup mock DNS resolver
|
||||
plugins.dns.promises.resolve = async (hostname: string) => {
|
||||
// Setup mock DNS resolver with proper typing
|
||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||
return mockDnsResolveImpl(hostname);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const warmupDataPath = plugins.path.join(paths.dataDir, 'warmup');
|
||||
if (plugins.fs.existsSync(warmupDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(warmupDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
IPWarmupManager.instance = null;
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of IPWarmupManager
|
||||
tap.test('should initialize IPWarmupManager with default settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance();
|
||||
|
||||
expect(ipWarmupManager).toBeTruthy();
|
||||
expect(typeof ipWarmupManager.getBestIPForSending).toEqual('function');
|
||||
expect(typeof ipWarmupManager.canSendMoreToday).toEqual('function');
|
||||
expect(typeof ipWarmupManager.getStageCount).toEqual('function');
|
||||
expect(typeof ipWarmupManager.setActiveAllocationPolicy).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize IPWarmupManager with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com', 'test.com'],
|
||||
fallbackPercentage: 75
|
||||
});
|
||||
|
||||
// Test setting allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// Get best IP for sending
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
|
||||
// Check stage count
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(typeof stageCount).toEqual('number');
|
||||
});
|
||||
|
||||
// Test IP allocation policies
|
||||
tap.test('should allocate IPs using balanced policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Use getBestIPForSending multiple times and check if all IPs are used
|
||||
const usedIPs = new Set();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
if (ip) usedIPs.add(ip);
|
||||
}
|
||||
|
||||
// We should use at least 2 different IPs with balanced policy
|
||||
expect(usedIPs.size >= 2).toBeTrue();
|
||||
});
|
||||
|
||||
// Test round robin allocation policy
|
||||
tap.test('should allocate IPs using round robin policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// First few IPs should rotate through the available IPs
|
||||
const firstIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const secondIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const thirdIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Round robin should give us different IPs for consecutive calls
|
||||
expect(firstIP !== secondIP).toBeTrue();
|
||||
|
||||
// With 3 IPs, the fourth call should cycle back to one of the IPs
|
||||
const fourthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check that the fourth IP is one of the 3 valid IPs
|
||||
expect(['192.168.1.1', '192.168.1.2', '192.168.1.3'].includes(fourthIP)).toBeTrue();
|
||||
});
|
||||
|
||||
// Test dedicated domain allocation policy
|
||||
tap.test('should allocate IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Instead of mapDomainToIP which doesn't exist, we'll simulate domain mapping
|
||||
// by making dedicated calls per domain - we can't call the internal method directly
|
||||
|
||||
// Each domain should get its dedicated IP
|
||||
const exampleIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const testIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
const otherIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@other.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'other.com'
|
||||
});
|
||||
|
||||
// Since we're not actually mapping domains to IPs, we can only test if they return valid IPs
|
||||
// The original assertions have been modified since we can't guarantee which IP will be returned
|
||||
expect(exampleIP).toBeTruthy();
|
||||
expect(testIP).toBeTruthy();
|
||||
expect(otherIP).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test daily sending limits
|
||||
tap.test('should enforce daily sending limits', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1'],
|
||||
targetDomains: ['example.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
// Override the warmup stage for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.warmupStatuses.set('192.168.1.1', {
|
||||
ipAddress: '192.168.1.1',
|
||||
isActive: true,
|
||||
currentStage: 1,
|
||||
startDate: new Date(),
|
||||
currentStageStartDate: new Date(),
|
||||
targetCompletionDate: new Date(),
|
||||
currentDailyAllocation: 5,
|
||||
sentInCurrentStage: 0,
|
||||
totalSent: 0,
|
||||
dailyStats: [],
|
||||
metrics: {
|
||||
openRate: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Set a very low daily limit for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.config.stages = [
|
||||
{ stage: 1, maxDailyVolume: 5, durationDays: 5, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } }
|
||||
];
|
||||
|
||||
// First pass: should be able to get an IP
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip === '192.168.1.1').toBeTrue();
|
||||
|
||||
// Record 5 sends to reach the daily limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend('192.168.1.1');
|
||||
}
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
expect(canSendMore).toEqual(false);
|
||||
|
||||
// After reaching limit, getBestIPForSending should return null
|
||||
// since there are no available IPs
|
||||
const sixthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(sixthIP === null).toBeTrue();
|
||||
});
|
||||
|
||||
// Test recording sends
|
||||
tap.test('should record send events correctly', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com'],
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Get an IP for sending
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// If we got an IP, record some sends
|
||||
if (ip) {
|
||||
// Record a few sends
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend(ip);
|
||||
}
|
||||
|
||||
// Check if we can still send more
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(ip);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
// Test that DedicatedDomainPolicy assigns IPs correctly
|
||||
tap.test('should assign IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
});
|
||||
|
||||
// Set allocation policy to dedicated domains
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Check allocation by querying for different domains
|
||||
const ip1 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const ip2 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
// If we got IPs, they should be consistently assigned
|
||||
if (ip1 && ip2) {
|
||||
// Requesting the same domain again should return the same IP
|
||||
const ip1again = ipWarmupManager.getBestIPForSending({
|
||||
from: 'another@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip1again === ip1).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,165 @@
|
||||
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';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
let opsServerPort: number;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTypedRequestUrl(): string {
|
||||
return `http://localhost:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort,
|
||||
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>(
|
||||
getTypedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
expect(responseIdentity).toHaveProperty('jwt');
|
||||
expect(responseIdentity).toHaveProperty('userId');
|
||||
expect(responseIdentity).toHaveProperty('name');
|
||||
expect(responseIdentity).toHaveProperty('expiresAt');
|
||||
expect(responseIdentity).toHaveProperty('role');
|
||||
expect(responseIdentity.role).toEqual('admin');
|
||||
|
||||
identity = responseIdentity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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();
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected verify response to include identity');
|
||||
}
|
||||
expect(responseIdentity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(responseIdentity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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();
|
||||
@@ -0,0 +1,378 @@
|
||||
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 createActiveConnectionSnapshots(entries: Array<{
|
||||
count: number;
|
||||
sourceIp?: string;
|
||||
routeId?: string;
|
||||
domain?: string;
|
||||
localPort?: number;
|
||||
}>) {
|
||||
const snapshots: any[] = [];
|
||||
let index = 0;
|
||||
for (const entry of entries) {
|
||||
for (let i = 0; i < entry.count; i++) {
|
||||
snapshots.push({
|
||||
id: `test-connection-${index++}`,
|
||||
sourceIp: entry.sourceIp || '192.0.2.10',
|
||||
sourcePort: 40000 + index,
|
||||
localPort: entry.localPort || 443,
|
||||
domain: entry.domain,
|
||||
routeId: entry.routeId,
|
||||
targetHost: '127.0.0.1',
|
||||
targetPort: 8443,
|
||||
protocol: 'https',
|
||||
state: 'active',
|
||||
startedAtMs: Date.now(),
|
||||
ageMs: 0,
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
domainRequestRates?: Map<string, { perSecond: number; lastMinute: number }>;
|
||||
backendMetrics?: Map<string, any>;
|
||||
protocolCache?: any[];
|
||||
requestsTotal?: number;
|
||||
connectionsByIP?: Map<string, number>;
|
||||
throughputByIP?: Map<string, { in: number; out: number }>;
|
||||
}) {
|
||||
const connectionsByIP = args.connectionsByIP || new Map<string, number>();
|
||||
const throughputByIP = args.throughputByIP || new Map<string, { in: number; out: number }>();
|
||||
return {
|
||||
connections: {
|
||||
active: () => 0,
|
||||
total: () => 0,
|
||||
byRoute: () => args.connectionsByRoute,
|
||||
byIP: () => connectionsByIP,
|
||||
topIPs: (limit = 10) => Array.from(connectionsByIP.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([ip, count]) => ({ ip, count })),
|
||||
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: () => throughputByIP,
|
||||
},
|
||||
requests: {
|
||||
perSecond: () => 0,
|
||||
perMinute: () => 0,
|
||||
total: () => args.requestsTotal || 0,
|
||||
byDomain: () => args.domainRequestRates || new Map<string, { perSecond: number; lastMinute: number }>(),
|
||||
},
|
||||
totals: {
|
||||
bytesIn: () => 0,
|
||||
bytesOut: () => 0,
|
||||
connections: () => 0,
|
||||
},
|
||||
backends: {
|
||||
byBackend: () => args.backendMetrics || new Map<string, any>(),
|
||||
protocols: () => new Map<string, string>(),
|
||||
topByErrors: () => [],
|
||||
detectedProtocols: () => args.protocolCache || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
|
||||
{ count: 3, routeId: 'route-id-only', domain: 'alpha.example.com' },
|
||||
{ count: 1, routeId: 'route-id-only', domain: 'beta.example.com' },
|
||||
]),
|
||||
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);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager prefers live domain request rates for current activity', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map([
|
||||
['route-id-only', 10],
|
||||
]),
|
||||
throughputByRoute: new Map([
|
||||
['route-id-only', { in: 1000, out: 1000 }],
|
||||
]),
|
||||
domainRequestsByIP: new Map([
|
||||
['192.0.2.10', new Map([
|
||||
['alpha.example.com', 1000],
|
||||
['beta.example.com', 1],
|
||||
])],
|
||||
]),
|
||||
domainRequestRates: new Map([
|
||||
['alpha.example.com', { perSecond: 0, lastMinute: 0 }],
|
||||
['beta.example.com', { perSecond: 5, lastMinute: 60 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
|
||||
{ count: 10, routeId: 'route-id-only', domain: 'beta.example.com' },
|
||||
]),
|
||||
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!.activeConnections).toEqual(0);
|
||||
expect(alpha!.requestsPerSecond).toEqual(0);
|
||||
expect(beta!.activeConnections).toEqual(10);
|
||||
expect(beta!.requestsPerSecond).toEqual(5);
|
||||
expect(beta!.bytesInPerSecond).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('MetricsManager does not duplicate backend active counts onto protocol cache rows', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
backendMetrics: new Map([
|
||||
['192.0.2.1:443', {
|
||||
protocol: 'h2',
|
||||
activeConnections: 257,
|
||||
totalConnections: 1000,
|
||||
connectErrors: 1,
|
||||
handshakeErrors: 2,
|
||||
requestErrors: 3,
|
||||
avgConnectTimeMs: 4,
|
||||
poolHitRate: 0.9,
|
||||
h2Failures: 5,
|
||||
}],
|
||||
]),
|
||||
protocolCache: [
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'alpha.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
{
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
domain: 'beta.example.com',
|
||||
protocol: 'h2',
|
||||
h2Suppressed: false,
|
||||
h3Suppressed: false,
|
||||
h2CooldownRemainingSecs: null,
|
||||
h3CooldownRemainingSecs: null,
|
||||
h2ConsecutiveFailures: null,
|
||||
h3ConsecutiveFailures: null,
|
||||
h3Port: null,
|
||||
ageSecs: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const smartProxy = {
|
||||
getMetrics: () => proxyMetrics,
|
||||
getActiveConnectionSnapshots: () => [],
|
||||
routeManager: {
|
||||
getRoutes: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new MetricsManager({ smartProxy } as any);
|
||||
const stats = await manager.getNetworkStats();
|
||||
const aggregate = stats.backends.find((item) => item.id === 'backend:192.0.2.1:443');
|
||||
const cacheRows = stats.backends.filter((item) => item.id?.startsWith('cache:'));
|
||||
|
||||
expect(aggregate!.activeConnections).toEqual(257);
|
||||
expect(cacheRows.length).toEqual(2);
|
||||
expect(cacheRows.every((item) => item.activeConnections === 0)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('MetricsManager queues IP intelligence without awaiting enrichment', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
connectionsByIP: new Map([
|
||||
['8.8.8.8', 4],
|
||||
['1.1.1.1', 2],
|
||||
]),
|
||||
throughputByIP: new Map([
|
||||
['8.8.8.8', { in: 500, out: 250 }],
|
||||
['1.1.1.1', { in: 1500, out: 1000 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const queuedIps: string[][] = [];
|
||||
const manager = new MetricsManager({
|
||||
smartProxy: {
|
||||
getMetrics: () => proxyMetrics,
|
||||
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
|
||||
{ count: 4, sourceIp: '8.8.8.8' },
|
||||
{ count: 2, sourceIp: '1.1.1.1' },
|
||||
]),
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
|
||||
listIpIntelligence: async () => [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
await manager.getNetworkStats();
|
||||
|
||||
expect(queuedIps).toHaveLength(1);
|
||||
expect(queuedIps[0]).toContain('8.8.8.8');
|
||||
expect(queuedIps[0]).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
|
||||
const proxyMetrics = createProxyMetrics({
|
||||
connectionsByRoute: new Map(),
|
||||
throughputByRoute: new Map(),
|
||||
domainRequestsByIP: new Map(),
|
||||
connectionsByIP: new Map([
|
||||
['8.8.8.8', 4],
|
||||
['8.8.4.4', 3],
|
||||
['1.1.1.1', 5],
|
||||
]),
|
||||
throughputByIP: new Map([
|
||||
['8.8.8.8', { in: 500, out: 250 }],
|
||||
['8.8.4.4', { in: 700, out: 350 }],
|
||||
['1.1.1.1', { in: 2000, out: 1000 }],
|
||||
]),
|
||||
});
|
||||
|
||||
const manager = new MetricsManager({
|
||||
smartProxy: {
|
||||
getMetrics: () => proxyMetrics,
|
||||
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
|
||||
{ count: 4, sourceIp: '8.8.8.8' },
|
||||
{ count: 3, sourceIp: '8.8.4.4' },
|
||||
{ count: 5, sourceIp: '1.1.1.1' },
|
||||
]),
|
||||
routeManager: { getRoutes: () => [] },
|
||||
},
|
||||
securityPolicyManager: {
|
||||
queueObservedIps: () => undefined,
|
||||
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
|
||||
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
|
||||
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
|
||||
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const stats = await manager.getNetworkStats();
|
||||
|
||||
expect(stats.topASNs).toHaveLength(2);
|
||||
expect(stats.topASNs[0].asn).toEqual(15169);
|
||||
expect(stats.topASNs[0].organization).toEqual('Google LLC');
|
||||
expect(stats.topASNs[0].activeConnections).toEqual(7);
|
||||
expect(stats.topASNs[0].ipCount).toEqual(2);
|
||||
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
|
||||
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
|
||||
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
|
||||
expect(stats.topASNs[1].asn).toEqual(13335);
|
||||
expect(stats.topASNs[1].activeConnections).toEqual(5);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,225 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { createMigrationRunner } from '../ts_migrations/index.js';
|
||||
|
||||
function setPath(target: Record<string, any>, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let cursor = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
cursor[part] = cursor[part] || {};
|
||||
cursor = cursor[part];
|
||||
}
|
||||
cursor[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function getPath(target: Record<string, any>, path: string): unknown {
|
||||
let cursor: any = target;
|
||||
for (const part of path.split('.')) {
|
||||
if (cursor === null || cursor === undefined) return undefined;
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(set)) {
|
||||
setPath(document, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
|
||||
for (const [key, expected] of Object.entries(query)) {
|
||||
const actual = getPath(document, key);
|
||||
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
|
||||
if ('$exists' in expected) {
|
||||
const exists = actual !== undefined;
|
||||
if (exists !== Boolean(expected.$exists)) return false;
|
||||
continue;
|
||||
}
|
||||
if ('$type' in expected) {
|
||||
if (expected.$type === 'string' && typeof actual !== 'string') return false;
|
||||
continue;
|
||||
}
|
||||
if ('$in' in expected) {
|
||||
if (!Array.isArray(expected.$in) || !expected.$in.includes(actual)) return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (actual !== expected) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
return {
|
||||
findOne: async (query: Record<string, any> = {}) => {
|
||||
const document = documents.find((candidate) => matchesQuery(candidate, query));
|
||||
return document ? structuredClone(document) : null;
|
||||
},
|
||||
find: (query: Record<string, any> = {}) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const document of documents) {
|
||||
if (matchesQuery(document, query)) {
|
||||
yield structuredClone(document);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
insertOne: async (document: Record<string, any>) => {
|
||||
documents.push(structuredClone(document));
|
||||
return { insertedId: document._id || document.id };
|
||||
},
|
||||
updateMany: async (query: Record<string, any>, update: any) => {
|
||||
let modifiedCount = 0;
|
||||
for (const document of documents) {
|
||||
if (!matchesQuery(document, query)) continue;
|
||||
applySet(document, update.$set || {});
|
||||
modifiedCount++;
|
||||
}
|
||||
return { modifiedCount };
|
||||
},
|
||||
updateOne: async (query: Record<string, any>, update: any) => {
|
||||
const document = documents.find((candidate) => matchesQuery(candidate, query));
|
||||
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
|
||||
applySet(document, update.$set || {});
|
||||
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeDb(
|
||||
currentVersion: string,
|
||||
collections: Record<string, Array<Record<string, any>>> = {},
|
||||
) {
|
||||
const ledgerDocument = {
|
||||
nameId: 'smartmigration:smartmigration',
|
||||
data: {
|
||||
currentVersion,
|
||||
steps: {},
|
||||
lock: { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: {},
|
||||
},
|
||||
};
|
||||
|
||||
const fakeCollections = new Map(
|
||||
Object.entries(collections).map(([name, documents]) => [name, createFakeCollection(documents)]),
|
||||
);
|
||||
const emptyCollection = createFakeCollection();
|
||||
|
||||
const ledgerCollection = {
|
||||
createIndex: async () => undefined,
|
||||
findOne: async () => structuredClone(ledgerDocument),
|
||||
findOneAndUpdate: async (_query: unknown, update: any) => {
|
||||
applySet(ledgerDocument, update.$set || {});
|
||||
return structuredClone(ledgerDocument);
|
||||
},
|
||||
updateOne: async (_query: unknown, update: any) => {
|
||||
applySet(ledgerDocument, update.$set || {});
|
||||
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
mongoDb: {
|
||||
collection: (name: string) =>
|
||||
name === 'SmartdataEasyStore'
|
||||
? ledgerCollection
|
||||
: fakeCollections.get(name) || emptyCollection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('migration runner applies schema steps through the current target', async () => {
|
||||
const sourceProfiles: Array<Record<string, any>> = [];
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.16.0', { SourceProfileDoc: sourceProfiles }),
|
||||
'13.42.0',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.currentVersionBefore).toEqual('13.16.0');
|
||||
expect(result.currentVersionAfter).toEqual('13.42.0');
|
||||
expect(result.stepsApplied).toHaveLength(4);
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
|
||||
});
|
||||
|
||||
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
|
||||
const profiles: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'profile-doc-1',
|
||||
id: 'standard-profile',
|
||||
name: 'Standard',
|
||||
security: {
|
||||
ipAllowList: ['192.168.*', '127.0.0.1'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
},
|
||||
];
|
||||
const routes: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'route-doc-1',
|
||||
id: 'route-1',
|
||||
route: {
|
||||
name: 'Public service domains',
|
||||
match: { ports: 443, domains: ['code.foss.global'] },
|
||||
action: { type: 'forward', targets: [{ host: '192.168.5.247', port: 443 }] },
|
||||
security: {
|
||||
ipAllowList: ['192.168.*', '*'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
sourceProfileRef: 'standard-profile',
|
||||
sourceProfileName: 'Standard',
|
||||
},
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.40.1', {
|
||||
SourceProfileDoc: profiles,
|
||||
RouteDoc: routes,
|
||||
}),
|
||||
'13.40.2',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(routes[0].route.security.ipAllowList.includes('*')).toBeFalse();
|
||||
expect(routes[0].route.security.ipAllowList).toContain('192.168.*');
|
||||
expect(routes[0].route.security.maxConnections).toEqual(1000);
|
||||
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('migration runner seeds only missing default source profiles', async () => {
|
||||
const sourceProfiles: Array<Record<string, any>> = [
|
||||
{
|
||||
id: 'public-profile',
|
||||
name: 'PUBLIC',
|
||||
description: 'Existing public profile',
|
||||
security: { ipAllowList: ['*'] },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.40.2', { SourceProfileDoc: sourceProfiles }),
|
||||
'13.42.0',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
const publicProfiles = sourceProfiles.filter((profile) => profile.name === 'PUBLIC');
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(sourceProfiles).toHaveLength(3);
|
||||
expect(publicProfiles).toHaveLength(1);
|
||||
expect(publicProfiles[0].security.rateLimit).toBeUndefined();
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,66 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async (tools) => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
|
||||
// Basic checks
|
||||
expect(reputationData).toBeTruthy();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(canSendMore !== undefined).toBeTrue();
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { getOciContainerConfig } from '../ts_oci_container/index.js';
|
||||
|
||||
tap.test('OCI config should accept explicit DNS bind interface', async () => {
|
||||
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
|
||||
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
|
||||
|
||||
try {
|
||||
const config = getOciContainerConfig();
|
||||
expect(config.dnsBindInterface).toEqual('192.168.190.3');
|
||||
} finally {
|
||||
if (previousValue === undefined) {
|
||||
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
|
||||
} else {
|
||||
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,126 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { requireOpsAuth } from '../ts/opsserver/helpers/auth.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const makeIdentity = (role: string = 'user'): interfaces.data.IIdentity => ({
|
||||
jwt: `jwt-${role}`,
|
||||
userId: `${role}-user`,
|
||||
name: role,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
role,
|
||||
});
|
||||
|
||||
const makeOpsServer = (options: {
|
||||
identityRole?: string | null;
|
||||
tokenScopes?: TScope[];
|
||||
tokenPolicy?: interfaces.data.IApiTokenPolicy;
|
||||
}) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'test-token',
|
||||
tokenHash: 'hash',
|
||||
scopes: options.tokenScopes || [],
|
||||
policy: options.tokenPolicy,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
createdBy: 'token-user',
|
||||
enabled: true,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
adminHandler: {
|
||||
validateIdentity: async (identityArg?: interfaces.data.IIdentity) => {
|
||||
if (!identityArg || options.identityRole === null) return null;
|
||||
return { ...identityArg, role: options.identityRole || identityArg.role || 'user' };
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
apiTokenManager: {
|
||||
validateToken: async (rawTokenArg: string) => rawTokenArg === 'valid-token' ? token : null,
|
||||
hasScope: (storedTokenArg: interfaces.data.IStoredApiToken, scopeArg: TScope) => {
|
||||
if (storedTokenArg.policy?.role === 'admin') return true;
|
||||
return storedTokenArg.scopes.includes('*') || storedTokenArg.scopes.includes(scopeArg) || Boolean(storedTokenArg.policy?.scopes?.includes(scopeArg));
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const getErrorText = (errorArg: unknown) => {
|
||||
return (errorArg as any).errorText || (errorArg as any).text || (errorArg as Error).message;
|
||||
};
|
||||
|
||||
tap.test('requireOpsAuth accepts valid JWT identity for read endpoints', async () => {
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: 'user' }),
|
||||
{ identity: makeIdentity('user') },
|
||||
{ scope: 'config:read' },
|
||||
);
|
||||
expect(auth.type).toEqual('identity');
|
||||
expect(auth.userId).toEqual('user-user');
|
||||
expect(auth.isAdmin).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth rejects non-admin JWT identity for admin identity requirements', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: 'user' }),
|
||||
{ identity: makeIdentity('user') },
|
||||
{ scope: 'routes:write', requireAdminIdentity: true },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('admin identity required');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth accepts scoped API tokens', async () => {
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'logs:read' },
|
||||
);
|
||||
expect(auth.type).toEqual('apiToken');
|
||||
expect(auth.userId).toEqual('token-user');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth rejects API tokens without the required scope', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['logs:read'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'stats:read' },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
tap.test('requireOpsAuth requires admin policy for sensitive API-token operations', async () => {
|
||||
let errorText = '';
|
||||
try {
|
||||
await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenScopes: ['tokens:manage'] }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'tokens:manage', requireAdminToken: true },
|
||||
);
|
||||
} catch (error) {
|
||||
errorText = getErrorText(error);
|
||||
}
|
||||
expect(errorText).toEqual('admin API token required');
|
||||
|
||||
const auth = await requireOpsAuth(
|
||||
makeOpsServer({ identityRole: null, tokenPolicy: { role: 'admin' } }),
|
||||
{ apiToken: 'valid-token' },
|
||||
{ scope: 'tokens:manage', requireAdminToken: true },
|
||||
);
|
||||
expect(auth.isAdmin).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,149 @@
|
||||
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';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
let opsServerPort: number;
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function typedRequestUrl(): string {
|
||||
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort,
|
||||
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>(
|
||||
typedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: testAdminPassword,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
typedRequestUrl(),
|
||||
'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>(
|
||||
typedRequestUrl(),
|
||||
'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>(
|
||||
typedRequestUrl(),
|
||||
'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>(
|
||||
typedRequestUrl(),
|
||||
'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>(
|
||||
typedRequestUrl(),
|
||||
'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();
|
||||
@@ -0,0 +1,153 @@
|
||||
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';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
let opsServerPort: number;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTypedRequestUrl(): string {
|
||||
return `http://localhost:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort,
|
||||
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>(
|
||||
getTypedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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>(
|
||||
getTypedRequestUrl(),
|
||||
'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();
|
||||
@@ -1,141 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { RateLimiter } from '../ts/mta/classes.ratelimiter.js';
|
||||
|
||||
tap.test('RateLimiter - should be instantiable', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
expect(limiter).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 5,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 5 requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 6th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 3 requests for key1
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.isAllowed('key1')).toEqual(true);
|
||||
}
|
||||
|
||||
// 4th request for key1 should be denied
|
||||
expect(limiter.isAllowed('key1')).toEqual(false);
|
||||
|
||||
// But key2 should still be allowed
|
||||
expect(limiter.isAllowed('key2')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should refill tokens over time', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100, // Short period for testing
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should support burst allowance', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100,
|
||||
perKey: true,
|
||||
burstTokens: 2, // Allow 2 extra tokens for bursts
|
||||
initialTokens: 4 // Start with max + burst tokens
|
||||
});
|
||||
|
||||
// Should allow 4 requests (2 regular + 2 burst)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 5th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
|
||||
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should return correct stats', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Make some requests
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
|
||||
// Get stats
|
||||
const stats = limiter.getStats('test');
|
||||
|
||||
expect(stats.remaining).toEqual(7);
|
||||
expect(stats.limit).toEqual(10);
|
||||
expect(stats.allowed).toEqual(3);
|
||||
expect(stats.denied).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should reset limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Reset
|
||||
limiter.reset('test');
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,409 @@
|
||||
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 replace inline route security when source profile is selected', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
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!.includes('127.0.0.1')).toBeFalse();
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
storedRoutes.set('route-d', {
|
||||
id: 'route-d',
|
||||
route: makeRoute({ name: 'route-d' }),
|
||||
enabled: true,
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(3);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
expect(profileRefs).toContain('route-d');
|
||||
|
||||
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 resolve source policy binding display names', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourcePolicy!.bindings[0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -1,243 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const reputationDataPath = plugins.path.join(paths.dataDir, 'reputation');
|
||||
if (plugins.fs.existsSync(reputationDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(reputationDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
SenderReputationMonitor.instance = null;
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of SenderReputationMonitor
|
||||
tap.test('should initialize SenderReputationMonitor with default settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance();
|
||||
|
||||
expect(reputationMonitor).toBeTruthy();
|
||||
// Check if the object has the expected methods
|
||||
expect(typeof reputationMonitor.recordSendEvent).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationData).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationSummary).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize SenderReputationMonitor with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com', 'test.com'],
|
||||
updateFrequency: 12 * 60 * 60 * 1000, // 12 hours
|
||||
alertThresholds: {
|
||||
minReputationScore: 80,
|
||||
maxComplaintRate: 0.05
|
||||
}
|
||||
});
|
||||
|
||||
// Test adding domains
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Test retrieving reputation data
|
||||
const data = reputationMonitor.getReputationData('example.com');
|
||||
expect(data).toBeTruthy();
|
||||
expect(data.domain).toEqual('example.com');
|
||||
});
|
||||
|
||||
// Test recording and tracking send events
|
||||
tap.test('should record send events and update metrics', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record a series of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', count: 1 });
|
||||
|
||||
// Check metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
});
|
||||
|
||||
// Test reputation score calculation
|
||||
tap.test('should calculate reputation scores correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['high.com', 'medium.com', 'low.com']
|
||||
});
|
||||
|
||||
// Record events for different domains
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'delivered', count: 990 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'open', count: 500 });
|
||||
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'delivered', count: 950 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'open', count: 300 });
|
||||
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'delivered', count: 850 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'open', count: 100 });
|
||||
|
||||
// Get reputation summary
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(Array.isArray(summary)).toBeTrue();
|
||||
expect(summary.length >= 3).toBeTrue();
|
||||
|
||||
// Check that domains are included in the summary
|
||||
const domains = summary.map(item => item.domain);
|
||||
expect(domains.includes('high.com')).toBeTrue();
|
||||
expect(domains.includes('medium.com')).toBeTrue();
|
||||
expect(domains.includes('low.com')).toBeTrue();
|
||||
});
|
||||
|
||||
// Test adding and removing domains
|
||||
tap.test('should add and remove domains for monitoring', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Add a new domain
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Record data for the new domain
|
||||
reputationMonitor.recordSendEvent('newdomain.com', { type: 'sent', count: 50 });
|
||||
|
||||
// Check that data was recorded for the new domain
|
||||
const metrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(50);
|
||||
|
||||
// Remove a domain
|
||||
reputationMonitor.removeDomain('newdomain.com');
|
||||
|
||||
// Check that data is no longer available
|
||||
const removedMetrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(removedMetrics === null).toBeTrue();
|
||||
});
|
||||
|
||||
// Test handling open and click events
|
||||
tap.test('should track engagement metrics correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record basic sending metrics
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Record engagement events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 500 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 250 });
|
||||
|
||||
// Check engagement metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.engagement.opens).toEqual(500);
|
||||
expect(metrics.engagement.clicks).toEqual(250);
|
||||
expect(typeof metrics.engagement.openRate).toEqual('number');
|
||||
expect(typeof metrics.engagement.clickRate).toEqual('number');
|
||||
});
|
||||
|
||||
// Test historical data tracking
|
||||
tap.test('should store historical reputation data', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record events over multiple days
|
||||
const today = new Date();
|
||||
|
||||
// Record data
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Get metrics data
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check that historical data exists
|
||||
expect(metrics.historical).toBeTruthy();
|
||||
expect(metrics.historical.reputationScores).toBeTruthy();
|
||||
|
||||
// Check that daily send volume is tracked
|
||||
expect(metrics.volume.dailySendVolume).toBeTruthy();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
expect(metrics.volume.dailySendVolume[todayStr]).toEqual(1000);
|
||||
});
|
||||
|
||||
// Test event recording for different event types
|
||||
tap.test('should correctly handle different event types', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record different types of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', receivingDomain: 'gmail.com', count: 1 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 50 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 25 });
|
||||
|
||||
// Check metrics for different event types
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check volume metrics
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
|
||||
// Check complaint metrics
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
expect(metrics.complaints.topDomains[0].domain).toEqual('gmail.com');
|
||||
|
||||
// Check engagement metrics
|
||||
expect(metrics.engagement.opens).toEqual(50);
|
||||
expect(metrics.engagement.clicks).toEqual(25);
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,200 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js';
|
||||
import { SecurityPolicyManager } from '../ts/security/index.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-security-policy-${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 rule of await SecurityBlockRuleDoc.findAll()) {
|
||||
await rule.delete();
|
||||
}
|
||||
for (const record of await IpIntelligenceDoc.findAll()) {
|
||||
await record.delete();
|
||||
}
|
||||
for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) {
|
||||
await event.delete();
|
||||
}
|
||||
};
|
||||
|
||||
const createIntelligenceResult = (asn: number) => ({
|
||||
asn,
|
||||
asnOrg: `ASN ${asn}`,
|
||||
registrantOrg: null,
|
||||
registrantCountry: null,
|
||||
networkRange: null,
|
||||
networkCidrs: null,
|
||||
abuseContact: null,
|
||||
country: null,
|
||||
countryCode: 'US',
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadius: null,
|
||||
timezone: null,
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'cidr',
|
||||
value: '203.0.113.0 - 203.0.113.255',
|
||||
reason: 'test range',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']);
|
||||
|
||||
const firewall = await manager.compileRemoteIngressFirewall();
|
||||
expect(firewall.blockedIps).toEqual(['203.0.113.0/24']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = '198.51.100.23';
|
||||
intelligenceDoc.asn = 64500;
|
||||
intelligenceDoc.asnOrg = 'Example Network';
|
||||
intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127';
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'asn',
|
||||
value: 'AS64500',
|
||||
reason: 'test asn range',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = '198.51.100.130';
|
||||
intelligenceDoc.asn = 64501;
|
||||
intelligenceDoc.asnOrg = 'Example Split Network';
|
||||
intelligenceDoc.networkRange = null;
|
||||
intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24'];
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
|
||||
await manager.createBlockRule({
|
||||
type: 'asn',
|
||||
value: 'AS64501',
|
||||
reason: 'test asn cidr array',
|
||||
});
|
||||
|
||||
const policy = await manager.compilePolicy();
|
||||
expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']);
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
const firewall = await manager.compileRemoteIngressFirewall();
|
||||
expect(firewall).toEqual({ blockedIps: [] });
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager filters listed IP intelligence records', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager();
|
||||
|
||||
for (const [ipAddress, asn] of [['8.8.8.8', 15169], ['1.1.1.1', 13335]] as const) {
|
||||
const intelligenceDoc = new IpIntelligenceDoc();
|
||||
intelligenceDoc.ipAddress = ipAddress;
|
||||
intelligenceDoc.asn = asn;
|
||||
intelligenceDoc.asnOrg = `ASN ${asn}`;
|
||||
intelligenceDoc.firstSeenAt = Date.now();
|
||||
intelligenceDoc.lastSeenAt = Date.now();
|
||||
intelligenceDoc.updatedAt = Date.now();
|
||||
intelligenceDoc.seenCount = 1;
|
||||
await intelligenceDoc.save();
|
||||
}
|
||||
|
||||
const records = await manager.listIpIntelligence({ ipAddresses: ['1.1.1.1'] });
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].ipAddress).toEqual('1.1.1.1');
|
||||
});
|
||||
|
||||
tap.test('SecurityPolicyManager force refresh waits for an in-flight background observation', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
const manager = new SecurityPolicyManager({ intelligenceRefreshMs: 0 });
|
||||
|
||||
let releaseFirstLookup!: () => void;
|
||||
let lookupCount = 0;
|
||||
(manager as any).smartNetwork = {
|
||||
getIpIntelligence: async () => {
|
||||
lookupCount++;
|
||||
if (lookupCount === 1) {
|
||||
await new Promise<void>((resolve) => { releaseFirstLookup = resolve; });
|
||||
return createIntelligenceResult(64500);
|
||||
}
|
||||
return createIntelligenceResult(64501);
|
||||
},
|
||||
stop: async () => {},
|
||||
};
|
||||
|
||||
const backgroundObservation = manager.observeIp('8.8.8.8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const forcedRefresh = manager.refreshIpIntelligence('8.8.8.8');
|
||||
releaseFirstLookup();
|
||||
|
||||
const record = await forcedRefresh;
|
||||
await backgroundObservation;
|
||||
|
||||
expect(lookupCount).toEqual(2);
|
||||
expect(record?.asn).toEqual(64501);
|
||||
});
|
||||
|
||||
tap.test('cleanup security policy test db', async () => {
|
||||
const dbHandle = await testDbPromise;
|
||||
await clearTestState();
|
||||
await dbHandle.cleanup();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,248 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
|
||||
// Import the components we want to test
|
||||
import { EmailValidator } from '../ts/email/classes.emailvalidator.js';
|
||||
import { TemplateManager } from '../ts/email/classes.templatemanager.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
|
||||
// Ensure test directories exist
|
||||
paths.ensureDirectories();
|
||||
|
||||
tap.test('EmailValidator - should validate email formats correctly', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
// Test valid email formats
|
||||
expect(validator.isValidFormat('user@example.com')).toBeTrue();
|
||||
expect(validator.isValidFormat('firstname.lastname@example.com')).toBeTrue();
|
||||
expect(validator.isValidFormat('user+tag@example.com')).toBeTrue();
|
||||
|
||||
// Test invalid email formats
|
||||
expect(validator.isValidFormat('user@')).toBeFalse();
|
||||
expect(validator.isValidFormat('@example.com')).toBeFalse();
|
||||
expect(validator.isValidFormat('user@example')).toBeFalse();
|
||||
expect(validator.isValidFormat('user.example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('EmailValidator - should perform comprehensive validation', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
// Test basic validation (syntax-only)
|
||||
const basicResult = await validator.validate('user@example.com', { checkSyntaxOnly: true });
|
||||
expect(basicResult.isValid).toBeTrue();
|
||||
expect(basicResult.details.formatValid).toBeTrue();
|
||||
|
||||
// We can't reliably test MX validation in all environments, but the function should run
|
||||
const mxResult = await validator.validate('user@example.com', { checkMx: true });
|
||||
expect(typeof mxResult.isValid).toEqual('boolean');
|
||||
expect(typeof mxResult.hasMx).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('EmailValidator - should detect invalid emails', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
const invalidResult = await validator.validate('invalid@@example.com', { checkSyntaxOnly: true });
|
||||
expect(invalidResult.isValid).toBeFalse();
|
||||
expect(invalidResult.details.formatValid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('TemplateManager - should register and retrieve templates', async (tools) => {
|
||||
const templateManager = new TemplateManager({
|
||||
from: 'test@example.com'
|
||||
});
|
||||
|
||||
// Register a custom template
|
||||
templateManager.registerTemplate({
|
||||
id: 'test-template',
|
||||
name: 'Test Template',
|
||||
description: 'A test template',
|
||||
from: 'test@example.com',
|
||||
subject: 'Test Subject: {{name}}',
|
||||
bodyHtml: '<p>Hello, {{name}}!</p>',
|
||||
bodyText: 'Hello, {{name}}!',
|
||||
category: 'test'
|
||||
});
|
||||
|
||||
// Get the template back
|
||||
const template = templateManager.getTemplate('test-template');
|
||||
expect(template).toBeTruthy();
|
||||
expect(template.id).toEqual('test-template');
|
||||
expect(template.subject).toEqual('Test Subject: {{name}}');
|
||||
|
||||
// List templates
|
||||
const templates = templateManager.listTemplates();
|
||||
expect(templates.length > 0).toBeTrue();
|
||||
expect(templates.some(t => t.id === 'test-template')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('TemplateManager - should create smartmail from template', async (tools) => {
|
||||
const templateManager = new TemplateManager({
|
||||
from: 'test@example.com'
|
||||
});
|
||||
|
||||
// Register a template
|
||||
templateManager.registerTemplate({
|
||||
id: 'welcome-test',
|
||||
name: 'Welcome Test',
|
||||
description: 'A welcome test template',
|
||||
from: 'welcome@example.com',
|
||||
subject: 'Welcome, {{name}}!',
|
||||
bodyHtml: '<p>Hello, {{name}}! Welcome to our service.</p>',
|
||||
bodyText: 'Hello, {{name}}! Welcome to our service.',
|
||||
category: 'test'
|
||||
});
|
||||
|
||||
// Create smartmail from template
|
||||
const smartmail = await templateManager.createSmartmail('welcome-test', {
|
||||
name: 'John Doe'
|
||||
});
|
||||
|
||||
expect(smartmail).toBeTruthy();
|
||||
expect(smartmail.options.from).toEqual('welcome@example.com');
|
||||
expect(smartmail.getSubject()).toEqual('Welcome, John Doe!');
|
||||
expect(smartmail.getBody(true).indexOf('Hello, John Doe!') > -1).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Email - should handle template variables', async (tools) => {
|
||||
// Create email with variables
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Hello {{name}}!',
|
||||
text: 'Welcome, {{name}}! Your order #{{orderId}} has been processed.',
|
||||
html: '<p>Welcome, <strong>{{name}}</strong>! Your order #{{orderId}} has been processed.</p>',
|
||||
variables: {
|
||||
name: 'John Doe',
|
||||
orderId: '12345'
|
||||
}
|
||||
});
|
||||
|
||||
// Test variable substitution
|
||||
expect(email.getSubjectWithVariables()).toEqual('Hello John Doe!');
|
||||
expect(email.getTextWithVariables()).toEqual('Welcome, John Doe! Your order #12345 has been processed.');
|
||||
expect(email.getHtmlWithVariables().indexOf('<strong>John Doe</strong>') > -1).toBeTrue();
|
||||
|
||||
// Test with additional variables
|
||||
const additionalVars = {
|
||||
name: 'Jane Smith', // Override existing variable
|
||||
status: 'shipped' // Add new variable
|
||||
};
|
||||
|
||||
expect(email.getSubjectWithVariables(additionalVars)).toEqual('Hello Jane Smith!');
|
||||
|
||||
// Add a new variable
|
||||
email.setVariable('trackingNumber', 'TRK123456');
|
||||
expect(email.getTextWithVariables().indexOf('12345') > -1).toBeTrue();
|
||||
|
||||
// Update multiple variables at once
|
||||
email.setVariables({
|
||||
orderId: '67890',
|
||||
status: 'delivered'
|
||||
});
|
||||
|
||||
expect(email.getTextWithVariables().indexOf('67890') > -1).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Email and Smartmail compatibility - should convert between formats', async (tools) => {
|
||||
// Create a Smartmail instance
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: 'smartmail@example.com',
|
||||
subject: 'Test Subject',
|
||||
body: '<p>This is a test email.</p>',
|
||||
creationObjectRef: {
|
||||
orderId: '12345'
|
||||
}
|
||||
});
|
||||
|
||||
// Add recipient and attachment
|
||||
smartmail.addRecipient('recipient@example.com');
|
||||
|
||||
const attachment = await plugins.smartfile.SmartFile.fromString(
|
||||
'test.txt',
|
||||
'This is a test attachment',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
smartmail.addAttachment(attachment);
|
||||
|
||||
// Convert to Email
|
||||
const resolvedSmartmail = await smartmail;
|
||||
const email = Email.fromSmartmail(resolvedSmartmail);
|
||||
|
||||
// Verify first conversion (Smartmail to Email)
|
||||
expect(email.from).toEqual('smartmail@example.com');
|
||||
expect(email.to.indexOf('recipient@example.com') > -1).toBeTrue();
|
||||
expect(email.subject).toEqual('Test Subject');
|
||||
expect(email.html?.indexOf('This is a test email') > -1).toBeTrue();
|
||||
expect(email.attachments.length).toEqual(1);
|
||||
|
||||
// Convert back to Smartmail
|
||||
const convertedSmartmail = await email.toSmartmail();
|
||||
|
||||
// Verify second conversion (Email back to Smartmail) with simplified assertions
|
||||
expect(convertedSmartmail.options.from).toEqual('smartmail@example.com');
|
||||
expect(Array.isArray(convertedSmartmail.options.to)).toBeTrue();
|
||||
expect(convertedSmartmail.options.to.length).toEqual(1);
|
||||
expect(convertedSmartmail.getSubject()).toEqual('Test Subject');
|
||||
expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toBeTrue();
|
||||
expect(convertedSmartmail.attachments.length).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('Email - should validate email addresses', async (tools) => {
|
||||
// Attempt to create an email with invalid addresses
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
const email = new Email({
|
||||
from: 'invalid-email',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message.indexOf('Invalid sender email address') > -1).toBeTrue();
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
|
||||
// Attempt with invalid recipient
|
||||
errorThrown = false;
|
||||
|
||||
try {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'invalid-recipient',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message.indexOf('Invalid recipient email address') > -1).toBeTrue();
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
|
||||
// Valid email should not throw
|
||||
let validEmail: Email;
|
||||
try {
|
||||
validEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
|
||||
expect(validEmail).toBeTruthy();
|
||||
expect(validEmail.from).toEqual('sender@example.com');
|
||||
} catch (error) {
|
||||
expect(error === undefined).toBeTrue(); // This should not happen
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
tap.stopForcefully();
|
||||
})
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import * as http from 'node:http';
|
||||
import * as net from 'node:net';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startBackend(
|
||||
handler: http.RequestListener = (_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('ok');
|
||||
},
|
||||
): Promise<{ server: http.Server; port: number }> {
|
||||
const server = http.createServer(handler);
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
return { server, port };
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
async function requestHeaders(
|
||||
port: number,
|
||||
path: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<http.IncomingMessage> {
|
||||
return await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
|
||||
request.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of response) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
|
||||
const backend = await startBackend();
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
name: 'rate-limit-smoke',
|
||||
match: {
|
||||
ports: proxyPort,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: backend.port }],
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 1,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'too many requests',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
||||
const firstBody = await firstResponse.text();
|
||||
const secondBody = await secondResponse.text();
|
||||
|
||||
expect(firstResponse.status).toEqual(200);
|
||||
expect(firstBody).toEqual('ok');
|
||||
expect(secondResponse.status).toEqual(429);
|
||||
expect(secondBody).toContain('too many requests');
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(backend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
|
||||
const limitedBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('limited');
|
||||
});
|
||||
const fallbackBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('fallback');
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'terminal-rate-limit',
|
||||
name: 'terminal-rate-limit',
|
||||
priority: 10,
|
||||
match: { ports: proxyPort, domains: 'limited.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 1,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'limited route exceeded',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lower-priority-fallback',
|
||||
name: 'lower-priority-fallback',
|
||||
priority: 0,
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
|
||||
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
|
||||
const firstBody = await readResponseBody(firstResponse);
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(firstBody).toEqual('limited');
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
expect(secondBody).toContain('limited route exceeded');
|
||||
expect(secondBody.includes('fallback')).toBeFalse();
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(limitedBackend.server),
|
||||
closeServer(fallbackBackend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
|
||||
let firstResponse: http.IncomingMessage | undefined;
|
||||
let secondResponse: http.IncomingMessage | undefined;
|
||||
let releaseResponse: (() => void) | undefined;
|
||||
const releasePromise = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
const backend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.flushHeaders();
|
||||
void releasePromise.then(() => response.end('released'));
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'max-connections-smoke',
|
||||
name: 'max-connections-smoke',
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: backend.port }],
|
||||
},
|
||||
security: {
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
firstResponse = await requestHeaders(proxyPort, '/hold');
|
||||
secondResponse = await requestHeaders(proxyPort, '/blocked');
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
releaseResponse?.();
|
||||
expect(await readResponseBody(firstResponse)).toEqual('released');
|
||||
expect(secondBody.length > 0).toBeTrue();
|
||||
} finally {
|
||||
releaseResponse?.();
|
||||
firstResponse?.destroy();
|
||||
secondResponse?.destroy();
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(backend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
|
||||
let firstResponse: http.IncomingMessage | undefined;
|
||||
let secondResponse: http.IncomingMessage | undefined;
|
||||
let releaseResponse: (() => void) | undefined;
|
||||
const releasePromise = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
const limitedBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.flushHeaders();
|
||||
void releasePromise.then(() => response.end('limited released'));
|
||||
});
|
||||
const fallbackBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('fallback');
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'terminal-max-connections',
|
||||
name: 'terminal-max-connections',
|
||||
priority: 10,
|
||||
match: { ports: proxyPort, domains: 'limited.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
|
||||
},
|
||||
security: {
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'max-connections-lower-priority-fallback',
|
||||
name: 'max-connections-lower-priority-fallback',
|
||||
priority: 0,
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
|
||||
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
releaseResponse?.();
|
||||
const firstBody = await readResponseBody(firstResponse);
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(firstBody).toEqual('limited released');
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
expect(secondBody.includes('fallback')).toBeFalse();
|
||||
} finally {
|
||||
releaseResponse?.();
|
||||
firstResponse?.destroy();
|
||||
secondResponse?.destroy();
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(limitedBackend.server),
|
||||
closeServer(fallbackBackend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,915 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
||||
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
|
||||
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
|
||||
function makeRoute(): IRouteConfig {
|
||||
return {
|
||||
id: 'route-1',
|
||||
name: 'gitea',
|
||||
priority: 10,
|
||||
match: { ports: 443, domains: 'code.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
|
||||
};
|
||||
}
|
||||
|
||||
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
|
||||
return {
|
||||
description: '',
|
||||
security: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
...profile,
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('source policy compiler expands one route into ordered source variants', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'ai',
|
||||
name: 'AI Crawlers',
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.0/24'],
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'ai' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
|
||||
expect(variants.length).toEqual(3);
|
||||
expect(variants[0].name).toEqual('gitea:source:Trusted');
|
||||
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
|
||||
expect(variants[0].security?.ipAllowList).toBeUndefined();
|
||||
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
|
||||
expect(variants[2].match.clientIp).toBeUndefined();
|
||||
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority);
|
||||
});
|
||||
|
||||
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
||||
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
|
||||
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
|
||||
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
|
||||
});
|
||||
|
||||
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 120,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-forwarded-for',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-client-id',
|
||||
},
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/git'],
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants).toHaveLength(2);
|
||||
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
|
||||
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
|
||||
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
|
||||
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'ai',
|
||||
name: 'AI Crawlers',
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.0/24'],
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'ai',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants.length).toEqual(3);
|
||||
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
|
||||
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
|
||||
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
|
||||
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
|
||||
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
|
||||
expect(variants[1].match.path).toBeUndefined();
|
||||
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
|
||||
expect(variants[2].name).toEqual('gitea:source:Public');
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants.map((variant) => variant.match.path)).toEqual([
|
||||
'/*/*.git/info/refs',
|
||||
'/*/*.git/git-upload-pack',
|
||||
'/*/*.git/git-receive-pack',
|
||||
'/*/*.git/info/lfs',
|
||||
'/*/*.git/info/lfs/*',
|
||||
undefined,
|
||||
]);
|
||||
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
|
||||
expect(variants[5].id).toEqual('route-1:source:public');
|
||||
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
|
||||
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
|
||||
|
||||
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
|
||||
pathClass: 'git-smart-http' as const,
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
|
||||
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
|
||||
),
|
||||
}));
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).toContain('compiled route variants');
|
||||
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'empty-ai',
|
||||
name: 'Empty AI',
|
||||
security: {
|
||||
ipAllowList: [],
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(emptyProfileVariants.length).toEqual(0);
|
||||
expect(missingResolverVariants.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const route = makeRoute();
|
||||
route.priority = 10000;
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
route,
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants.length > 0).toBeTrue();
|
||||
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const appliedRoutes: IRouteConfig[][] = [];
|
||||
const manager = new RouteConfigManager(
|
||||
() => ({
|
||||
updateRoutes: async (routes: IRouteConfig[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
} as any),
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await manager.applyRoutes();
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(2);
|
||||
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
|
||||
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
|
||||
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'empty-ai',
|
||||
name: 'Empty AI',
|
||||
security: {
|
||||
ipAllowList: [],
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
}));
|
||||
|
||||
const appliedRoutes: IRouteConfig[][] = [];
|
||||
const manager = new RouteConfigManager(
|
||||
() => ({
|
||||
updateRoutes: async (routes: IRouteConfig[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
} as any),
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await manager.applyRoutes();
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Wildcard source profile bindings must be last');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('trusted');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'missing' not found");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'empty-ai',
|
||||
name: 'Empty AI',
|
||||
security: { ipAllowList: [] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies without a final all-source fallback', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Source policy must end with an all-source fallback profile');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('public');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
route: {
|
||||
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('compiled route-port variants');
|
||||
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('maxConnections');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].maxConnections).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('rate limit error message');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].rateLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'public',
|
||||
name: 'Public',
|
||||
security: { ipAllowList: ['*'] },
|
||||
}));
|
||||
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
() => ({ enabled: false }),
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
||||
(_item, index) => `/too-many-${index}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('path patterns');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,214 @@
|
||||
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`;
|
||||
const TEST_ADMIN_PASSWORD = 'test-admin-password';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
// ============================================================================
|
||||
// Setup — db disabled, handlers return graceful fallbacks
|
||||
// ============================================================================
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = TEST_ADMIN_PASSWORD;
|
||||
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: TEST_ADMIN_PASSWORD,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
const responseIdentity = response.identity;
|
||||
if (!responseIdentity) {
|
||||
throw new Error('Expected admin login response to include identity');
|
||||
}
|
||||
adminIdentity = responseIdentity;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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();
|
||||
@@ -1,9 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
tap.test('should create a platform service', async () => {});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,471 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
||||
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
||||
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
|
||||
|
||||
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'socket' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
(manager as any).resolvedForwardingMode = (manager as any).forwardingModeOverride ?? 'socket';
|
||||
(manager as any).forwardingModeOverride = undefined;
|
||||
(manager as any).vpnServer = { running: true };
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(true);
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(startCalls).toEqual(1);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('socket');
|
||||
});
|
||||
|
||||
tap.test('VpnManager keeps explicit hybrid mode even without host-IP clients', async () => {
|
||||
const manager = new VpnManager({ forwardingMode: 'hybrid' });
|
||||
|
||||
let stopCalls = 0;
|
||||
let startCalls = 0;
|
||||
|
||||
(manager as any).vpnServer = { running: true };
|
||||
(manager as any).resolvedForwardingMode = 'hybrid';
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { useHostIp: false }],
|
||||
]);
|
||||
(manager as any).stop = async () => {
|
||||
stopCalls++;
|
||||
};
|
||||
(manager as any).start = async () => {
|
||||
startCalls++;
|
||||
};
|
||||
|
||||
const restarted = await (manager as any).reconcileForwardingMode();
|
||||
|
||||
expect(restarted).toEqual(false);
|
||||
expect(stopCalls).toEqual(0);
|
||||
expect(startCalls).toEqual(0);
|
||||
expect((manager as any).resolvedForwardingMode).toEqual('hybrid');
|
||||
});
|
||||
|
||||
tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts VPN services', async () => {
|
||||
const dcRouter = new DcRouter({
|
||||
smartProxyConfig: { routes: [] },
|
||||
dbConfig: { enabled: false },
|
||||
vpnConfig: { enabled: false },
|
||||
});
|
||||
|
||||
let stopCalls = 0;
|
||||
let setupCalls = 0;
|
||||
let applyCalls = 0;
|
||||
const resolverValues: Array<unknown> = [];
|
||||
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
(dcRouter as any).routeConfigManager = {
|
||||
setVpnClientAccessResolver: (resolver: unknown) => {
|
||||
resolverValues.push(resolver);
|
||||
},
|
||||
applyRoutes: async () => {
|
||||
applyCalls++;
|
||||
},
|
||||
};
|
||||
(dcRouter as any).setupVpnServer = async () => {
|
||||
setupCalls++;
|
||||
dcRouter.vpnManager = {
|
||||
stop: async () => {
|
||||
stopCalls++;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: true, subnet: '10.9.0.0/24' });
|
||||
|
||||
expect(stopCalls).toEqual(1);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(0);
|
||||
expect(typeof resolverValues.at(-1)).toEqual('function');
|
||||
|
||||
await dcRouter.updateVpnConfig({ enabled: false });
|
||||
|
||||
expect(stopCalls).toEqual(2);
|
||||
expect(setupCalls).toEqual(1);
|
||||
expect(applyCalls).toEqual(1);
|
||||
expect(resolverValues.at(-1)).toBeUndefined();
|
||||
expect(dcRouter.vpnManager).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN clients', async () => {
|
||||
const manager = new RouteConfigManager(() => undefined);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: { ipAllowList: ['*'] },
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['*']);
|
||||
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['client-1'],
|
||||
);
|
||||
const route = {
|
||||
name: 'private-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: ['private.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: {
|
||||
ipAllowList: ['*', '203.0.113.10'],
|
||||
ipBlockList: ['198.51.100.5'],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
|
||||
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
|
||||
const manager = new RouteConfigManager(
|
||||
() => undefined,
|
||||
undefined,
|
||||
() => ['client-1'],
|
||||
);
|
||||
const route = {
|
||||
name: 'shared-private-route',
|
||||
match: { domains: ['app.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.10'],
|
||||
ipBlockList: ['198.51.100.5'],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const prepared = (manager as any).injectVpnSecurity(route);
|
||||
|
||||
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
|
||||
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'hagen.team VPN access',
|
||||
domains: ['*.hagen.team'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'hagen-app',
|
||||
match: { domains: 'app.hagen.team', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'hagen.team VPN access',
|
||||
domains: ['*.hagen.team'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routes = new Map([
|
||||
['route-1', {
|
||||
id: 'route-1',
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
route: {
|
||||
name: 'hagen-app',
|
||||
match: { domains: 'app.hagen.team', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
},
|
||||
}],
|
||||
]) as any;
|
||||
|
||||
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
|
||||
|
||||
expect(accessSpec.domains).toContain('*.hagen.team');
|
||||
expect(accessSpec.domains).toContain('app.hagen.team');
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager allows source-IP reachable routes for opted-in profiles', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'restricted-public-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'blocked-route',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: {
|
||||
ipAllowList: ['203.0.113.0/24'],
|
||||
ipBlockList: ['*'],
|
||||
},
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP reachable', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'public-route',
|
||||
match: { domains: 'public.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const entries = manager.getMatchingVpnClients(
|
||||
{
|
||||
name: 'vpn-only-route',
|
||||
vpnOnly: true,
|
||||
match: { domains: 'private.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.10'] },
|
||||
} as any,
|
||||
'route-1',
|
||||
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual(['client-1']);
|
||||
});
|
||||
|
||||
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
|
||||
const manager = new TargetProfileManager();
|
||||
(manager as any).profiles.set('profile-1', {
|
||||
id: 'profile-1',
|
||||
name: 'source-ip access',
|
||||
allowRoutesByClientSourceIp: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
});
|
||||
|
||||
const routes = new Map([
|
||||
['route-1', {
|
||||
id: 'route-1',
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
route: {
|
||||
name: 'source-reachable-app',
|
||||
match: { domains: 'app.example.com', ports: [443] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||
security: { ipAllowList: ['203.0.113.0/24'] },
|
||||
},
|
||||
}],
|
||||
]) as any;
|
||||
|
||||
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
|
||||
|
||||
expect(accessSpec.domains).toContain('app.example.com');
|
||||
});
|
||||
|
||||
tap.test('VpnManager normalizes real remote addresses', async () => {
|
||||
expect(VpnManager.normalizeRemoteAddress('203.0.113.10:51234')).toEqual('203.0.113.10');
|
||||
expect(VpnManager.normalizeRemoteAddress('[2001:db8::1]:51234')).toEqual('2001:db8::1');
|
||||
expect(VpnManager.normalizeRemoteAddress('2001:db8::1')).toEqual('2001:db8::1');
|
||||
});
|
||||
|
||||
tap.test('VpnManager refreshes live source IPs from WireGuard peer endpoints', async () => {
|
||||
const manager = new VpnManager({});
|
||||
let sourceIpChangeCalls = 0;
|
||||
(manager as any).config.onClientSourceIpsChanged = () => {
|
||||
sourceIpChangeCalls++;
|
||||
};
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { clientId: 'client-1', wgPublicKey: 'wg-public-key' }],
|
||||
]);
|
||||
(manager as any).vpnServer = {
|
||||
listClients: async () => ([
|
||||
{
|
||||
clientId: 'runtime-client-1',
|
||||
registeredClientId: 'client-1',
|
||||
assignedIp: '10.8.0.2',
|
||||
transportType: 'wireguard',
|
||||
},
|
||||
]),
|
||||
listWgPeers: async () => ([
|
||||
{
|
||||
publicKey: 'wg-public-key',
|
||||
allowedIps: ['10.8.0.2/32'],
|
||||
endpoint: '198.51.100.44:61234',
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
packetsSent: 0,
|
||||
packetsReceived: 0,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const changed = await manager.refreshClientSourceIps();
|
||||
const changedAgain = await manager.refreshClientSourceIps();
|
||||
|
||||
expect(changed).toEqual(true);
|
||||
expect(changedAgain).toEqual(false);
|
||||
expect(manager.getClientSourceIp('client-1')).toEqual('198.51.100.44');
|
||||
expect(sourceIpChangeCalls).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
|
||||
const manager = new VpnManager({
|
||||
serverEndpoint: 'vpn.example.com',
|
||||
getClientAllowedIPs: async () => ['10.8.0.0/24', '203.0.113.10/32'],
|
||||
});
|
||||
|
||||
(manager as any).vpnServer = {
|
||||
rotateClientKey: async () => ({
|
||||
entry: {
|
||||
clientId: 'client-1',
|
||||
publicKey: 'noise-public-key',
|
||||
wgPublicKey: 'wg-public-key',
|
||||
},
|
||||
wireguardConfig: '[Interface]\nPrivateKey = old\nAddress = 10.8.0.2/24\n[Peer]\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.com:51820\n',
|
||||
secrets: { noisePrivateKey: 'noise-private-key', wgPrivateKey: 'wg-private-key' },
|
||||
}),
|
||||
};
|
||||
(manager as any).clients = new Map([
|
||||
['client-1', { clientId: 'client-1', targetProfileIds: ['profile-1'] }],
|
||||
]);
|
||||
(manager as any).persistClient = async () => {};
|
||||
|
||||
const bundle = await manager.rotateClientKey('client-1');
|
||||
|
||||
expect(bundle.wireguardConfig).toContain('AllowedIPs = 10.8.0.0/24, 203.0.113.10/32');
|
||||
});
|
||||
|
||||
export default tap.start()
|
||||
@@ -0,0 +1,175 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { WorkAppMailManager } from '../ts/email/classes.workapp-mail-manager.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
class MemoryStorageManager {
|
||||
public store = new Map<string, string>();
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
return this.store.get(key) || null;
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const createDcRouterStub = () => {
|
||||
const storageManager = new MemoryStorageManager();
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587, 465],
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'operator-route',
|
||||
match: { recipients: 'ops@example.com' },
|
||||
action: { type: 'reject', reject: { code: 550, message: 'not here' } },
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
users: [{ username: 'operator', password: 'secret' }],
|
||||
},
|
||||
};
|
||||
const dcRouterRef: any = {
|
||||
storageManager,
|
||||
options: { emailConfig },
|
||||
emailServer: {
|
||||
updateOptions: (patch: Partial<IUnifiedEmailServerOptions>) => {
|
||||
dcRouterRef.options.emailConfig = {
|
||||
...dcRouterRef.options.emailConfig,
|
||||
...patch,
|
||||
};
|
||||
},
|
||||
},
|
||||
updateEmailRoutes: async (routes: IUnifiedEmailServerOptions['routes']) => {
|
||||
dcRouterRef.options.emailConfig.routes = routes;
|
||||
},
|
||||
};
|
||||
return { dcRouterRef, storageManager };
|
||||
};
|
||||
|
||||
tap.test('WorkAppMailManager syncs SMTP identity and inbound smartmta route', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
|
||||
const createResult = await manager.syncMailIdentity({
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'Hello',
|
||||
domain: 'Example.com',
|
||||
inbound: {
|
||||
enabled: true,
|
||||
targetHost: '10.0.0.2',
|
||||
targetPort: 2525,
|
||||
},
|
||||
}, 'tester');
|
||||
|
||||
expect(createResult.success).toEqual(true);
|
||||
expect(createResult.action).toEqual('created');
|
||||
expect(createResult.identity?.address).toEqual('hello@example.com');
|
||||
expect(createResult.identity?.smtp.username.startsWith('workapp-')).toEqual(true);
|
||||
expect((createResult.identity as any).smtpPassword).toBeUndefined();
|
||||
expect(createResult.smtpCredentials?.password.length).toBeGreaterThan(20);
|
||||
|
||||
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||
expect(generatedRoute.match.recipients).toEqual('hello@example.com');
|
||||
expect(generatedRoute.action.forward.host).toEqual('10.0.0.2');
|
||||
expect(generatedRoute.action.forward.port).toEqual(2525);
|
||||
expect(generatedRoute.action.forward.addHeaders['X-Dcrouter-WorkApp-Id']).toEqual('app-1');
|
||||
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name === 'operator-route')).toEqual(true);
|
||||
|
||||
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||
expect(generatedUser.password).toEqual(createResult.smtpCredentials?.password);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||
|
||||
const listResult = await manager.listMailIdentities({ workAppId: 'app-1' });
|
||||
expect(listResult.length).toEqual(1);
|
||||
expect(listResult[0].address).toEqual('hello@example.com');
|
||||
});
|
||||
|
||||
tap.test('WorkAppMailManager updates, resets credentials, and deletes identities', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
const ownership = {
|
||||
workHosterType: 'onebox' as const,
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
};
|
||||
|
||||
const createResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||
}, 'tester');
|
||||
const firstPassword = createResult.smtpCredentials!.password;
|
||||
|
||||
const updateResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.3', targetPort: 2526 },
|
||||
}, 'tester');
|
||||
expect(updateResult.action).toEqual('updated');
|
||||
expect(updateResult.smtpCredentials).toBeUndefined();
|
||||
const generatedUser = dcRouterRef.options.emailConfig.auth.users.find((user: any) => user.username.startsWith('workapp-'));
|
||||
expect(generatedUser.password).toEqual(firstPassword);
|
||||
const generatedRoute = dcRouterRef.options.emailConfig.routes.find((route: any) => route.name.startsWith('workapp-mail-'));
|
||||
expect(generatedRoute.action.forward.host).toEqual('10.0.0.3');
|
||||
|
||||
const resetResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
resetSmtpPassword: true,
|
||||
}, 'tester');
|
||||
expect(resetResult.smtpCredentials?.password !== firstPassword).toEqual(true);
|
||||
|
||||
const deleteResult = await manager.syncMailIdentity({
|
||||
ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
delete: true,
|
||||
}, 'tester');
|
||||
expect(deleteResult.action).toEqual('deleted');
|
||||
expect(dcRouterRef.options.emailConfig.routes.some((route: any) => route.name.startsWith('workapp-mail-'))).toEqual(false);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username.startsWith('workapp-'))).toEqual(false);
|
||||
expect(dcRouterRef.options.emailConfig.auth.users.some((user: any) => user.username === 'operator')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkAppMailManager applies persisted identities to startup email config', async () => {
|
||||
const { dcRouterRef } = createDcRouterStub();
|
||||
const manager = new WorkAppMailManager(dcRouterRef);
|
||||
await manager.syncMailIdentity({
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: { enabled: true, targetHost: '10.0.0.2', targetPort: 2525 },
|
||||
}, 'tester');
|
||||
|
||||
const baseStartupConfig: IUnifiedEmailServerOptions = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25],
|
||||
domains: [{ domain: 'example.com', dnsMode: 'external-dns' }],
|
||||
routes: [],
|
||||
};
|
||||
const startupConfig = await manager.applyStoredIdentitiesToEmailConfig(baseStartupConfig);
|
||||
|
||||
expect(startupConfig.routes.some((route) => route.name.startsWith('workapp-mail-'))).toEqual(true);
|
||||
expect(startupConfig.auth?.users?.some((user) => user.username.startsWith('workapp-'))).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,565 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
type TScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
const fireTypedRequest = async (
|
||||
router: plugins.typedrequest.TypedRouter,
|
||||
method: string,
|
||||
request: Record<string, any>,
|
||||
) => {
|
||||
return await router.routeAndAddResponse({
|
||||
method,
|
||||
request,
|
||||
response: {},
|
||||
correlation: {
|
||||
id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
phase: 'request',
|
||||
},
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const makeApiTokenManager = (
|
||||
scopes: TScope[],
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'workhoster-test-token',
|
||||
scopes,
|
||||
createdBy: 'token-user',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
policy,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
|
||||
if (storedToken.policy?.role === 'admin') return true;
|
||||
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
|
||||
const gatewayClientAllowedScopes = new Set<TScope>([
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
]);
|
||||
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
|
||||
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
|
||||
const scopes = new Set(storedToken.scopes);
|
||||
for (const policyScope of storedToken.policy?.scopes || []) {
|
||||
scopes.add(policyScope);
|
||||
}
|
||||
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeRouteConfigManager = () => {
|
||||
const routes = new Map<string, interfaces.data.IRoute>();
|
||||
let nextRouteNumber = 1;
|
||||
|
||||
return {
|
||||
routes,
|
||||
manager: {
|
||||
findApiRouteByExternalKey: (externalKey: string) => {
|
||||
return Array.from(routes.values()).find((route) =>
|
||||
route.origin === 'api' && route.metadata?.externalKey === externalKey,
|
||||
);
|
||||
},
|
||||
createRoute: async (
|
||||
route: interfaces.data.IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: interfaces.data.IRouteMetadata,
|
||||
) => {
|
||||
const id = `route-${nextRouteNumber++}`;
|
||||
routes.set(id, {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdBy,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
origin: 'api',
|
||||
metadata,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
updateRoute: async (
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<interfaces.data.IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<interfaces.data.IRouteMetadata>;
|
||||
},
|
||||
) => {
|
||||
const storedRoute = routes.get(id);
|
||||
if (!storedRoute) return { success: false, message: 'Route not found' };
|
||||
if (patch.route) {
|
||||
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
storedRoute.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.metadata) {
|
||||
storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata };
|
||||
}
|
||||
storedRoute.updatedAt = Date.now();
|
||||
return { success: true };
|
||||
},
|
||||
deleteRoute: async (id: string) => {
|
||||
const deleted = routes.delete(id);
|
||||
return deleted ? { success: true } : { success: false, message: 'Route not found' };
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setupHandler = (options: {
|
||||
scopes: TScope[];
|
||||
policy?: interfaces.data.IApiTokenPolicy;
|
||||
isAdmin?: boolean;
|
||||
dcRouterRef?: Record<string, any>;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const opsServerRef: any = {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
validateIdentity: async (identity: interfaces.data.IIdentity) => options.isAdmin
|
||||
? { ...identity, role: 'admin' }
|
||||
: identity,
|
||||
adminIdentityGuard: {
|
||||
exec: async () => Boolean(options.isAdmin),
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
||||
...options.dcRouterRef,
|
||||
},
|
||||
};
|
||||
|
||||
new WorkHosterHandler(opsServerRef);
|
||||
return { typedrouter, opsServerRef };
|
||||
};
|
||||
|
||||
tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {
|
||||
remoteIngressConfig: { enabled: true },
|
||||
dnsScopes: ['example.com'],
|
||||
http3: { enabled: false },
|
||||
},
|
||||
routeConfigManager: {
|
||||
getMergedRoutes: () => ({ routes: [] }),
|
||||
},
|
||||
smartProxy: {},
|
||||
emailDomainManager: {},
|
||||
emailServer: {},
|
||||
dnsManager: {
|
||||
listDomains: async () => [
|
||||
{ id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true },
|
||||
{ id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false },
|
||||
],
|
||||
toPublicDomain: (domainDoc: any) => ({
|
||||
...domainDoc,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(capabilitiesResult.error).toBeUndefined();
|
||||
expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true);
|
||||
expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false);
|
||||
|
||||
const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
expect(domainsResult.error).toBeUndefined();
|
||||
expect(domainsResult.response.domains.length).toEqual(2);
|
||||
expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true);
|
||||
expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:write'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
const ownership: interfaces.data.IWorkAppRouteOwnership = {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
};
|
||||
|
||||
const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
route: {
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.2', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeUndefined();
|
||||
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(1);
|
||||
|
||||
const createdRoute = routeConfig.routes.get('route-1')!;
|
||||
expect(createdRoute.createdBy).toEqual('token-user');
|
||||
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||
expect(createdRoute.metadata).toEqual({
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
gatewayClientAppId: 'app-1',
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
});
|
||||
|
||||
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
enabled: false,
|
||||
route: {
|
||||
name: 'updated-workapp-route',
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.3', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResult.error).toBeUndefined();
|
||||
expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(1);
|
||||
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
||||
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
||||
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(deleteResult.error).toBeUndefined();
|
||||
expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.size).toEqual(0);
|
||||
|
||||
const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership,
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(unchangedResult.error).toBeUndefined();
|
||||
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
},
|
||||
},
|
||||
dcRouterRef: { options: {} },
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
|
||||
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
|
||||
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:write'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: { syncRoutes: true },
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
|
||||
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
route: {
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.2', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeUndefined();
|
||||
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
|
||||
|
||||
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'other-box',
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
|
||||
const identity: interfaces.data.IIdentity = {
|
||||
jwt: 'admin-jwt',
|
||||
userId: 'admin-user',
|
||||
name: 'admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
const gatewayClient: interfaces.data.IGatewayClient = {
|
||||
id: 'onebox-main',
|
||||
type: 'onebox',
|
||||
name: 'Main Onebox',
|
||||
hostnamePatterns: ['*.apps.example.com'],
|
||||
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
|
||||
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'admin-user',
|
||||
};
|
||||
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: [],
|
||||
isAdmin: true,
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
gatewayClientManager: {
|
||||
listClients: async () => [gatewayClient],
|
||||
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
|
||||
},
|
||||
apiTokenManager: {
|
||||
listTokens: () => [{
|
||||
id: 'token-1',
|
||||
name: 'token',
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
|
||||
createdAt: 1,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
}],
|
||||
createToken: async (
|
||||
_name: string,
|
||||
_scopes: TScope[],
|
||||
_expiresInDays: number | null,
|
||||
_createdBy: string,
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
createdTokenPolicy = policy;
|
||||
return { id: 'new-token', rawToken: 'dcr_created' };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
|
||||
|
||||
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
|
||||
identity,
|
||||
gatewayClientId: 'onebox-main',
|
||||
});
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
|
||||
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
|
||||
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
expect(routeConfig.routes.size).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes and syncs WorkApp mail identities', async () => {
|
||||
const syncedRequests: Array<{ data: any; userId: string }> = [];
|
||||
const identity: interfaces.data.IWorkAppMailIdentity = {
|
||||
id: 'mail-1',
|
||||
externalKey: 'onebox:box-1:app-1:hello@example.com',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
address: 'hello@example.com',
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
enabled: true,
|
||||
inbound: {
|
||||
enabled: true,
|
||||
targetHost: '10.0.0.2',
|
||||
targetPort: 2525,
|
||||
},
|
||||
smtp: {
|
||||
enabled: true,
|
||||
username: 'workapp-user',
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'token-user',
|
||||
};
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read', 'workhosters:write'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
listMailIdentities: async (filter: any) => filter.workAppId === 'app-1' ? [identity] : [],
|
||||
syncMailIdentity: async (data: any, userId: string) => {
|
||||
syncedRequests.push({ data, userId });
|
||||
return {
|
||||
success: true,
|
||||
action: 'created',
|
||||
identity,
|
||||
smtpCredentials: {
|
||||
username: 'workapp-user',
|
||||
password: 'generated-password',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'getWorkAppMailIdentities', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: { workAppId: 'app-1' },
|
||||
});
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.identities).toEqual([identity]);
|
||||
|
||||
const syncResult = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: identity.ownership,
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
inbound: identity.inbound,
|
||||
});
|
||||
expect(syncResult.error).toBeUndefined();
|
||||
expect(syncResult.response.success).toEqual(true);
|
||||
expect(syncResult.response.smtpCredentials.password).toEqual('generated-password');
|
||||
expect(syncedRequests[0].userId).toEqual('token-user');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects WorkApp mail sync without workhosters:write', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
workAppMailManager: {
|
||||
syncMailIdentity: async () => ({ success: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'syncWorkAppMailIdentity', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
workHosterType: 'onebox',
|
||||
workHosterId: 'box-1',
|
||||
workAppId: 'app-1',
|
||||
},
|
||||
localPart: 'hello',
|
||||
domain: 'example.com',
|
||||
});
|
||||
|
||||
expect(result.error?.text).toEqual('insufficient scope');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
import fs from 'node:fs';
|
||||
|
||||
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
|
||||
const checks = {
|
||||
packageVersion: readJson('/app/package.json').version,
|
||||
interfacesVersion: readJson('/app/node_modules/@serve.zone/interfaces/package.json').version,
|
||||
remoteingressVersion: readJson('/app/node_modules/@serve.zone/remoteingress/package.json').version,
|
||||
hasCli: fs.existsSync('/app/cli.js'),
|
||||
hasWebBundle: fs.existsSync('/app/dist_serve/bundle.js'),
|
||||
};
|
||||
|
||||
await import('/app/dist_ts/index.js');
|
||||
|
||||
if (checks.packageVersion !== '13.25.0') {
|
||||
throw new Error(`Unexpected dcrouter package version ${checks.packageVersion}`);
|
||||
}
|
||||
if (checks.interfacesVersion !== '5.4.6') {
|
||||
throw new Error(`Unexpected interfaces version ${checks.interfacesVersion}`);
|
||||
}
|
||||
if (checks.remoteingressVersion !== '4.17.1') {
|
||||
throw new Error(`Unexpected remoteingress version ${checks.remoteingressVersion}`);
|
||||
}
|
||||
if (!checks.hasCli) {
|
||||
throw new Error('Missing cli.js');
|
||||
}
|
||||
if (!checks.hasWebBundle) {
|
||||
throw new Error('Missing web bundle');
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(checks));
|
||||
NODE
|
||||
@@ -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.');
|
||||
@@ -2,7 +2,7 @@
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '2.4.2',
|
||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.43.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './manager.acme-config.js';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export class AIBridge {
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SzPlatformService } from './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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { ApiTokenDoc } from '../db/index.js';
|
||||
import type {
|
||||
IApiTokenPolicy,
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const TOKEN_PREFIX_STR = 'dcr_';
|
||||
const ENV_ADMIN_TOKEN_ID = 'env-admin-token';
|
||||
const ENV_ADMIN_TOKEN_CREATED_BY = 'dcrouter-env';
|
||||
|
||||
export class ApiTokenManager {
|
||||
private tokens = new Map<string, IStoredApiToken>();
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadTokens();
|
||||
await this.ensureEnvAdminToken();
|
||||
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,
|
||||
policy?: IApiTokenPolicy,
|
||||
): 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 = this.hashToken(rawToken);
|
||||
|
||||
const now = Date.now();
|
||||
const stored: IStoredApiToken = {
|
||||
id,
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
policy,
|
||||
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 = this.hashToken(rawToken);
|
||||
|
||||
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 {
|
||||
if (token.policy?.role === 'admin') return true;
|
||||
|
||||
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
|
||||
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
]);
|
||||
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
|
||||
|
||||
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
||||
if (scopes.has(scope)) return true;
|
||||
|
||||
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
'workhosters:read': ['gateway-clients:read'],
|
||||
'workhosters:write': ['gateway-clients:write'],
|
||||
};
|
||||
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
policy: stored.policy,
|
||||
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 = this.hashToken(rawToken);
|
||||
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,
|
||||
policy: doc.policy,
|
||||
createdAt: doc.createdAt,
|
||||
expiresAt: doc.expiresAt,
|
||||
lastUsedAt: doc.lastUsedAt,
|
||||
createdBy: doc.createdBy,
|
||||
enabled: doc.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureEnvAdminToken(): Promise<void> {
|
||||
const rawToken = process.env.DCROUTER_ADMIN_API_TOKEN?.trim();
|
||||
if (!rawToken) return;
|
||||
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) {
|
||||
throw new Error(`DCROUTER_ADMIN_API_TOKEN must start with ${TOKEN_PREFIX_STR}`);
|
||||
}
|
||||
if (rawToken.length < TOKEN_PREFIX_STR.length + 32) {
|
||||
throw new Error('DCROUTER_ADMIN_API_TOKEN is too short');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = this.tokens.get(ENV_ADMIN_TOKEN_ID);
|
||||
const stored: IStoredApiToken = {
|
||||
id: ENV_ADMIN_TOKEN_ID,
|
||||
name: process.env.DCROUTER_ADMIN_API_TOKEN_NAME?.trim() || 'Environment Admin Token',
|
||||
tokenHash: this.hashToken(rawToken),
|
||||
scopes: ['*'],
|
||||
policy: { role: 'admin' },
|
||||
createdAt: existing?.createdAt || now,
|
||||
expiresAt: null,
|
||||
lastUsedAt: existing?.lastUsedAt || null,
|
||||
createdBy: existing?.createdBy || ENV_ADMIN_TOKEN_CREATED_BY,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.tokens.set(stored.id, stored);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `Environment admin API token ensured (id: ${stored.id})`);
|
||||
}
|
||||
|
||||
private hashToken(rawToken: string): string {
|
||||
return plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
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.policy = stored.policy;
|
||||
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.policy = stored.policy;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.expiresAt = stored.expiresAt;
|
||||
doc.lastUsedAt = stored.lastUsedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.enabled = stored.enabled;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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: 'TRUSTED NETWORKS',
|
||||
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI CRAWLERS',
|
||||
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
|
||||
security: {
|
||||
ipAllowList: [],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 30,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PUBLIC',
|
||||
description: 'Public fallback source profile with per-IP request limiting',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 120,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { GatewayClientDoc } from '../db/index.js';
|
||||
import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js';
|
||||
|
||||
const defaultCapabilities: IGatewayClient['capabilities'] = {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
syncDnsRecords: false,
|
||||
requestCertificates: false,
|
||||
};
|
||||
|
||||
export class GatewayClientManager {
|
||||
public async initialize(): Promise<void> {}
|
||||
|
||||
public async listClients(): Promise<IGatewayClient[]> {
|
||||
const docs = await GatewayClientDoc.findAll();
|
||||
return docs.map((doc) => this.toPublicClient(doc));
|
||||
}
|
||||
|
||||
public async getClient(id: string): Promise<IGatewayClient | null> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
return doc ? this.toPublicClient(doc) : null;
|
||||
}
|
||||
|
||||
public async createClient(options: {
|
||||
id?: string;
|
||||
type: IGatewayClient['type'];
|
||||
name: string;
|
||||
description?: string;
|
||||
hostnamePatterns?: string[];
|
||||
allowedRouteTargets?: IGatewayClient['allowedRouteTargets'];
|
||||
capabilities?: IGatewayClient['capabilities'];
|
||||
createdBy: string;
|
||||
}): Promise<IGatewayClient> {
|
||||
const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`);
|
||||
if (!id) {
|
||||
throw new Error('gateway client id is required');
|
||||
}
|
||||
if (await GatewayClientDoc.findById(id)) {
|
||||
throw new Error('gateway client already exists');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const doc = new GatewayClientDoc();
|
||||
doc.id = id;
|
||||
doc.type = options.type;
|
||||
doc.name = options.name.trim();
|
||||
doc.description = options.description?.trim() || undefined;
|
||||
doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []);
|
||||
doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []);
|
||||
doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) };
|
||||
doc.enabled = true;
|
||||
doc.createdAt = now;
|
||||
doc.updatedAt = now;
|
||||
doc.createdBy = options.createdBy;
|
||||
await doc.save();
|
||||
return this.toPublicClient(doc);
|
||||
}
|
||||
|
||||
public async updateClient(
|
||||
id: string,
|
||||
patch: Partial<Pick<IGatewayClient, 'name' | 'description' | 'hostnamePatterns' | 'allowedRouteTargets' | 'capabilities' | 'enabled'>>,
|
||||
): Promise<IGatewayClient | null> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
if (!doc) return null;
|
||||
if (patch.name !== undefined) doc.name = patch.name.trim();
|
||||
if (patch.description !== undefined) doc.description = patch.description.trim() || undefined;
|
||||
if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns);
|
||||
if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets);
|
||||
if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities };
|
||||
if (patch.enabled !== undefined) doc.enabled = patch.enabled;
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
return this.toPublicClient(doc);
|
||||
}
|
||||
|
||||
public async deleteClient(id: string): Promise<boolean> {
|
||||
const doc = await GatewayClientDoc.findById(id);
|
||||
if (!doc) return false;
|
||||
await doc.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
private normalizeId(id: string): string {
|
||||
return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private normalizeStringList(values: string[]): string[] {
|
||||
return values.map((value) => value.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] {
|
||||
return targets
|
||||
.map((target) => ({
|
||||
host: target.host.trim().toLowerCase(),
|
||||
ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535),
|
||||
}))
|
||||
.filter((target) => target.host && target.ports.length > 0);
|
||||
}
|
||||
|
||||
private toPublicClient(doc: GatewayClientDoc): IGatewayClient {
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
hostnamePatterns: doc.hostnamePatterns || [],
|
||||
allowedRouteTargets: doc.allowedRouteTargets || [],
|
||||
capabilities: doc.capabilities || {},
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
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,
|
||||
IRouteSourcePolicy,
|
||||
} 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(id, affectedIds, storedRoutes);
|
||||
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 resolveSourceProfileSecurity(profileId: string): IRouteSecurity | null {
|
||||
const resolvedSecurity = this.resolveSourceProfile(profileId);
|
||||
return resolvedSecurity ? this.cloneSecurityFields(resolvedSecurity) : null;
|
||||
}
|
||||
|
||||
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 refs = this.getSourceProfileRefsFromMetadata(stored.metadata);
|
||||
for (const ref of refs) {
|
||||
if (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 (this.metadataUsesSourceProfile(stored.metadata, 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.
|
||||
* When a source profile is selected, it owns the route security fully.
|
||||
* 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.sourcePolicy?.bindings.length) {
|
||||
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
|
||||
if (resolvedSourcePolicy) {
|
||||
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
|
||||
resolvedMetadata.sourceProfileRef = undefined;
|
||||
resolvedMetadata.sourceProfileName = undefined;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
}
|
||||
} else if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
route = {
|
||||
...route,
|
||||
security: this.cloneSecurityFields(resolvedSecurity),
|
||||
};
|
||||
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) => this.metadataUsesSourceProfile(doc.metadata, 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 (this.metadataUsesSourceProfile(stored.metadata, 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 resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy.bindings
|
||||
.map((binding) => {
|
||||
const profile = this.profiles.get(binding.sourceProfileRef);
|
||||
if (!profile) {
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source policy resolution`);
|
||||
return binding;
|
||||
}
|
||||
return {
|
||||
...binding,
|
||||
sourceProfileName: profile.name,
|
||||
};
|
||||
})
|
||||
.filter((binding) => binding.sourceProfileRef);
|
||||
|
||||
return bindings.length > 0 ? { bindings } : undefined;
|
||||
}
|
||||
|
||||
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
|
||||
return this.getSourceProfileRefsFromMetadata(metadata).includes(profileId);
|
||||
}
|
||||
|
||||
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (metadata?.sourceProfileRef) {
|
||||
refs.add(metadata.sourceProfileRef);
|
||||
}
|
||||
for (const binding of metadata?.sourcePolicy?.bindings || []) {
|
||||
if (binding.sourceProfileRef) {
|
||||
refs.add(binding.sourceProfileRef);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
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;
|
||||
if (override.vpn !== undefined) merged.vpn = override.vpn;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private cloneSecurityFields(security: IRouteSecurity): IRouteSecurity {
|
||||
return structuredClone(security);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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(
|
||||
profileId: string,
|
||||
routeIds: string[],
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = this.clearSourceProfileFromMetadata(doc.metadata, profileId);
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
const storedRoute = storedRoutes?.get(routeId);
|
||||
if (storedRoute?.metadata) {
|
||||
storedRoute.metadata = this.clearSourceProfileFromMetadata(storedRoute.metadata, profileId);
|
||||
storedRoute.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
|
||||
const sourcePolicy = metadata.sourcePolicy?.bindings?.length
|
||||
? {
|
||||
bindings: metadata.sourcePolicy.bindings.filter(
|
||||
(binding) => binding.sourceProfileRef !== profileId,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nextMetadata: IRouteMetadata = {
|
||||
...metadata,
|
||||
sourceProfileRef: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileName,
|
||||
sourcePolicy: sourcePolicy?.bindings.length ? sourcePolicy : undefined,
|
||||
};
|
||||
|
||||
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !nextMetadata.networkTargetRef) {
|
||||
nextMetadata.lastResolvedAt = undefined;
|
||||
}
|
||||
|
||||
return nextMetadata;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
|
||||
import type {
|
||||
IHttpRedirectInfo,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSecurity,
|
||||
} 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';
|
||||
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
||||
import { deriveHttpRedirects } from './helpers.http-redirects.js';
|
||||
|
||||
export type TVpnClientAllowEntry = string | { clientId: 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 getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
|
||||
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => 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);
|
||||
}
|
||||
|
||||
public setVpnClientAccessResolver(
|
||||
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
|
||||
): void {
|
||||
this.getVpnClientAccessForRoute = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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] };
|
||||
}
|
||||
|
||||
public getHttpRedirects(): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
throw new Error(sourcePolicyPayloadError);
|
||||
}
|
||||
|
||||
// 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 = this.normalizeRouteMetadata(metadata);
|
||||
if (resolvedMetadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(resolvedMetadata?.sourcePolicy, route);
|
||||
if (sourcePolicyValidationError) {
|
||||
throw new Error(sourcePolicyValidationError);
|
||||
}
|
||||
|
||||
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 sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(patch.metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
return { success: false, message: sourcePolicyPayloadError };
|
||||
}
|
||||
|
||||
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
const previousRoute = structuredClone(stored.route);
|
||||
const previousMetadata = structuredClone(stored.metadata);
|
||||
const previousEnabled = stored.enabled;
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
||||
|
||||
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
|
||||
for (const [key, val] of Object.entries(patch.route)) {
|
||||
if (val === null && key !== 'action' && key !== 'match') {
|
||||
delete (mergedRoute as any)[key];
|
||||
}
|
||||
}
|
||||
|
||||
stored.route = mergedRoute;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
}
|
||||
if (patch.metadata !== undefined) {
|
||||
stored.metadata = this.normalizeRouteMetadata({
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
if (
|
||||
previousSourceProfileRef
|
||||
&& !stored.metadata?.sourceProfileRef
|
||||
&& !patch.route?.security
|
||||
) {
|
||||
delete stored.route.security;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
|
||||
if (sourcePolicyValidationError) {
|
||||
stored.route = previousRoute;
|
||||
stored.metadata = previousMetadata;
|
||||
stored.enabled = previousEnabled;
|
||||
return { success: false, message: sourcePolicyValidationError };
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
public findApiRouteByExternalKey(externalKey: string): IRoute | undefined {
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.origin === 'api' && route.metadata?.externalKey === externalKey) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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: this.normalizeRouteMetadata(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 normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
: undefined,
|
||||
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
||||
? metadata.ownerType
|
||||
: undefined,
|
||||
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
||||
? metadata.gatewayClientType
|
||||
: metadata.workHosterType,
|
||||
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
||||
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
||||
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
||||
? metadata.workHosterType
|
||||
: metadata.gatewayClientType,
|
||||
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
||||
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.sourcePolicy && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
normalized.gatewayClientType = undefined;
|
||||
normalized.gatewayClientId = undefined;
|
||||
normalized.gatewayClientAppId = undefined;
|
||||
normalized.workHosterType = undefined;
|
||||
normalized.workHosterId = undefined;
|
||||
normalized.workAppId = undefined;
|
||||
normalized.externalKey = undefined;
|
||||
} else {
|
||||
normalized.ownerType = 'gatewayClient';
|
||||
normalized.workHosterType = normalized.gatewayClientType;
|
||||
normalized.workHosterId = normalized.gatewayClientId;
|
||||
normalized.workAppId = normalized.gatewayClientAppId;
|
||||
}
|
||||
|
||||
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy?.bindings;
|
||||
if (!Array.isArray(bindings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
|
||||
for (const binding of bindings) {
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
if (!sourceProfileRef) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
|
||||
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
|
||||
|
||||
normalizedBindings.push({
|
||||
...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}),
|
||||
sourceProfileRef,
|
||||
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
|
||||
? { sourceProfileName: binding.sourceProfileName.trim() }
|
||||
: {}),
|
||||
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
||||
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
|
||||
? { maxConnections: binding.maxConnections }
|
||||
: {}),
|
||||
...(binding.onExceeded?.type === '429'
|
||||
? {
|
||||
onExceeded: {
|
||||
type: '429' as const,
|
||||
...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim()
|
||||
? { errorMessage: binding.onExceeded.errorMessage.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedBindings.length > 0 ? { bindings: normalizedBindings } : undefined;
|
||||
}
|
||||
|
||||
private normalizePathPolicies(
|
||||
pathPolicies?: IRoutePathPolicyBinding[],
|
||||
): IRoutePathPolicyBinding[] | undefined {
|
||||
if (!Array.isArray(pathPolicies)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
|
||||
for (const pathPolicy of pathPolicies) {
|
||||
if (!validClasses.has(pathPolicy.pathClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
|
||||
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
|
||||
? [...new Set(pathPolicy.pathPatterns
|
||||
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
|
||||
.filter(Boolean))]
|
||||
: undefined;
|
||||
|
||||
normalizedPathPolicies.push({
|
||||
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
|
||||
pathClass: pathPolicy.pathClass,
|
||||
...(pathPatterns?.length ? { pathPatterns } : {}),
|
||||
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
||||
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
|
||||
? { maxConnections: pathPolicy.maxConnections }
|
||||
: {}),
|
||||
...(pathPolicy.onExceeded?.type === '429'
|
||||
? {
|
||||
onExceeded: {
|
||||
type: '429' as const,
|
||||
...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim()
|
||||
? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
|
||||
}
|
||||
|
||||
private validateSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
|
||||
if (shapeError) {
|
||||
return shapeError;
|
||||
}
|
||||
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, this.referenceResolver);
|
||||
}
|
||||
|
||||
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
|
||||
if (!rateLimit || typeof rateLimit !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const maxRequests = Number(rateLimit.maxRequests);
|
||||
const window = Number(rateLimit.window);
|
||||
if (!Number.isFinite(maxRequests) || maxRequests < 0 || !Number.isFinite(window) || window < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: rateLimit.enabled !== false,
|
||||
maxRequests,
|
||||
window,
|
||||
keyBy: 'ip',
|
||||
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
|
||||
? { errorMessage: rateLimit.errorMessage.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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 = this.normalizeRouteMetadata(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 = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
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) {
|
||||
await this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
});
|
||||
}
|
||||
|
||||
private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] {
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
return enabledRoutes;
|
||||
}
|
||||
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
|
||||
hydratedRoute || storedRoute.route,
|
||||
storedRoute.metadata,
|
||||
this.referenceResolver,
|
||||
storedRoute.id,
|
||||
);
|
||||
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(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 dcRoute = route as IDcRouterRouteConfig;
|
||||
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
|
||||
|
||||
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
|
||||
return route;
|
||||
}
|
||||
|
||||
const existingVpnSecurity = route.security?.vpn || {};
|
||||
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
|
||||
existingVpnSecurity.allowedClients || [],
|
||||
vpnEntries,
|
||||
);
|
||||
|
||||
return {
|
||||
...route,
|
||||
security: {
|
||||
...route.security,
|
||||
vpn: {
|
||||
...existingVpnSecurity,
|
||||
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
|
||||
allowedClients: mergedAllowedClients,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mergeVpnClientAllowEntries(
|
||||
existingEntries: TVpnClientAllowEntry[],
|
||||
vpnEntries: TVpnClientAllowEntry[],
|
||||
): TVpnClientAllowEntry[] {
|
||||
const merged: TVpnClientAllowEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of [...existingEntries, ...vpnEntries]) {
|
||||
const key = typeof entry === 'string'
|
||||
? `client:${entry}`
|
||||
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import {
|
||||
giteaRoutePathClassLabels,
|
||||
giteaRoutePathClassPatterns,
|
||||
routePathClasses,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type {
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteMetadata,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourcePolicyBinding,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
const MIN_ROUTE_PRIORITY = 0;
|
||||
const MAX_ROUTE_PRIORITY = 10000;
|
||||
const SOURCE_PRIORITY_BAND = 0.0008;
|
||||
const PATH_PRIORITY_BAND = 0.0001;
|
||||
|
||||
export const sourcePolicyLimits = {
|
||||
maxBindings: 16,
|
||||
maxPathPoliciesPerBinding: 12,
|
||||
maxPathPatternsPerPolicy: 64,
|
||||
maxPathPatternLength: 256,
|
||||
maxPathPatternWildcards: 8,
|
||||
maxSourceProfileRefLength: 256,
|
||||
maxIdLength: 128,
|
||||
maxExceededMessageLength: 512,
|
||||
maxCompiledVariantsPerRoute: 512,
|
||||
} as const;
|
||||
|
||||
export class SourcePolicyCompiler {
|
||||
public static compileRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
metadata: IRouteMetadata | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
const bindings = metadata?.sourcePolicy?.bindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return [route];
|
||||
}
|
||||
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
|
||||
return [];
|
||||
}
|
||||
if (!referenceResolver) {
|
||||
return [];
|
||||
}
|
||||
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const basePriority = route.priority ?? 0;
|
||||
|
||||
bindings.forEach((binding, index) => {
|
||||
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
|
||||
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
|
||||
if (!profile || !profileSecurity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
|
||||
if (sourceMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
|
||||
const sourceMatch = this.matchesAllSources(sourceMatches)
|
||||
? { ...route.match }
|
||||
: { ...route.match, clientIp: sourceMatches };
|
||||
const pathPolicies = binding.pathPolicies || [];
|
||||
|
||||
if (pathPolicies.length === 0) {
|
||||
compiledRoutes.push(this.buildCompiledRoute({
|
||||
route,
|
||||
sourceMatch,
|
||||
profileName: profile.name,
|
||||
profileSecurity,
|
||||
binding,
|
||||
sourcePriority,
|
||||
routeId,
|
||||
sourceIndex: index,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let hasSourceFallback = false;
|
||||
pathPolicies.forEach((pathPolicy, pathIndex) => {
|
||||
const pathPatterns = this.getPathPatterns(pathPolicy);
|
||||
if (pathPatterns.length === 0) {
|
||||
hasSourceFallback = true;
|
||||
compiledRoutes.push(this.buildCompiledRoute({
|
||||
route,
|
||||
sourceMatch,
|
||||
profileName: profile.name,
|
||||
profileSecurity,
|
||||
binding,
|
||||
pathPolicy,
|
||||
sourcePriority,
|
||||
routeId,
|
||||
sourceIndex: index,
|
||||
pathIndex,
|
||||
pathPolicyCount: pathPolicies.length,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
pathPatterns.forEach((pathPattern, pathPatternIndex) => {
|
||||
compiledRoutes.push(this.buildCompiledRoute({
|
||||
route,
|
||||
sourceMatch,
|
||||
profileName: profile.name,
|
||||
profileSecurity,
|
||||
binding,
|
||||
pathPolicy,
|
||||
pathPattern,
|
||||
sourcePriority,
|
||||
routeId,
|
||||
sourceIndex: index,
|
||||
pathIndex,
|
||||
pathPolicyCount: pathPolicies.length,
|
||||
pathPatternIndex,
|
||||
pathPatternCount: pathPatterns.length,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasSourceFallback) {
|
||||
compiledRoutes.push(this.buildCompiledRoute({
|
||||
route,
|
||||
sourceMatch,
|
||||
profileName: profile.name,
|
||||
profileSecurity,
|
||||
binding,
|
||||
sourcePriority,
|
||||
routeId,
|
||||
sourceIndex: index,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return this.applyIntegerPriorities(compiledRoutes, basePriority);
|
||||
}
|
||||
|
||||
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
|
||||
if (!sourcePolicy) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(sourcePolicy.bindings)) {
|
||||
return 'Source policy bindings must be an array';
|
||||
}
|
||||
if (sourcePolicy.bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
for (const binding of sourcePolicy.bindings) {
|
||||
if (!binding || typeof binding !== 'object') {
|
||||
return 'Source policy binding must be an object';
|
||||
}
|
||||
if (typeof binding.sourceProfileRef !== 'string') {
|
||||
return 'Source policy binding requires a source profile';
|
||||
}
|
||||
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
|
||||
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
}
|
||||
if (binding.sourceProfileRef.trim().length === 0) {
|
||||
return 'Source policy binding requires a source profile';
|
||||
}
|
||||
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
|
||||
return `Source policy binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
}
|
||||
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
|
||||
return 'Source policy maxConnections must be non-negative';
|
||||
}
|
||||
const bindingRateLimitError = this.validateRateLimitPayload(binding.rateLimit);
|
||||
if (bindingRateLimitError) {
|
||||
return bindingRateLimitError;
|
||||
}
|
||||
const bindingMessage = binding.onExceeded?.errorMessage;
|
||||
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
|
||||
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
||||
}
|
||||
|
||||
const pathPolicies = binding.pathPolicies;
|
||||
if (pathPolicies === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(pathPolicies)) {
|
||||
return 'Source policy path policies must be an array';
|
||||
}
|
||||
if (pathPolicies.length > sourcePolicyLimits.maxPathPoliciesPerBinding) {
|
||||
return `Source policy binding exceeds ${sourcePolicyLimits.maxPathPoliciesPerBinding} path policies`;
|
||||
}
|
||||
|
||||
for (const pathPolicy of pathPolicies) {
|
||||
if (!pathPolicy || typeof pathPolicy !== 'object') {
|
||||
return 'Source policy path policy must be an object';
|
||||
}
|
||||
if (!validClasses.has(pathPolicy.pathClass)) {
|
||||
return 'Source policy path policy uses an unsupported path class';
|
||||
}
|
||||
if (typeof pathPolicy.id === 'string' && pathPolicy.id.length > sourcePolicyLimits.maxIdLength) {
|
||||
return `Source policy path policy id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
}
|
||||
if (typeof pathPolicy.maxConnections === 'number' && pathPolicy.maxConnections < 0) {
|
||||
return 'Source policy path policy maxConnections must be non-negative';
|
||||
}
|
||||
const pathRateLimitError = this.validateRateLimitPayload(pathPolicy.rateLimit);
|
||||
if (pathRateLimitError) {
|
||||
return pathRateLimitError;
|
||||
}
|
||||
const pathMessage = pathPolicy.onExceeded?.errorMessage;
|
||||
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
|
||||
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
||||
}
|
||||
|
||||
const pathPatterns = pathPolicy.pathPatterns;
|
||||
if (pathPatterns === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(pathPatterns)) {
|
||||
return 'Source policy path patterns must be an array';
|
||||
}
|
||||
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
|
||||
return `Source policy path class exceeds ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
|
||||
}
|
||||
for (const pattern of pathPatterns) {
|
||||
if (typeof pattern !== 'string') {
|
||||
return 'Source policy path pattern must be a string';
|
||||
}
|
||||
if (pattern.length > sourcePolicyLimits.maxPathPatternLength) {
|
||||
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternLength} characters`;
|
||||
}
|
||||
const wildcardCount = pattern.split('*').length - 1;
|
||||
if (wildcardCount > sourcePolicyLimits.maxPathPatternWildcards) {
|
||||
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternWildcards} wildcards`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
|
||||
if (!rateLimit || typeof rateLimit !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
|
||||
for (const key of ['maxRequests', 'window'] as const) {
|
||||
const value = rawRateLimit[key];
|
||||
if (typeof value === 'string' && value.length > 32) {
|
||||
return `Source policy rate limit ${key} exceeds 32 characters`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof rateLimit.errorMessage === 'string'
|
||||
&& rateLimit.errorMessage.length > sourcePolicyLimits.maxExceededMessageLength
|
||||
) {
|
||||
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static validateSourcePolicyShape(
|
||||
sourcePolicy?: IRouteSourcePolicy,
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
|
||||
if (payloadError) {
|
||||
return payloadError;
|
||||
}
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let estimatedCompiledRoutes = 0;
|
||||
for (const binding of bindings) {
|
||||
const pathPolicies = binding.pathPolicies || [];
|
||||
|
||||
if (pathPolicies.length === 0) {
|
||||
estimatedCompiledRoutes++;
|
||||
} else {
|
||||
let hasSourceFallback = false;
|
||||
for (const pathPolicy of pathPolicies) {
|
||||
const pathPatterns = this.getPathPatterns(pathPolicy);
|
||||
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
|
||||
return `Source policy path class expands beyond ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
|
||||
}
|
||||
if (pathPatterns.length === 0) {
|
||||
hasSourceFallback = true;
|
||||
estimatedCompiledRoutes++;
|
||||
} else {
|
||||
estimatedCompiledRoutes += pathPatterns.length;
|
||||
}
|
||||
}
|
||||
if (!hasSourceFallback) {
|
||||
estimatedCompiledRoutes++;
|
||||
}
|
||||
}
|
||||
|
||||
if (estimatedCompiledRoutes > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route variants`;
|
||||
}
|
||||
}
|
||||
|
||||
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
|
||||
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static validateResolvedSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!referenceResolver) {
|
||||
return 'Source policy requires source profile resolution';
|
||||
}
|
||||
|
||||
for (let index = 0; index < bindings.length; index++) {
|
||||
const binding = bindings[index];
|
||||
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
|
||||
if (!profile) {
|
||||
return `Source profile '${binding.sourceProfileRef}' not found`;
|
||||
}
|
||||
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
|
||||
if (!profileSecurity) {
|
||||
return `Source profile '${profile.name}' could not be resolved`;
|
||||
}
|
||||
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
|
||||
if (sourceMatches.length === 0) {
|
||||
return `Source profile '${profile.name}' has no source matches`;
|
||||
}
|
||||
const matchesAllSources = this.matchesAllSources(sourceMatches);
|
||||
if (matchesAllSources && index < bindings.length - 1) {
|
||||
return 'Wildcard source profile bindings must be last in a source policy';
|
||||
}
|
||||
if (index === bindings.length - 1 && !matchesAllSources) {
|
||||
return 'Source policy must end with an all-source fallback profile';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static buildCompiledRoute(options: {
|
||||
route: plugins.smartproxy.IRouteConfig;
|
||||
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
|
||||
profileName: string;
|
||||
profileSecurity: IRouteSecurity;
|
||||
binding: IRouteSourcePolicyBinding;
|
||||
pathPolicy?: IRoutePathPolicyBinding;
|
||||
pathPattern?: string;
|
||||
sourcePriority: number;
|
||||
routeId?: string;
|
||||
sourceIndex: number;
|
||||
pathIndex?: number;
|
||||
pathPolicyCount?: number;
|
||||
pathPatternIndex?: number;
|
||||
pathPatternCount?: number;
|
||||
}): plugins.smartproxy.IRouteConfig {
|
||||
const routeKey = options.route.id || options.routeId || options.route.name || 'route';
|
||||
const bindingKey = options.binding.id || options.binding.sourceProfileRef || String(options.sourceIndex + 1);
|
||||
const pathPolicyKey = options.pathPolicy
|
||||
? options.pathPolicy.id || options.pathPolicy.pathClass
|
||||
: undefined;
|
||||
const pathLabel = options.pathPolicy
|
||||
? giteaRoutePathClassLabels[options.pathPolicy.pathClass]
|
||||
: undefined;
|
||||
const pathPatternSuffix = options.pathPatternCount && options.pathPatternCount > 1
|
||||
? `:${(options.pathPatternIndex || 0) + 1}`
|
||||
: '';
|
||||
const pathPriority = options.pathPolicy
|
||||
? this.calculatePathPriorityOffset(
|
||||
options.pathPattern,
|
||||
options.pathIndex || 0,
|
||||
options.pathPolicyCount || 1,
|
||||
options.pathPatternIndex || 0,
|
||||
options.pathPatternCount || 1,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...options.route,
|
||||
id: pathPolicyKey
|
||||
? `${routeKey}:source:${bindingKey}:path:${pathPolicyKey}${pathPatternSuffix}`
|
||||
: `${routeKey}:source:${bindingKey}`,
|
||||
name: pathLabel
|
||||
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
|
||||
: `${options.route.name || routeKey}:source:${options.profileName}`,
|
||||
match: options.pathPattern
|
||||
? { ...options.sourceMatch, path: options.pathPattern }
|
||||
: { ...options.sourceMatch },
|
||||
priority: this.clampPriority(options.sourcePriority + pathPriority),
|
||||
security: this.buildBindingSecurity(
|
||||
options.route.security,
|
||||
options.profileSecurity,
|
||||
options.binding,
|
||||
options.pathPolicy,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
|
||||
const patterns: string[] = pathPolicy.pathPatterns?.length
|
||||
? pathPolicy.pathPatterns
|
||||
: giteaRoutePathClassPatterns[pathPolicy.pathClass];
|
||||
return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
private static calculatePathPriorityOffset(
|
||||
pathPattern: string | undefined,
|
||||
pathIndex: number,
|
||||
pathPolicyCount: number,
|
||||
pathPatternIndex: number,
|
||||
pathPatternCount: number,
|
||||
): number {
|
||||
if (!pathPattern) {
|
||||
return 0;
|
||||
}
|
||||
const pathPolicyOffset = ((pathPolicyCount - pathIndex) / (pathPolicyCount + 1))
|
||||
* (PATH_PRIORITY_BAND * 0.9);
|
||||
const pathPatternOffset = ((pathPatternCount - pathPatternIndex) / (pathPatternCount + 1))
|
||||
* (PATH_PRIORITY_BAND * 0.1 / (pathPolicyCount + 1));
|
||||
return pathPolicyOffset + pathPatternOffset;
|
||||
}
|
||||
|
||||
private static calculateSourcePriority(
|
||||
basePriority: number,
|
||||
sourceIndex: number,
|
||||
sourceCount: number,
|
||||
): number {
|
||||
const safeBasePriority = this.clampPriority(
|
||||
basePriority,
|
||||
MIN_ROUTE_PRIORITY,
|
||||
MAX_ROUTE_PRIORITY - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND,
|
||||
);
|
||||
const sourceStep = SOURCE_PRIORITY_BAND / (sourceCount + 1);
|
||||
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
|
||||
}
|
||||
|
||||
private static applyIntegerPriorities(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
basePriority: number,
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
if (routes.length === 0) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
const priorityOrder = routes
|
||||
.map((route, originalIndex) => ({
|
||||
originalIndex,
|
||||
priority: typeof route.priority === 'number' && Number.isFinite(route.priority)
|
||||
? route.priority
|
||||
: basePriority,
|
||||
}))
|
||||
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
|
||||
const topPriority = Math.trunc(this.clampPriority(
|
||||
basePriority + routes.length - 1,
|
||||
MIN_ROUTE_PRIORITY + routes.length - 1,
|
||||
MAX_ROUTE_PRIORITY,
|
||||
));
|
||||
const integerPriorities = new Map<number, number>();
|
||||
priorityOrder.forEach((entry, index) => {
|
||||
integerPriorities.set(entry.originalIndex, topPriority - index);
|
||||
});
|
||||
|
||||
return routes.map((route, index) => ({
|
||||
...route,
|
||||
priority: integerPriorities.get(index) ?? MIN_ROUTE_PRIORITY,
|
||||
}));
|
||||
}
|
||||
|
||||
private static clampPriority(
|
||||
priority: number,
|
||||
min = MIN_ROUTE_PRIORITY,
|
||||
max = MAX_ROUTE_PRIORITY,
|
||||
): number {
|
||||
if (!Number.isFinite(priority)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(max, Math.max(min, priority));
|
||||
}
|
||||
|
||||
private static getExpandedPortCount(portRange: plugins.smartproxy.IRouteConfig['match']['ports'] | undefined): number {
|
||||
if (portRange === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (typeof portRange === 'number') {
|
||||
return Number.isFinite(portRange) ? 1 : sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
|
||||
}
|
||||
if (!Array.isArray(portRange)) {
|
||||
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const portEntry of portRange) {
|
||||
if (typeof portEntry === 'number') {
|
||||
if (!Number.isFinite(portEntry)) {
|
||||
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
|
||||
}
|
||||
count++;
|
||||
} else if (
|
||||
portEntry
|
||||
&& typeof portEntry === 'object'
|
||||
&& Number.isFinite(portEntry.from)
|
||||
&& Number.isFinite(portEntry.to)
|
||||
&& portEntry.from <= portEntry.to
|
||||
) {
|
||||
count += Math.floor(portEntry.to) - Math.floor(portEntry.from) + 1;
|
||||
} else {
|
||||
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
|
||||
}
|
||||
if (count > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
private static normalizeMaxConnections(value: IRouteSecurity['maxConnections']): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
private static forceIpRateLimit(
|
||||
rateLimit: IRouteSecurity['rateLimit'] | undefined,
|
||||
): IRouteSecurity['rateLimit'] | undefined {
|
||||
if (!rateLimit) {
|
||||
return undefined;
|
||||
}
|
||||
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
|
||||
return {
|
||||
...rest,
|
||||
keyBy: 'ip',
|
||||
} as IRouteSecurity['rateLimit'];
|
||||
}
|
||||
|
||||
private static sanitizeSourcePolicySecurity(security: IRouteSecurity): IRouteSecurity {
|
||||
const sanitized = structuredClone(security);
|
||||
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
|
||||
if (maxConnections === undefined) {
|
||||
delete sanitized.maxConnections;
|
||||
} else {
|
||||
sanitized.maxConnections = maxConnections;
|
||||
}
|
||||
if (sanitized.rateLimit) {
|
||||
sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static isEmptySecurity(security: IRouteSecurity): boolean {
|
||||
return Object.keys(security).length === 0;
|
||||
}
|
||||
|
||||
private static getSourceMatchEntries(security: IRouteSecurity): string[] {
|
||||
const entries = security.ipAllowList || [];
|
||||
const normalizedEntries: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const rawEntry = typeof entry === 'string' ? entry : entry.ip;
|
||||
if (typeof rawEntry !== 'string') continue;
|
||||
const normalizedEntry = rawEntry.trim();
|
||||
if (normalizedEntry) {
|
||||
normalizedEntries.push(normalizedEntry);
|
||||
}
|
||||
}
|
||||
return [...new Set(normalizedEntries)];
|
||||
}
|
||||
|
||||
private static matchesAllSources(sourceMatches: string[]): boolean {
|
||||
return sourceMatches.includes('*')
|
||||
|| (sourceMatches.includes('0.0.0.0/0') && sourceMatches.includes('::/0'));
|
||||
}
|
||||
|
||||
private static buildBindingSecurity(
|
||||
routeSecurity: IRouteSecurity | undefined,
|
||||
profileSecurity: IRouteSecurity,
|
||||
binding: IRouteSourcePolicyBinding,
|
||||
pathPolicy?: IRoutePathPolicyBinding,
|
||||
): IRouteSecurity | undefined {
|
||||
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
|
||||
const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
|
||||
|
||||
if (binding.rateLimit !== undefined) {
|
||||
sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
|
||||
}
|
||||
if (binding.maxConnections !== undefined) {
|
||||
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
|
||||
if (maxConnections === undefined) {
|
||||
delete sourceSecurity.maxConnections;
|
||||
} else {
|
||||
sourceSecurity.maxConnections = maxConnections;
|
||||
}
|
||||
}
|
||||
if (binding.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
|
||||
sourceSecurity.rateLimit = {
|
||||
...sourceSecurity.rateLimit,
|
||||
errorMessage: binding.onExceeded.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathPolicy?.rateLimit !== undefined) {
|
||||
sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
|
||||
}
|
||||
if (pathPolicy?.maxConnections !== undefined) {
|
||||
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
|
||||
if (maxConnections === undefined) {
|
||||
delete sourceSecurity.maxConnections;
|
||||
} else {
|
||||
sourceSecurity.maxConnections = maxConnections;
|
||||
}
|
||||
}
|
||||
if (pathPolicy?.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
|
||||
sourceSecurity.rateLimit = {
|
||||
...sourceSecurity.rateLimit,
|
||||
errorMessage: pathPolicy.onExceeded.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const mergedSecurity = this.sanitizeSourcePolicySecurity({
|
||||
...baseSecurity,
|
||||
...sourceSecurity,
|
||||
});
|
||||
|
||||
return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
|
||||
}
|
||||
|
||||
private static omitSourceMatchFields(security: IRouteSecurity): IRouteSecurity {
|
||||
const { ipAllowList: _ipAllowList, ...controls } = security;
|
||||
return this.sanitizeSourcePolicySecurity(controls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
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';
|
||||
|
||||
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
allowRoutesByClientSourceIp?: boolean;
|
||||
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,
|
||||
allowRoutesByClientSourceIp: data.allowRoutesByClientSourceIp === true,
|
||||
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);
|
||||
if (patch.allowRoutesByClientSourceIp !== undefined) {
|
||||
profile.allowRoutesByClientSourceIp = patch.allowRoutesByClientSourceIp === true;
|
||||
}
|
||||
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 → VPN client grants
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
|
||||
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
|
||||
*
|
||||
* 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. Profiles can also opt
|
||||
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
|
||||
*/
|
||||
public getMatchingVpnClients(
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): TVpnClientAllowEntry[] {
|
||||
const entries: TVpnClientAllowEntry[] = [];
|
||||
const routeDomains = this.getRouteDomains(route);
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.clientId) 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 (
|
||||
profile.allowRoutesByClientSourceIp === true
|
||||
&& this.routeHasSourcePolicy(route)
|
||||
) {
|
||||
fullAccess = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullAccess) {
|
||||
entries.push(client.clientId);
|
||||
} else if (scopedDomains.size > 0) {
|
||||
entries.push({ clientId: client.clientId, 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;
|
||||
const dcRoute = route.route as IDcRouterRouteConfig;
|
||||
const routeDomains = this.getRouteDomains(dcRoute);
|
||||
const profileMatchesRoute = this.routeMatchesProfile(
|
||||
dcRoute,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
);
|
||||
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
|
||||
&& this.routeHasSourcePolicy(dcRoute);
|
||||
if (profileMatchesRoute || sourceIpMatchesRoute) {
|
||||
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 = this.getRouteDomains(route);
|
||||
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 routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
|
||||
const security = (route as any).security;
|
||||
const blockEntries = Array.isArray(security?.ipBlockList)
|
||||
? security.ipBlockList
|
||||
: security?.ipBlockList
|
||||
? [security.ipBlockList]
|
||||
: [];
|
||||
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
|
||||
}
|
||||
|
||||
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
|
||||
const domains = (route.match as any)?.domains;
|
||||
if (!domains) return [];
|
||||
return Array.isArray(domains) ? domains : [domains];
|
||||
}
|
||||
|
||||
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,
|
||||
allowRoutesByClientSourceIp: doc.allowRoutesByClientSourceIp === true,
|
||||
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.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
||||
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.allowRoutesByClientSourceIp = profile.allowRoutesByClientSourceIp === true;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
|
||||
const REDIRECT_STATUS_CODE = 301;
|
||||
const REDIRECT_PRIORITY = 0;
|
||||
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
|
||||
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface IRedirectCandidate {
|
||||
key: string;
|
||||
id: string;
|
||||
domainPattern: string;
|
||||
pathPattern?: string;
|
||||
sourceRouteNames: Set<string>;
|
||||
sourceRouteIds: Set<string>;
|
||||
remoteIngress?: IRouteRemoteIngress;
|
||||
}
|
||||
|
||||
interface IRedirectConflict {
|
||||
routeName: string;
|
||||
covers: boolean;
|
||||
}
|
||||
|
||||
export interface IHttpRedirectDerivationResult {
|
||||
redirects: IHttpRedirectInfo[];
|
||||
runtimeRoutes: IDcRouterRouteConfig[];
|
||||
}
|
||||
|
||||
export function deriveHttpRedirectConfiguration(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IHttpRedirectDerivationResult {
|
||||
const candidates = collectRedirectCandidates(routes);
|
||||
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
|
||||
const redirects: IHttpRedirectInfo[] = [];
|
||||
const runtimeRoutes: IDcRouterRouteConfig[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const conflict = findHttpConflict(candidate, httpRoutes);
|
||||
const redirectInfo: IHttpRedirectInfo = {
|
||||
id: candidate.id,
|
||||
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
|
||||
domainPattern: candidate.domainPattern,
|
||||
pathPattern: candidate.pathPattern,
|
||||
fromTemplate: 'http://{domain}{path}',
|
||||
toTemplate: REDIRECT_TARGET_TEMPLATE,
|
||||
statusCode: REDIRECT_STATUS_CODE,
|
||||
priority: REDIRECT_PRIORITY,
|
||||
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
|
||||
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
|
||||
coveredByRouteNames: conflict ? [conflict.routeName] : [],
|
||||
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
|
||||
notes: conflict
|
||||
? conflict.covers
|
||||
? 'An explicit HTTP route already covers this redirect scope.'
|
||||
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
redirects.push(redirectInfo);
|
||||
|
||||
if (redirectInfo.status === 'active') {
|
||||
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
return { redirects, runtimeRoutes };
|
||||
}
|
||||
|
||||
export function deriveHttpRedirects(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirectConfiguration(routes).redirects;
|
||||
}
|
||||
|
||||
export function buildHttpRedirectRuntimeRoutes(
|
||||
routes: plugins.smartproxy.IRouteConfig[],
|
||||
): IDcRouterRouteConfig[] {
|
||||
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
|
||||
}
|
||||
|
||||
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
|
||||
const candidates = new Map<string, IRedirectCandidate>();
|
||||
|
||||
for (const route of routes) {
|
||||
if (!isHttpsRedirectSource(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const domainPattern of getDomainPatterns(route)) {
|
||||
const key = createRedirectKey(domainPattern, route.match.path);
|
||||
const existing = candidates.get(key);
|
||||
if (existing) {
|
||||
existing.sourceRouteNames.add(getRouteDisplayName(route));
|
||||
if (route.id) existing.sourceRouteIds.add(route.id);
|
||||
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = createRedirectRouteName(domainPattern, route.match.path);
|
||||
candidates.set(key, {
|
||||
key,
|
||||
id,
|
||||
domainPattern,
|
||||
pathPattern: route.match.path,
|
||||
sourceRouteNames: new Set([getRouteDisplayName(route)]),
|
||||
sourceRouteIds: new Set(route.id ? [route.id] : []),
|
||||
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
if (isGeneratedRedirectRoute(route)) return false;
|
||||
if (route.enabled === false) return false;
|
||||
if (route.action.type !== 'forward') return false;
|
||||
if (!route.match.ports) return false;
|
||||
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
|
||||
if (!route.action.tls) return false;
|
||||
if (!route.match.domains) return false;
|
||||
if (route.match.transport === 'udp') return false;
|
||||
if (route.match.protocol && route.match.protocol !== 'http') return false;
|
||||
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
if (isGeneratedRedirectRoute(route)) return false;
|
||||
if (route.enabled === false) return false;
|
||||
if (!route.match.ports) return false;
|
||||
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
|
||||
if (route.match.transport === 'udp') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findHttpConflict(
|
||||
candidate: IRedirectCandidate,
|
||||
httpRoutes: plugins.smartproxy.IRouteConfig[],
|
||||
): IRedirectConflict | undefined {
|
||||
for (const route of httpRoutes) {
|
||||
if (!httpRouteOverlapsCandidate(route, candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
routeName: getRouteDisplayName(route),
|
||||
covers: httpRouteCoversCandidate(route, candidate),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function httpRouteOverlapsCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidate: IRedirectCandidate,
|
||||
): boolean {
|
||||
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
|
||||
&& pathOverlaps(route.match.path, candidate.pathPattern);
|
||||
}
|
||||
|
||||
function httpRouteCoversCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidate: IRedirectCandidate,
|
||||
): boolean {
|
||||
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return routeDomainCoversCandidate(route, candidate.domainPattern)
|
||||
&& pathCovers(route.match.path, candidate.pathPattern);
|
||||
}
|
||||
|
||||
function routeDomainOverlapsCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidatePattern: string,
|
||||
): boolean {
|
||||
const routePatterns = getDomainPatterns(route);
|
||||
if (routePatterns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
|
||||
}
|
||||
|
||||
function routeDomainCoversCandidate(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
candidatePattern: string,
|
||||
): boolean {
|
||||
const routePatterns = getDomainPatterns(route);
|
||||
if (routePatterns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
|
||||
}
|
||||
|
||||
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
|
||||
if (!route.match.domains) return [];
|
||||
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
||||
}
|
||||
|
||||
function normalizePattern(pattern: string): string {
|
||||
return pattern.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
|
||||
const cover = normalizePattern(coverPattern);
|
||||
const candidate = normalizePattern(candidatePattern);
|
||||
if (cover === candidate) return true;
|
||||
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
|
||||
|
||||
const coverSuffix = getLeadingWildcardSuffix(cover);
|
||||
const candidateSuffix = getLeadingWildcardSuffix(candidate);
|
||||
if (coverSuffix && candidateSuffix) {
|
||||
return candidateSuffix.endsWith(coverSuffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
|
||||
const first = normalizePattern(firstPattern);
|
||||
const second = normalizePattern(secondPattern);
|
||||
if (first === second) return true;
|
||||
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
|
||||
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
|
||||
|
||||
const firstSuffix = getLeadingWildcardSuffix(first);
|
||||
const secondSuffix = getLeadingWildcardSuffix(second);
|
||||
if (firstSuffix && secondSuffix) {
|
||||
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
|
||||
const regex = wildcardPatternToRegex(normalizePattern(pattern));
|
||||
return regex.test(normalizePattern(hostname));
|
||||
}
|
||||
|
||||
function wildcardPatternToRegex(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
|
||||
}
|
||||
|
||||
function getLeadingWildcardSuffix(pattern: string): string | undefined {
|
||||
if (!pattern.startsWith('*')) return undefined;
|
||||
if (pattern.slice(1).includes('*')) return undefined;
|
||||
return pattern.slice(1);
|
||||
}
|
||||
|
||||
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
|
||||
if (!coverPath) return true;
|
||||
if (!candidatePath) return false;
|
||||
if (coverPath === candidatePath) return true;
|
||||
if (!coverPath.includes('*')) return false;
|
||||
const coverPrefix = coverPath.split('*')[0];
|
||||
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
|
||||
const candidatePrefix = candidatePath.split('*')[0];
|
||||
return candidatePrefix.startsWith(coverPrefix);
|
||||
}
|
||||
|
||||
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
|
||||
if (!firstPath || !secondPath) return true;
|
||||
if (firstPath === secondPath) return true;
|
||||
const firstPrefix = firstPath.split('*')[0];
|
||||
const secondPrefix = secondPath.split('*')[0];
|
||||
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
|
||||
}
|
||||
|
||||
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
|
||||
return {
|
||||
id: candidate.id,
|
||||
name: candidate.id,
|
||||
description: 'Generated HTTP to HTTPS redirect',
|
||||
priority: REDIRECT_PRIORITY,
|
||||
tags: ['system', 'redirect', 'auto'],
|
||||
match: {
|
||||
ports: 80,
|
||||
domains: candidate.domainPattern,
|
||||
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
|
||||
},
|
||||
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRemoteIngress(
|
||||
current: IRouteRemoteIngress | undefined,
|
||||
next: IRouteRemoteIngress | undefined,
|
||||
): IRouteRemoteIngress | undefined {
|
||||
if (!next?.enabled) return current;
|
||||
if (!current?.enabled) {
|
||||
return {
|
||||
enabled: true,
|
||||
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const currentFilter = current.edgeFilter || [];
|
||||
const nextFilter = next.edgeFilter || [];
|
||||
if (currentFilter.length === 0 || nextFilter.length === 0) {
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
|
||||
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
|
||||
}
|
||||
|
||||
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
|
||||
const key = createRedirectKey(domainPattern, pathPattern);
|
||||
const slug = key
|
||||
.replace(/\*/g, 'wildcard')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'route';
|
||||
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
|
||||
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
|
||||
}
|
||||
|
||||
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
|
||||
return route.name || route.id || 'unnamed-route';
|
||||
}
|
||||
|
||||
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
||||
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
|
||||
}
|
||||
|
||||
function createHttpRedirectHandler(
|
||||
locationTemplate: string,
|
||||
statusCode: number,
|
||||
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
|
||||
return (socket, context) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', handleData);
|
||||
socket.removeListener('error', cleanup);
|
||||
socket.removeListener('close', cleanup);
|
||||
};
|
||||
|
||||
const handleData = (data: string | Uint8Array) => {
|
||||
cleanup();
|
||||
const request = parseHttpRequest(data);
|
||||
if (!request) {
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
|
||||
const finalLocation = locationTemplate
|
||||
.replace('{domain}', domain)
|
||||
.replace('{port}', String(context.port))
|
||||
.replace('{path}', request.path || '/')
|
||||
.replace('{clientIp}', context.clientIp);
|
||||
const message = `Redirecting to ${finalLocation}`;
|
||||
const response = [
|
||||
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
|
||||
`Location: ${finalLocation}`,
|
||||
'Content-Type: text/plain',
|
||||
`Content-Length: ${message.length}`,
|
||||
'Connection: close',
|
||||
'',
|
||||
message,
|
||||
].join('\r\n');
|
||||
|
||||
socket.end(response);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
|
||||
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
|
||||
socket.once('data', handleData);
|
||||
socket.once('error', cleanup);
|
||||
socket.once('close', cleanup);
|
||||
};
|
||||
}
|
||||
|
||||
function parseHttpRequest(data: string | Uint8Array): {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
} | undefined {
|
||||
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
const headerEnd = requestText.indexOf('\r\n\r\n');
|
||||
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
|
||||
const lines = headerText.split('\r\n');
|
||||
const [method, rawPath] = (lines[0] || '').split(' ');
|
||||
if (!method || !rawPath) return undefined;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of lines.slice(1)) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex <= 0) continue;
|
||||
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
||||
const value = line.slice(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
path: normalizeRequestPath(rawPath),
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequestPath(rawPath: string): string {
|
||||
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(rawPath);
|
||||
return `${url.pathname}${url.search}` || '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
return rawPath.startsWith('/') ? rawPath : '/';
|
||||
}
|
||||
|
||||
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
|
||||
if (!hostHeader) return undefined;
|
||||
const host = hostHeader.split(',')[0].trim();
|
||||
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
|
||||
if (host.startsWith('[')) {
|
||||
const bracketIndex = host.indexOf(']');
|
||||
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
|
||||
}
|
||||
|
||||
return host.replace(/:(80|443)$/, '');
|
||||
}
|
||||
|
||||
function getHttpStatusText(statusCode: number): string {
|
||||
switch (statusCode) {
|
||||
case 301:
|
||||
return 'Moved Permanently';
|
||||
case 302:
|
||||
return 'Found';
|
||||
case 307:
|
||||
return 'Temporary Redirect';
|
||||
case 308:
|
||||
return 'Permanent Redirect';
|
||||
default:
|
||||
return 'Redirect';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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 { GatewayClientManager } from './classes.gateway-client-manager.js';
|
||||
export { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
||||
export * from './helpers.http-redirects.js';
|
||||
export { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IApiTokenPolicy, 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 policy?: IApiTokenPolicy;
|
||||
|
||||
@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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user