Compare commits
701 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
@@ -21,3 +21,4 @@ dist_*/
|
||||
**/.claude/settings.local.json
|
||||
.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();
|
||||
+2341
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.38.2",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
"dist_serve"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/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.1",
|
||||
"@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.11.1",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
|
||||
"@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@^5.8.0",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.21.1",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.4.0",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
}
|
||||
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,79 +0,0 @@
|
||||
{
|
||||
"@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
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "dcrouter",
|
||||
"description": "A traffic router intended to be gating your datacenter.",
|
||||
"npmPackagename": "@serve.zone/dcrouter",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone",
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"traffic router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "verdaccio.lossless.digital"
|
||||
}
|
||||
}
|
||||
+64
-47
@@ -1,64 +1,82 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "6.0.0",
|
||||
"version": "13.38.2",
|
||||
"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"
|
||||
"./interfaces": "./dist_ts_interfaces/index.js",
|
||||
"./apiclient": "./dist_ts_apiclient/index.js"
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"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 && npm run bundle)",
|
||||
"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": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.2.3"
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdocker": "^2.4.0",
|
||||
"@git.zone/tsdeno": "^1.4.0",
|
||||
"@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.2.6",
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.42.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.8.1",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@design.estate/dees-catalog": "^3.83.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.3.1",
|
||||
"@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.1",
|
||||
"@push.rocks/smartdns": "^7.9.3",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.11",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.2",
|
||||
"@push.rocks/smartnetwork": "^4.4.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.3",
|
||||
"@push.rocks/smartproxy": "^25.3.1",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.11.1",
|
||||
"@push.rocks/smartradius": "^1.1.2",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"uuid": "^13.0.0"
|
||||
"@push.rocks/smartvpn": "1.20.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@serve.zone/catalog": "^2.12.4",
|
||||
"@serve.zone/interfaces": "^5.8.0",
|
||||
"@serve.zone/remoteingress": "^4.21.1",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
@@ -86,24 +104,23 @@
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"binary/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"ts_apiclient/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"dist_ts_apiclient/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"cli.ts.js",
|
||||
"cli.child.js",
|
||||
"cli.child.ts",
|
||||
"deno.json",
|
||||
"tsconfig.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+3842
-4004
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
mongodb-memory-server: true
|
||||
puppeteer: true
|
||||
+1
-1
@@ -133,7 +133,7 @@ The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
Configuration in `.smartconfig.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
|
||||
@@ -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 }
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -129,7 +129,8 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
cacheConfig: {
|
||||
opsServerPort: 3104,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
};
|
||||
@@ -142,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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();
|
||||
@@ -9,7 +9,8 @@ tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
cacheConfig: { enabled: false }
|
||||
opsServerPort: 3100,
|
||||
dbConfig: { enabled: false }
|
||||
});
|
||||
|
||||
await dcRouter.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();
|
||||
@@ -103,6 +103,9 @@ tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
}, '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');
|
||||
}
|
||||
@@ -197,6 +200,9 @@ tap.test('Error retry utilities should work correctly', async () => {
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
@@ -262,6 +268,9 @@ tap.test('Error handling can be combined with retry for robust operations', asyn
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
+36
-21
@@ -5,11 +5,14 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3102,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
@@ -18,30 +21,34 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -52,12 +59,16 @@ tap.test('should verify valid JWT identity', async () => {
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -74,7 +85,7 @@ tap.test('should reject invalid JWT', async () => {
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -85,13 +96,17 @@ tap.test('should verify JWT matches identity data', async () => {
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
@@ -105,7 +120,7 @@ tap.test('should handle logout', async () => {
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3102/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -128,4 +143,4 @@ tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||
|
||||
const emptyProtocolDistribution = {
|
||||
h1Active: 0,
|
||||
h1Total: 0,
|
||||
h2Active: 0,
|
||||
h2Total: 0,
|
||||
h3Active: 0,
|
||||
h3Total: 0,
|
||||
wsActive: 0,
|
||||
wsTotal: 0,
|
||||
otherActive: 0,
|
||||
otherTotal: 0,
|
||||
};
|
||||
|
||||
function createProxyMetrics(args: {
|
||||
connectionsByRoute: Map<string, number>;
|
||||
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,69 @@
|
||||
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 applySet(document: Record<string, any>, set: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(set)) {
|
||||
setPath(document, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeDb(currentVersion: string) {
|
||||
const ledgerDocument = {
|
||||
nameId: 'smartmigration:smartmigration',
|
||||
data: {
|
||||
currentVersion,
|
||||
steps: {},
|
||||
lock: { holder: null, acquiredAt: null, expiresAt: null },
|
||||
checkpoints: {},
|
||||
},
|
||||
};
|
||||
|
||||
const emptyCollection = {
|
||||
find: () => ({
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
}),
|
||||
updateMany: async () => ({ modifiedCount: 0 }),
|
||||
};
|
||||
|
||||
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 : emptyCollection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
|
||||
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.currentVersionBefore).toEqual('13.16.0');
|
||||
expect(result.currentVersionAfter).toEqual('13.31.0');
|
||||
expect(result.stepsApplied).toHaveLength(3);
|
||||
});
|
||||
|
||||
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();
|
||||
+67
-21
@@ -4,27 +4,51 @@ import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3101,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
identity: adminIdentity,
|
||||
detailed: false,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
expect(response.health.services).toHaveProperty('OpsServer');
|
||||
@@ -32,14 +56,15 @@ tap.test('should respond to health status request', async () => {
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
identity: adminIdentity,
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
expect(response.stats).toHaveProperty('cpuUsage');
|
||||
@@ -48,37 +73,58 @@ tap.test('should respond to server statistics request', async () => {
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
|
||||
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('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
expect(response.config).toHaveProperty('tls');
|
||||
expect(response.config).toHaveProperty('cache');
|
||||
expect(response.config).toHaveProperty('radius');
|
||||
expect(response.config).toHaveProperty('remoteIngress');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
identity: adminIdentity,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should reject unauthenticated requests', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
try {
|
||||
await healthRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
@@ -5,11 +5,14 @@ import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
opsServerPort: 3103,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
@@ -18,23 +21,27 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
password: testAdminPassword
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.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>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -49,7 +56,7 @@ tap.test('should allow admin to verify identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -64,7 +71,7 @@ tap.test('should reject verify identity without identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -82,35 +89,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow access to public endpoints without auth', async () => {
|
||||
tap.test('should reject protected endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
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 read-only config access', async () => {
|
||||
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'http://localhost:3103/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
// Config is read-only and doesn't require auth
|
||||
const response = await configRequest.fire({});
|
||||
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('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
console.log('Configuration read successfully');
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
|
||||
function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void {
|
||||
(resolver as any).targets.set(target.id, target);
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
|
||||
return {
|
||||
id: 'profile-1',
|
||||
name: 'STANDARD',
|
||||
description: 'Test profile',
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTarget(overrides: Partial<INetworkTarget> = {}): INetworkTarget {
|
||||
return {
|
||||
id: 'target-1',
|
||||
name: 'INFRA',
|
||||
description: 'Test target',
|
||||
host: '192.168.5.247',
|
||||
port: 443,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
createdBy: 'test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||
return {
|
||||
name: 'test-route',
|
||||
match: { ports: 443, domains: 'test.example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'placeholder', port: 80 }] },
|
||||
...overrides,
|
||||
} as IRouteConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resolution tests
|
||||
// ============================================================================
|
||||
|
||||
let resolver: ReferenceResolver;
|
||||
|
||||
tap.test('should create ReferenceResolver instance', async () => {
|
||||
resolver = new ReferenceResolver();
|
||||
expect(resolver).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listProfiles()).toBeArray();
|
||||
expect(resolver.listProfiles().length).toEqual(0);
|
||||
expect(resolver.listTargets()).toBeArray();
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
});
|
||||
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should merge inline route security with profile security', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// IP lists are unioned
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
|
||||
|
||||
// Inline maxConnections overrides profile
|
||||
expect(result.route.security!.maxConnections).toEqual(5000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
|
||||
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
const baseProfile = makeProfile({
|
||||
id: 'base-profile',
|
||||
name: 'BASE',
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8'],
|
||||
maxConnections: 500,
|
||||
},
|
||||
});
|
||||
injectProfile(resolver, baseProfile);
|
||||
|
||||
const extendedProfile = makeProfile({
|
||||
id: 'extended-profile',
|
||||
name: 'EXTENDED',
|
||||
security: {
|
||||
ipAllowList: ['160.79.104.0/21'],
|
||||
},
|
||||
extendsProfiles: ['base-profile'],
|
||||
});
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Should have IPs from both base and extended profiles
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
const profileA = makeProfile({
|
||||
id: 'circular-a',
|
||||
name: 'A',
|
||||
security: { ipAllowList: ['1.1.1.1'] },
|
||||
extendsProfiles: ['circular-b'],
|
||||
});
|
||||
const profileB = makeProfile({
|
||||
id: 'circular-b',
|
||||
name: 'B',
|
||||
security: { ipAllowList: ['2.2.2.2'] },
|
||||
extendsProfiles: ['circular-a'],
|
||||
});
|
||||
injectProfile(resolver, profileA);
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
// ---- Network target resolution ----
|
||||
|
||||
tap.test('should resolve network target onto a route', async () => {
|
||||
const target = makeTarget();
|
||||
injectTarget(resolver, target);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'target-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.action.targets).toBeTruthy();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle missing target gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route targets should be unchanged (still the placeholder)
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Combined resolution ----
|
||||
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Security from profile
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
|
||||
// Target from network target
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
tap.test('should skip resolution when no metadata refs', async () => {
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['1.2.3.4'] },
|
||||
});
|
||||
const metadata: IRouteMetadata = {};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be completely unchanged
|
||||
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
});
|
||||
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const first = resolver.resolveRoute(route, metadata);
|
||||
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||
|
||||
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||
});
|
||||
|
||||
// ---- Lookup helpers ----
|
||||
|
||||
tap.test('should find routes by profile ref (sync)', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-a', {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
route: makeRoute({ name: 'route-b' }),
|
||||
enabled: true,
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
storedRoutes.set('route-c', {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(2);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
|
||||
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||
expect(targetRefs.length).toEqual(2);
|
||||
expect(targetRefs).toContain('route-b');
|
||||
expect(targetRefs).toContain('route-c');
|
||||
});
|
||||
|
||||
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-x', {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-x');
|
||||
expect(usage[0].routeName).toEqual('my-route');
|
||||
});
|
||||
|
||||
tap.test('should get target usage for a specific target ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-y', {
|
||||
id: 'route-y',
|
||||
route: makeRoute({ name: 'other-route' }),
|
||||
enabled: true,
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-y');
|
||||
expect(usage[0].routeName).toEqual('other-route');
|
||||
});
|
||||
|
||||
// ---- Profile/target getters ----
|
||||
|
||||
tap.test('should get profile by name', async () => {
|
||||
const profile = resolver.getProfileByName('STANDARD');
|
||||
expect(profile).toBeTruthy();
|
||||
expect(profile!.id).toEqual('profile-1');
|
||||
});
|
||||
|
||||
tap.test('should get target by name', async () => {
|
||||
const target = resolver.getTargetByName('INFRA');
|
||||
expect(target).toBeTruthy();
|
||||
expect(target!.id).toEqual('target-1');
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||
expect(profile).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent target name', async () => {
|
||||
const target = resolver.getTargetByName('NONEXISTENT');
|
||||
expect(target).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -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,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,289 +0,0 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test data
|
||||
const testData = {
|
||||
string: 'Hello, World!',
|
||||
json: { name: 'test', value: 42, nested: { data: true } },
|
||||
largeString: 'x'.repeat(10000)
|
||||
};
|
||||
|
||||
tap.test('Storage Manager - Memory Backend', async () => {
|
||||
// Create StorageManager without config (defaults to memory)
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test basic get/set
|
||||
await storage.set('/test/key', testData.string);
|
||||
const value = await storage.get('/test/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test JSON helpers
|
||||
await storage.setJSON('/test/json', testData.json);
|
||||
const jsonValue = await storage.getJSON('/test/json');
|
||||
expect(jsonValue).toEqual(testData.json);
|
||||
|
||||
// Test exists
|
||||
expect(await storage.exists('/test/key')).toEqual(true);
|
||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||
|
||||
// Test delete
|
||||
await storage.delete('/test/key');
|
||||
expect(await storage.exists('/test/key')).toEqual(false);
|
||||
|
||||
// Test list
|
||||
await storage.set('/items/1', 'one');
|
||||
await storage.set('/items/2', 'two');
|
||||
await storage.set('/other/3', 'three');
|
||||
|
||||
const items = await storage.list('/items');
|
||||
expect(items.length).toEqual(2);
|
||||
expect(items).toContain('/items/1');
|
||||
expect(items).toContain('/items/2');
|
||||
|
||||
// Verify memory backend
|
||||
expect(storage.getBackend()).toEqual('memory');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||
|
||||
// Clean up test directory if it exists
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Create StorageManager with filesystem path
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/test/file', testData.string);
|
||||
const value = await storage.get('/test/file');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Verify file exists on disk
|
||||
const filePath = path.join(testDir, 'test', 'file');
|
||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toEqual(true);
|
||||
|
||||
// Test atomic writes (temp file should not exist)
|
||||
const tempPath = filePath + '.tmp';
|
||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||
expect(tempExists).toEqual(false);
|
||||
|
||||
// Test nested paths
|
||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||
expect(nestedValue).toEqual(testData.largeString);
|
||||
|
||||
// Test list with filesystem
|
||||
await storage.set('/fs/items/a', 'alpha');
|
||||
await storage.set('/fs/items/b', 'beta');
|
||||
await storage.set('/fs/other/c', 'gamma');
|
||||
|
||||
// Filesystem backend now properly supports list
|
||||
const fsItems = await storage.list('/fs/items');
|
||||
expect(fsItems.length).toEqual(2); // Should find both items
|
||||
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||
// Create in-memory storage for custom functions
|
||||
const customStore = new Map<string, string>();
|
||||
|
||||
const storage = new StorageManager({
|
||||
readFunction: async (key: string) => {
|
||||
return customStore.get(key) || null;
|
||||
},
|
||||
writeFunction: async (key: string, value: string) => {
|
||||
customStore.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/custom/key', testData.string);
|
||||
expect(customStore.has('/custom/key')).toEqual(true);
|
||||
|
||||
const value = await storage.get('/custom/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test that delete sets empty value (as per implementation)
|
||||
await storage.delete('/custom/key');
|
||||
expect(customStore.get('/custom/key')).toEqual('');
|
||||
|
||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||
expect(storage.getBackend()).toEqual('custom');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Key Validation', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test key normalization
|
||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||
const value1 = await storage.get('/test/key');
|
||||
expect(value1).toEqual('value1');
|
||||
|
||||
// Test dangerous path elements are removed
|
||||
await storage.set('/test/../danger/key', 'value2');
|
||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||
expect(value2).toEqual('value2');
|
||||
|
||||
// Test multiple slashes are normalized
|
||||
await storage.set('/test///multiple////slashes', 'value3');
|
||||
const value3 = await storage.get('/test/multiple/slashes');
|
||||
expect(value3).toEqual('value3');
|
||||
|
||||
// Test invalid keys throw errors
|
||||
let emptyKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set('', 'value');
|
||||
} catch (error) {
|
||||
emptyKeyError = error as Error;
|
||||
}
|
||||
expect(emptyKeyError).toBeTruthy();
|
||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
|
||||
let nullKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set(null as any, 'value');
|
||||
} catch (error) {
|
||||
nullKeyError = error as Error;
|
||||
}
|
||||
expect(nullKeyError).toBeTruthy();
|
||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||
const storage = new StorageManager();
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Simulate concurrent writes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Verify all writes succeeded
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = await storage.get(`/concurrent/key${i}`);
|
||||
expect(value).toEqual(`value${i}`);
|
||||
}
|
||||
|
||||
// Test concurrent reads
|
||||
const readPromises: Promise<string | null>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(results[i]).toEqual(`value${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Backend Priority', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||
|
||||
// Test that custom functions take priority over fsPath
|
||||
let warningLogged = false;
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (message: string) => {
|
||||
if (message.includes('Using custom read/write functions')) {
|
||||
warningLogged = true;
|
||||
}
|
||||
};
|
||||
|
||||
const storage = new StorageManager({
|
||||
fsPath: testDir,
|
||||
readFunction: async () => 'custom-value',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
console.warn = originalWarn;
|
||||
|
||||
expect(warningLogged).toEqual(true);
|
||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Error Handling', async () => {
|
||||
// Test filesystem errors
|
||||
const storage = new StorageManager({
|
||||
readFunction: async () => {
|
||||
throw new Error('Read error');
|
||||
},
|
||||
writeFunction: async () => {
|
||||
throw new Error('Write error');
|
||||
}
|
||||
});
|
||||
|
||||
// Read errors should return null
|
||||
const value = await storage.get('/error/key');
|
||||
expect(value).toEqual(null);
|
||||
|
||||
// Write errors should propagate
|
||||
let writeError: Error | null = null;
|
||||
try {
|
||||
await storage.set('/error/key', 'value');
|
||||
} catch (error) {
|
||||
writeError = error as Error;
|
||||
}
|
||||
expect(writeError).toBeTruthy();
|
||||
expect(writeError?.message).toEqual('Write error');
|
||||
|
||||
// Test JSON parse errors
|
||||
const jsonStorage = new StorageManager({
|
||||
readFunction: async () => 'invalid json',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
// Test JSON parse errors
|
||||
let jsonError: Error | null = null;
|
||||
try {
|
||||
await jsonStorage.getJSON('/invalid/json');
|
||||
} catch (error) {
|
||||
jsonError = error as Error;
|
||||
}
|
||||
expect(jsonError).toBeTruthy();
|
||||
expect(jsonError?.message).toContain('JSON');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - List Operations', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Populate storage with hierarchical data
|
||||
await storage.set('/app/config/database', 'db-config');
|
||||
await storage.set('/app/config/cache', 'cache-config');
|
||||
await storage.set('/app/data/users/1', 'user1');
|
||||
await storage.set('/app/data/users/2', 'user2');
|
||||
await storage.set('/app/logs/error.log', 'errors');
|
||||
|
||||
// List root
|
||||
const rootItems = await storage.list('/');
|
||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// List specific paths
|
||||
const configItems = await storage.list('/app/config');
|
||||
expect(configItems.length).toEqual(2);
|
||||
expect(configItems).toContain('/app/config/database');
|
||||
expect(configItems).toContain('/app/config/cache');
|
||||
|
||||
const userItems = await storage.list('/app/data/users');
|
||||
expect(userItems.length).toEqual(2);
|
||||
|
||||
// List non-existent path
|
||||
const emptyList = await storage.list('/nonexistent/path');
|
||||
expect(emptyList.length).toEqual(0);
|
||||
});
|
||||
|
||||
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
|
||||
+49
-15
@@ -1,21 +1,55 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
// 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...');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '6.0.0',
|
||||
version: '13.38.2',
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
-155
@@ -1,155 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { defaultTsmDbPath } from '../paths.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || defaultTsmDbPath,
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||
if (!CacheDb.instance) {
|
||||
CacheDb.instance = new CacheDb(options);
|
||||
}
|
||||
return CacheDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
CacheDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'CacheDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection info
|
||||
const connectionInfo = await this.localTsmDb.start();
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection URI
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: connectionInfo.connectionUri,
|
||||
mongoDbName: this.options.dbName,
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cache database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('CacheDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from './logger.js';
|
||||
import type { StorageManager } from './storage/index.js';
|
||||
import { CertBackoffDoc } from './db/index.js';
|
||||
|
||||
interface IBackoffEntry {
|
||||
failures: number;
|
||||
@@ -10,74 +10,86 @@ interface IBackoffEntry {
|
||||
|
||||
/**
|
||||
* Manages certificate provisioning scheduling with:
|
||||
* - Per-domain exponential backoff persisted in StorageManager
|
||||
* - Serial stagger queue with configurable delay between provisions
|
||||
* - 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 storageManager: StorageManager;
|
||||
private staggerDelayMs: number;
|
||||
private maxBackoffHours: number;
|
||||
|
||||
// In-memory serial queue
|
||||
private queue: Array<{
|
||||
domain: string;
|
||||
fn: () => Promise<any>;
|
||||
resolve: (value: any) => void;
|
||||
reject: (err: any) => void;
|
||||
}> = [];
|
||||
private processing = false;
|
||||
|
||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||
private backoffCache = new Map<string, IBackoffEntry>();
|
||||
|
||||
constructor(
|
||||
storageManager: StorageManager,
|
||||
options?: { staggerDelayMs?: number; maxBackoffHours?: number }
|
||||
options?: { maxBackoffHours?: number }
|
||||
) {
|
||||
this.storageManager = storageManager;
|
||||
this.staggerDelayMs = options?.staggerDelayMs ?? 3000;
|
||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a domain's backoff entry
|
||||
* Sanitized domain key for storage lookups
|
||||
*/
|
||||
private backoffKey(domain: string): string {
|
||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `/cert-backoff/${clean}`;
|
||||
private sanitizeDomain(domain: string): string {
|
||||
return domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backoff entry from storage (with in-memory cache)
|
||||
* 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 entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||
if (entry) {
|
||||
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 entry;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backoff entry to both cache and storage
|
||||
* Save backoff entry to both cache and database
|
||||
*/
|
||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||
this.backoffCache.set(domain, entry);
|
||||
await this.storageManager.setJSON(this.backoffKey(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
|
||||
* 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);
|
||||
return retryAfter.getTime() > Date.now();
|
||||
if (retryAfter.getTime() > Date.now()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backoff has expired — prune the stale entry
|
||||
this.backoffCache.delete(domain);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,12 +121,23 @@ export class CertProvisionScheduler {
|
||||
async clearBackoff(domain: string): Promise<void> {
|
||||
this.backoffCache.delete(domain);
|
||||
try {
|
||||
await this.storageManager.delete(this.backoffKey(domain));
|
||||
const sanitized = this.sanitizeDomain(domain);
|
||||
const doc = await CertBackoffDoc.findByDomain(sanitized);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
} catch {
|
||||
// Ignore delete errors (key may not exist)
|
||||
// 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
|
||||
*/
|
||||
@@ -126,9 +149,12 @@ export class CertProvisionScheduler {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return null;
|
||||
|
||||
// Only return if still in backoff
|
||||
// Only return if still in backoff — prune expired entries
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
if (retryAfter.getTime() <= Date.now()) return null;
|
||||
if (retryAfter.getTime() <= Date.now()) {
|
||||
this.backoffCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
failures: entry.failures,
|
||||
@@ -136,41 +162,4 @@ export class CertProvisionScheduler {
|
||||
lastError: entry.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a provision operation for serial execution with stagger delay.
|
||||
* Returns the result of the provision function.
|
||||
*/
|
||||
enqueueProvision<T>(domain: string, fn: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.queue.push({ domain, fn, resolve, reject });
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the stagger queue serially
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const item = this.queue.shift()!;
|
||||
try {
|
||||
logger.log('info', `Processing cert provision for ${item.domain}`);
|
||||
const result = await item.fn();
|
||||
item.resolve(result);
|
||||
} catch (err) {
|
||||
item.reject(err);
|
||||
}
|
||||
|
||||
// Stagger delay between provisions
|
||||
if (this.queue.length > 0) {
|
||||
await new Promise<void>((r) => setTimeout(r, this.staggerDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
+1591
-465
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,58 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { StorageManager } from './storage/index.js';
|
||||
import { AcmeCertDoc } from './db/index.js';
|
||||
|
||||
/**
|
||||
* ICertManager implementation backed by StorageManager.
|
||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||
* 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 {
|
||||
private keyPrefix = '/certs/';
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
constructor() {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||
if (!data) return null;
|
||||
return new plugins.smartacme.Cert(data);
|
||||
}
|
||||
|
||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
validUntil: cert.validUntil,
|
||||
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> {
|
||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||
const doc = await AcmeCertDoc.findByDomain(domainName);
|
||||
if (doc) {
|
||||
await doc.delete();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const keys = await this.storageManager.list(this.keyPrefix);
|
||||
for (const key of keys) {
|
||||
await this.storageManager.delete(key);
|
||||
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,95 @@
|
||||
import { logger } from '../logger.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
import type { IRouteSecurity } from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
export interface ISeedData {
|
||||
profiles?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
security: IRouteSecurity;
|
||||
extendsProfiles?: string[];
|
||||
}>;
|
||||
targets?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
host: string | string[];
|
||||
port: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class DbSeeder {
|
||||
constructor(private referenceResolver: ReferenceResolver) {}
|
||||
|
||||
/**
|
||||
* Check if DB is empty and seed if configured.
|
||||
* Called once during ConfigManagers service startup, after initialize().
|
||||
*/
|
||||
public async seedIfEmpty(
|
||||
seedOnEmpty?: boolean,
|
||||
seedData?: ISeedData,
|
||||
): Promise<void> {
|
||||
if (!seedOnEmpty) return;
|
||||
|
||||
const existingProfiles = this.referenceResolver.listProfiles();
|
||||
const existingTargets = this.referenceResolver.listTargets();
|
||||
|
||||
if (existingProfiles.length > 0 || existingTargets.length > 0) {
|
||||
logger.log('info', 'DB already contains profiles/targets, skipping seed');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Seeding database with initial profiles and targets...');
|
||||
|
||||
const profilesToSeed: NonNullable<ISeedData['profiles']> = seedData?.profiles ?? DEFAULT_PROFILES;
|
||||
const targetsToSeed: NonNullable<ISeedData['targets']> = seedData?.targets ?? DEFAULT_TARGETS;
|
||||
|
||||
for (const p of profilesToSeed) {
|
||||
await this.referenceResolver.createProfile({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
security: p.security,
|
||||
extendsProfiles: p.extendsProfiles,
|
||||
createdBy: 'system-seed',
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of targetsToSeed) {
|
||||
await this.referenceResolver.createTarget({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
host: t.host,
|
||||
port: t.port,
|
||||
createdBy: 'system-seed',
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('info', `Seeded ${profilesToSeed.length} profile(s) and ${targetsToSeed.length} target(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||
{
|
||||
name: 'PUBLIC',
|
||||
description: 'Allow all traffic — no IP restrictions',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'STANDARD',
|
||||
description: 'Standard internal access with common private subnets',
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '10.0.0.0/8', '127.0.0.1', '::1'],
|
||||
maxConnections: 1000,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_TARGETS: Array<NonNullable<ISeedData['targets']>[number]> = [
|
||||
{
|
||||
name: 'LOCALHOST',
|
||||
description: 'Local machine on port 443',
|
||||
host: '127.0.0.1',
|
||||
port: 443,
|
||||
},
|
||||
];
|
||||
@@ -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,577 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
|
||||
export class ReferenceResolver {
|
||||
private profiles = new Map<string, ISourceProfile>();
|
||||
private targets = new Map<string, INetworkTarget>();
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadProfiles();
|
||||
await this.loadTargets();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createProfile(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
security: IRouteSecurity;
|
||||
extendsProfiles?: string[];
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const profile: ISourceProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
security: data.security,
|
||||
extendsProfiles: data.extendsProfiles,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.profiles.set(id, profile);
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Created source profile '${profile.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateProfile(
|
||||
id: string,
|
||||
patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
throw new Error(`Source profile '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) profile.name = patch.name;
|
||||
if (patch.description !== undefined) profile.description = patch.description;
|
||||
if (patch.security !== undefined) profile.security = patch.security;
|
||||
if (patch.extendsProfiles !== undefined) profile.extendsProfiles = patch.extendsProfiles;
|
||||
profile.updatedAt = Date.now();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
logger.log('info', `Updated source profile '${profile.name}' (${id})`);
|
||||
|
||||
// Find routes referencing this profile
|
||||
const affectedRouteIds = await this.findRoutesByProfileRef(id);
|
||||
return { affectedRouteIds };
|
||||
}
|
||||
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
return { success: false, message: `Source profile '${id}' not found` };
|
||||
}
|
||||
|
||||
// Check usage
|
||||
const affectedIds = storedRoutes
|
||||
? this.findRoutesByProfileRefSync(id, storedRoutes)
|
||||
: await this.findRoutesByProfileRef(id);
|
||||
|
||||
if (affectedIds.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Profile '${profile.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
const doc = await SourceProfileDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.profiles.delete(id);
|
||||
|
||||
// If force-deleting with referencing routes, clear refs but keep resolved values
|
||||
if (affectedIds.length > 0) {
|
||||
await this.clearProfileRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getProfile(id: string): ISourceProfile | undefined {
|
||||
return this.profiles.get(id);
|
||||
}
|
||||
|
||||
public getProfileByName(name: string): ISourceProfile | undefined {
|
||||
for (const profile of this.profiles.values()) {
|
||||
if (profile.name === name) return profile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listProfiles(): ISourceProfile[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
|
||||
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||
for (const profile of this.profiles.values()) {
|
||||
usage.set(profile.id, []);
|
||||
}
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
const ref = stored.metadata?.sourceProfileRef;
|
||||
if (ref && usage.has(ref)) {
|
||||
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Target CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createTarget(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
host: string | string[];
|
||||
port: number;
|
||||
createdBy: string;
|
||||
}): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const target: INetworkTarget = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
};
|
||||
|
||||
this.targets.set(id, target);
|
||||
await this.persistTarget(target);
|
||||
logger.log('info', `Created network target '${target.name}' (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateTarget(
|
||||
id: string,
|
||||
patch: Partial<Omit<INetworkTarget, 'id' | 'createdAt' | 'createdBy'>>,
|
||||
): Promise<{ affectedRouteIds: string[] }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
throw new Error(`Network target '${id}' not found`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) target.name = patch.name;
|
||||
if (patch.description !== undefined) target.description = patch.description;
|
||||
if (patch.host !== undefined) target.host = patch.host;
|
||||
if (patch.port !== undefined) target.port = patch.port;
|
||||
target.updatedAt = Date.now();
|
||||
|
||||
await this.persistTarget(target);
|
||||
logger.log('info', `Updated network target '${target.name}' (${id})`);
|
||||
|
||||
const affectedRouteIds = await this.findRoutesByTargetRef(id);
|
||||
return { affectedRouteIds };
|
||||
}
|
||||
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
return { success: false, message: `Network target '${id}' not found` };
|
||||
}
|
||||
|
||||
const affectedIds = storedRoutes
|
||||
? this.findRoutesByTargetRefSync(id, storedRoutes)
|
||||
: await this.findRoutesByTargetRef(id);
|
||||
|
||||
if (affectedIds.length > 0 && !force) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Target '${target.name}' is in use by ${affectedIds.length} route(s). Use force=true to delete.`,
|
||||
};
|
||||
}
|
||||
|
||||
const doc = await NetworkTargetDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
this.targets.delete(id);
|
||||
|
||||
if (affectedIds.length > 0) {
|
||||
await this.clearTargetRefsOnRoutes(affectedIds);
|
||||
logger.log('warn', `Force-deleted target '${target.name}'; cleared refs on ${affectedIds.length} route(s)`);
|
||||
} else {
|
||||
logger.log('info', `Deleted network target '${target.name}' (${id})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public getTarget(id: string): INetworkTarget | undefined {
|
||||
return this.targets.get(id);
|
||||
}
|
||||
|
||||
public getTargetByName(name: string): INetworkTarget | undefined {
|
||||
for (const target of this.targets.values()) {
|
||||
if (target.name === name) return target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public listTargets(): INetworkTarget[] {
|
||||
return [...this.targets.values()];
|
||||
}
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
routes.push({ id: routeId, routeName: stored.route.name || routeId });
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Resolution
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
metadata?: IRouteMetadata,
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
// Merge: profile provides base, route's inline values override
|
||||
route = {
|
||||
...route,
|
||||
security: this.mergeSecurityFields(resolvedSecurity, route.security),
|
||||
};
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
const target = this.targets.get(resolvedMetadata.networkTargetRef);
|
||||
if (target) {
|
||||
const hosts = Array.isArray(target.host) ? target.host : [target.host];
|
||||
route = {
|
||||
...route,
|
||||
action: {
|
||||
...route.action,
|
||||
targets: hosts.map((h) => ({
|
||||
host: h,
|
||||
port: target.port,
|
||||
})),
|
||||
},
|
||||
};
|
||||
resolvedMetadata.networkTargetName = target.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Network target '${resolvedMetadata.networkTargetRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
return { route, metadata: resolvedMetadata };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reference lookup helpers
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
ids.push(routeId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveSourceProfile(
|
||||
profileId: string,
|
||||
visited: Set<string> = new Set(),
|
||||
depth: number = 0,
|
||||
): IRouteSecurity | null {
|
||||
if (depth > MAX_INHERITANCE_DEPTH) {
|
||||
logger.log('warn', `Max inheritance depth (${MAX_INHERITANCE_DEPTH}) exceeded resolving profile '${profileId}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visited.has(profileId)) {
|
||||
logger.log('warn', `Circular inheritance detected for profile '${profileId}'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) return null;
|
||||
|
||||
visited.add(profileId);
|
||||
|
||||
// Start with an empty base
|
||||
let baseSecurity: IRouteSecurity = {};
|
||||
|
||||
// Resolve parent profiles first (top-down, later overrides earlier)
|
||||
if (profile.extendsProfiles?.length) {
|
||||
for (const parentId of profile.extendsProfiles) {
|
||||
const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
|
||||
if (parentSecurity) {
|
||||
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply this profile's security on top
|
||||
return this.mergeSecurityFields(baseSecurity, profile.security);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two IRouteSecurity objects.
|
||||
* `override` values take precedence over `base` values.
|
||||
* For ipAllowList/ipBlockList: union arrays and deduplicate.
|
||||
* For scalar/object fields: override wins if present.
|
||||
*/
|
||||
private mergeSecurityFields(
|
||||
base: IRouteSecurity | undefined,
|
||||
override: IRouteSecurity | undefined,
|
||||
): IRouteSecurity {
|
||||
if (!base && !override) return {};
|
||||
if (!base) return { ...override };
|
||||
if (!override) return { ...base };
|
||||
|
||||
const merged: IRouteSecurity = { ...base };
|
||||
|
||||
// IP lists: union
|
||||
if (override.ipAllowList || base.ipAllowList) {
|
||||
merged.ipAllowList = [...new Set([
|
||||
...(base.ipAllowList || []),
|
||||
...(override.ipAllowList || []),
|
||||
])];
|
||||
}
|
||||
|
||||
if (override.ipBlockList || base.ipBlockList) {
|
||||
merged.ipBlockList = [...new Set([
|
||||
...(base.ipBlockList || []),
|
||||
...(override.ipBlockList || []),
|
||||
])];
|
||||
}
|
||||
|
||||
// Scalar/object fields: override wins
|
||||
if (override.maxConnections !== undefined) merged.maxConnections = override.maxConnections;
|
||||
if (override.rateLimit !== undefined) merged.rateLimit = override.rateLimit;
|
||||
if (override.authentication !== undefined) merged.authentication = override.authentication;
|
||||
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
|
||||
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadProfiles(): Promise<void> {
|
||||
const docs = await SourceProfileDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.profiles.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
security: doc.security,
|
||||
extendsProfiles: doc.extendsProfiles,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.profiles.size > 0) {
|
||||
logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTargets(): Promise<void> {
|
||||
const docs = await NetworkTargetDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.targets.set(doc.id, {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
host: doc.host,
|
||||
port: doc.port,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.targets.size > 0) {
|
||||
logger.log('info', `Loaded ${this.targets.size} network target(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistProfile(profile: ISourceProfile): Promise<void> {
|
||||
const existingDoc = await SourceProfileDoc.findById(profile.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = profile.name;
|
||||
existingDoc.description = profile.description;
|
||||
existingDoc.security = profile.security;
|
||||
existingDoc.extendsProfiles = profile.extendsProfiles;
|
||||
existingDoc.updatedAt = profile.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new SourceProfileDoc();
|
||||
doc.id = profile.id;
|
||||
doc.name = profile.name;
|
||||
doc.description = profile.description;
|
||||
doc.security = profile.security;
|
||||
doc.extendsProfiles = profile.extendsProfiles;
|
||||
doc.createdAt = profile.createdAt;
|
||||
doc.updatedAt = profile.updatedAt;
|
||||
doc.createdBy = profile.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async persistTarget(target: INetworkTarget): Promise<void> {
|
||||
const existingDoc = await NetworkTargetDoc.findById(target.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.name = target.name;
|
||||
existingDoc.description = target.description;
|
||||
existingDoc.host = target.host;
|
||||
existingDoc.port = target.port;
|
||||
existingDoc.updatedAt = target.updatedAt;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new NetworkTargetDoc();
|
||||
doc.id = target.id;
|
||||
doc.name = target.name;
|
||||
doc.description = target.description;
|
||||
doc.host = target.host;
|
||||
doc.port = target.port;
|
||||
doc.createdAt = target.createdAt;
|
||||
doc.updatedAt = target.updatedAt;
|
||||
doc.createdBy = target.createdBy;
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: ref cleanup on force-delete
|
||||
// =========================================================================
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
sourceProfileRef: undefined,
|
||||
sourceProfileName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
networkTargetRef: undefined,
|
||||
networkTargetName: undefined,
|
||||
};
|
||||
doc.updatedAt = Date.now();
|
||||
await doc.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
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?: () => 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] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: IDcRouterRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
metadata?: IRouteMetadata,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `route-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Resolve references if metadata has refs and resolver is available
|
||||
let resolvedMetadata = this.normalizeRouteMetadata(metadata);
|
||||
if (resolvedMetadata && this.referenceResolver) {
|
||||
const resolved = this.referenceResolver.resolveRoute(route, resolvedMetadata);
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: {
|
||||
route?: Partial<IDcRouterRouteConfig>;
|
||||
enabled?: boolean;
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<IRouteMutationResult> {
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
&& patch.metadata === undefined;
|
||||
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'System routes are managed by the system and can only be toggled',
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.route) {
|
||||
const mergedAction = patch.route.action
|
||||
? { ...stored.route.action, ...patch.route.action }
|
||||
: stored.route.action;
|
||||
// Handle explicit null to remove nested action properties (e.g., tls: null)
|
||||
if (patch.route.action) {
|
||||
for (const [key, val] of Object.entries(patch.route.action)) {
|
||||
if (val === null) {
|
||||
delete (mergedAction as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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),
|
||||
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.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: 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: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
|
||||
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||
if (this.onRoutesApplied) {
|
||||
await this.onRoutesApplied(enabledRoutes);
|
||||
}
|
||||
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||
});
|
||||
}
|
||||
|
||||
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
let preparedRoute = route;
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
|
||||
if (http3Config?.enabled !== false) {
|
||||
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||
}
|
||||
|
||||
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||
}
|
||||
|
||||
private injectVpnSecurity(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const 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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1,2 +1,8 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
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 { DbSeeder } from './classes.db-seeder.js';
|
||||
export { TargetProfileManager } from './classes.target-profile-manager.js';
|
||||
|
||||
@@ -170,7 +170,7 @@ export class ConfigValidator {
|
||||
} 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}`));
|
||||
errors.push(...itemResult.errors!.map(err => `${key}[${i}].${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export class ConfigValidator {
|
||||
if (rules.schema) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||
errors.push(...nestedResult.errors!.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
@@ -233,8 +233,8 @@ export class ConfigValidator {
|
||||
|
||||
// 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
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items!.schema!) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class ConfigValidator {
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||
`Configuration validation failed: ${result.errors!.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { CacheDb } from './classes.cachedb.js';
|
||||
import { DcRouterDb } from './classes.dcrouter-db.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
@@ -26,10 +26,10 @@ export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
private dcRouterDb: DcRouterDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
constructor(dcRouterDb: DcRouterDb, options: ICacheCleanerOptions = {}) {
|
||||
this.dcRouterDb = dcRouterDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
@@ -48,14 +48,14 @@ export class CacheCleaner {
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
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) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
this.runCleanup().catch((error: unknown) => {
|
||||
logger.log('error', `Cache cleanup failed: ${(error as Error).message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
@@ -86,8 +86,8 @@ export class CacheCleaner {
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
if (!this.dcRouterDb.isReady()) {
|
||||
logger.log('warn', 'DcRouterDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ export class CacheCleaner {
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Cache cleanup error: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -138,14 +138,14 @@ export class CacheCleaner {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||
} catch (deleteError: unknown) {
|
||||
logger.log('warn', `Failed to delete expired document: ${(deleteError as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
logger.log('error', `Error cleaning collection: ${(error as Error).message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export abstract class CachedDocument<T extends CachedDocument<T>> extends plugin
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt: Date;
|
||||
public expiresAt!: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
@@ -10,7 +10,7 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'faile
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
@@ -35,55 +35,55 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
public messageId!: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
public from!: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
public to!: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
public cc!: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
public bcc!: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
public subject!: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
public rawContent!: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
public status!: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
@@ -101,25 +101,25 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
public nextAttempt!: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
public lastError!: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
public deliveredAt!: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
public senderDomain!: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
@@ -131,7 +131,7 @@ export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
public routeData!: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
+12
-12
@@ -1,11 +1,11 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
@@ -45,61 +45,61 @@ export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
public ipAddress!: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
public score!: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
public isSpam!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
public isProxy!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
public isTor!: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
public isVPN!: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
public country!: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
public asn!: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
public org!: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
public blacklists!: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CertBackoffDoc extends plugins.smartdata.SmartDataDbDoc<CertBackoffDoc, CertBackoffDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domain!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public failures!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastFailure!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public retryAfter!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<CertBackoffDoc | null> {
|
||||
return await CertBackoffDoc.getInstance({ domain });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<CertBackoffDoc[]> {
|
||||
return await CertBackoffDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderStatus,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsProviderType;
|
||||
|
||||
/**
|
||||
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public credentials!: TDnsProviderCredentials;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TDnsProviderStatus = 'untested';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||
return await DnsProviderDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({ type });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domainId!: string;
|
||||
|
||||
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsRecordType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ttl: number = 300;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public proxied?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDnsRecordSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerRecordId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||
return await DnsRecordDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({ domainId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
/** FQDN — kept lowercased on save. */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDomainSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public authoritative: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nameservers?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalZoneId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSyncedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({ providerId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
IEmailDomainDkim,
|
||||
IEmailDomainRateLimits,
|
||||
IEmailDomainDnsStatus,
|
||||
} from '../../../ts_interfaces/data/email-domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domain: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public linkedDomainId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public subdomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dkim!: IEmailDomainDkim;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rateLimits?: IEmailDomainRateLimits;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public dnsStatus!: IEmailDomainDnsStatus;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||
return await EmailDomainDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc<GatewayClientDoc, GatewayClientDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TGatewayClientType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public hostnamePatterns: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public allowedRouteTargets: NonNullable<IApiTokenPolicy['allowedRouteTargets']> = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public capabilities: NonNullable<IApiTokenPolicy['capabilities']> = {};
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<GatewayClientDoc | null> {
|
||||
return await GatewayClientDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<GatewayClientDoc[]> {
|
||||
return await GatewayClientDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IIpIntelligenceRecord } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc<IpIntelligenceDoc, IpIntelligenceDoc> implements IIpIntelligenceRecord {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public asnOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantOrg: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public registrantCountry: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkRange: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public networkCidrs: string[] | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public abuseContact: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public countryCode: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public city: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public latitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public longitude: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accuracyRadius: number | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public timezone: string | null = null;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public firstSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSeenAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public seenCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByIp(ipAddress: string): Promise<IpIntelligenceDoc | null> {
|
||||
return await IpIntelligenceDoc.getInstance({ ipAddress });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<IpIntelligenceDoc[]> {
|
||||
return await IpIntelligenceDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class NetworkTargetDoc extends plugins.smartdata.SmartDataDbDoc<NetworkTargetDoc, NetworkTargetDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public host!: string | string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public port!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<NetworkTargetDoc | null> {
|
||||
return await NetworkTargetDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<NetworkTargetDoc | null> {
|
||||
return await NetworkTargetDoc.getInstance({ name });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<NetworkTargetDoc[]> {
|
||||
return await NetworkTargetDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class ProxyCertDoc extends plugins.smartdata.SmartDataDbDoc<ProxyCertDoc, ProxyCertDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public domain!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public publicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public privateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ca!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public validUntil!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public validFrom!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findByDomain(domain: string): Promise<ProxyCertDoc | null> {
|
||||
return await ProxyCertDoc.getInstance({ domain });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<ProxyCertDoc[]> {
|
||||
return await ProxyCertDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressEdgeDoc, RemoteIngressEdgeDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public secret!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public listenPorts!: number[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public listenPortsUdp!: number[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public autoDerivePorts!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tags!: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RemoteIngressEdgeDoc | null> {
|
||||
return await RemoteIngressEdgeDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RemoteIngressEdgeDoc[]> {
|
||||
return await RemoteIngressEdgeDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<RemoteIngressEdgeDoc[]> {
|
||||
return await RemoteIngressEdgeDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public route!: IDcRouterRouteConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public systemKey?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ 'route.name': name });
|
||||
}
|
||||
|
||||
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
||||
return await RouteDoc.getInstances({ origin });
|
||||
}
|
||||
|
||||
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ systemKey });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityBlockRule, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityBlockRuleDoc extends plugins.smartdata.SmartDataDbDoc<SecurityBlockRuleDoc, SecurityBlockRuleDoc> implements ISecurityBlockRule {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TSecurityBlockRuleType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public matchMode?: TSecurityBlockRuleMatchMode;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public reason?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = Date.now();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy: string = 'system';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SecurityBlockRuleDoc | null> {
|
||||
return await SecurityBlockRuleDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findEnabled(): Promise<SecurityBlockRuleDoc[]> {
|
||||
return await SecurityBlockRuleDoc.getInstances({ enabled: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ISecurityPolicyAuditEvent } from '../../../ts_interfaces/data/security-policy.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SecurityPolicyAuditDoc extends plugins.smartdata.SmartDataDbDoc<SecurityPolicyAuditDoc, SecurityPolicyAuditDoc> implements ISecurityPolicyAuditEvent {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public action!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public actor!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public details!: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: number = Date.now();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findRecent(limit = 100): Promise<SecurityPolicyAuditDoc[]> {
|
||||
const docs = await SecurityPolicyAuditDoc.getInstances({});
|
||||
return docs.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IRouteSecurity } from '../../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public security!: IRouteSecurity;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public extendsProfiles?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<SourceProfileDoc | null> {
|
||||
return await SourceProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<SourceProfileDoc[]> {
|
||||
return await SourceProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domains?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public targets?: ITargetProfileTarget[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public routeRefs?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public allowRoutesByClientSourceIp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<TargetProfileDoc | null> {
|
||||
return await TargetProfileDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<TargetProfileDoc[]> {
|
||||
return await TargetProfileDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
export interface IMacVlanMapping {
|
||||
mac: string;
|
||||
vlan: number;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VlanMappingsDoc extends plugins.smartdata.SmartDataDbDoc<VlanMappingsDoc, VlanMappingsDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'vlan-mappings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public mappings!: IMacVlanMapping[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.mappings = [];
|
||||
}
|
||||
|
||||
public static async load(): Promise<VlanMappingsDoc | null> {
|
||||
return await VlanMappingsDoc.getInstance({ configId: 'vlan-mappings' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc, VpnClientDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public clientId!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled!: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public targetProfileIds?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public assignedIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPrivateKey?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationAllowList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public destinationBlockList?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useHostIp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useDhcp?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public staticIp?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public forceVlan?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public vlanId?: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<VpnClientDoc[]> {
|
||||
return await VpnClientDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class VpnServerKeysDoc extends plugins.smartdata.SmartDataDbDoc<VpnServerKeysDoc, VpnServerKeysDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'vpn-server-keys';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePrivateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public noisePublicKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPrivateKey!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public wgPublicKey!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<VpnServerKeysDoc | null> {
|
||||
return await VpnServerKeysDoc.getInstance({ configId: 'vpn-server-keys' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Cached/TTL document classes
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
export * from './classes.ip-intelligence.doc.js';
|
||||
export * from './classes.security-block-rule.doc.js';
|
||||
export * from './classes.security-policy-audit.doc.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.gateway-client.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
export * from './classes.network-target.doc.js';
|
||||
|
||||
// VPN document classes
|
||||
export * from './classes.vpn-server-keys.doc.js';
|
||||
export * from './classes.vpn-client.doc.js';
|
||||
|
||||
// Certificate document classes
|
||||
export * from './classes.acme-cert.doc.js';
|
||||
export * from './classes.proxy-cert.doc.js';
|
||||
export * from './classes.cert-backoff.doc.js';
|
||||
|
||||
// Remote ingress document classes
|
||||
export * from './classes.remote-ingress-edge.doc.js';
|
||||
|
||||
// RADIUS document classes
|
||||
export * from './classes.vlan-mappings.doc.js';
|
||||
export * from './classes.accounting-session.doc.js';
|
||||
|
||||
// DNS / Domain management document classes
|
||||
export * from './classes.dns-provider.doc.js';
|
||||
export * from './classes.domain.doc.js';
|
||||
export * from './classes.dns-record.doc.js';
|
||||
|
||||
// ACME configuration (singleton)
|
||||
export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
@@ -1,6 +1,10 @@
|
||||
// Core cache infrastructure
|
||||
export * from './classes.cachedb.js';
|
||||
// Unified database manager
|
||||
export * from './classes.dcrouter-db.js';
|
||||
|
||||
// TTL base class and constants
|
||||
export * from './classes.cached.document.js';
|
||||
|
||||
// Cache cleaner
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './manager.dns.js';
|
||||
export * from './providers/index.js';
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user